Update install_rtorrent.py

This commit is contained in:
gru
2026-04-20 10:12:47 +02:00
parent 757c1d516e
commit d651aa7101

View File

@@ -1,21 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import itertools
import os import os
import pwd import pwd
import re import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading
import time
from pathlib import Path from pathlib import Path
DEFAULT_USER = "rtorrent" DEFAULT_USER = "rtorrent"
DEFAULT_GROUP = "rtorrent" DEFAULT_GROUP = "rtorrent"
DEFAULT_HOME = "/home/rtorrent" DEFAULT_HOME = "/home/rtorrent"
DEFAULT_BASE_DIR = "/opt/rtorrent_build" DEFAULT_BASE_DIR = "/opt/rtorrent_build"
DEFAULT_LIBTORRENT_REF = "master" DEFAULT_LIBTORRENT_REF = "v0.15.7"
DEFAULT_RTORRENT_REF = "master" DEFAULT_RTORRENT_REF = "v0.15.7"
DEFAULT_XMLRPC_REF = "latest-stable" DEFAULT_XMLRPC_REF = "latest-stable"
DEFAULT_CARES_REF = "1.34.6"
DEFAULT_CURL_REF = "8.19.0"
DEFAULT_SERVICE_PATH = "/etc/systemd/system/rtorrent@.service" DEFAULT_SERVICE_PATH = "/etc/systemd/system/rtorrent@.service"
DEFAULT_SCGI_PORT = 5000 DEFAULT_SCGI_PORT = 5000
DEFAULT_TORRENT_PORT = 51300 DEFAULT_TORRENT_PORT = 51300
@@ -25,29 +29,61 @@ class InstallError(Exception):
pass pass
def run(cmd, *, cwd=None, env=None, check=True): class Spinner:
print(f"\n>>> {' '.join(cmd)}") FRAMES = ["|", "/", "-", "\\"]
result = subprocess.run(cmd, cwd=cwd, env=env, check=False)
if check and result.returncode != 0: def __init__(self, message, enabled=True):
raise InstallError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}") self.message = message
return result.returncode self.enabled = enabled and sys.stdout.isatty()
self._stop = threading.Event()
self._thread = None
self._start = None
def _run(self):
for frame in itertools.cycle(self.FRAMES):
if self._stop.is_set():
break
elapsed = time.time() - self._start
sys.stdout.write(f"\r[ {frame} ] {self.message} ({elapsed:.1f}s)")
sys.stdout.flush()
time.sleep(0.12)
def __enter__(self):
self._start = time.time()
if self.enabled:
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
return self
def __exit__(self, exc_type, exc, tb):
elapsed = time.time() - self._start
if self.enabled:
self._stop.set()
self._thread.join(timeout=0.5)
status = "ERR" if exc else "OK "
sys.stdout.write(f"\r[ {status} ] {self.message} ({elapsed:.1f}s)\n")
sys.stdout.flush()
def capture(cmd, *, cwd=None, env=None, check=True): def run(cmd, *, cwd=None, env=None, check=True, debug=False, capture_output=False):
result = subprocess.run( if debug:
cmd, print(f"\n>>> {' '.join(cmd)}")
cwd=cwd, stdout = subprocess.PIPE if capture_output else (None if debug else subprocess.DEVNULL)
env=env, stderr = subprocess.PIPE if capture_output else (None if debug else subprocess.DEVNULL)
check=False, result = subprocess.run(cmd, cwd=cwd, env=env, check=False, text=True, stdout=stdout, stderr=stderr)
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if check and result.returncode != 0: if check and result.returncode != 0:
raise InstallError( stderr_text = ""
f"Command failed with exit code {result.returncode}: {' '.join(cmd)}\n{result.stderr.strip()}" if capture_output and result.stderr:
) stderr_text = f"\n{result.stderr.strip()}"
return result.stdout.strip() raise InstallError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}{stderr_text}")
return result
def capture(cmd, **kwargs):
result = run(cmd, capture_output=True, **kwargs)
out = (result.stdout or "").strip()
err = (result.stderr or "").strip()
return out if out else err
def require_root(): def require_root():
@@ -63,8 +99,8 @@ def detect_debian():
data = {} data = {}
for line in os_release.read_text().splitlines(): for line in os_release.read_text().splitlines():
if "=" in line: if "=" in line:
key, value = line.split("=", 1) k, v = line.split("=", 1)
data[key] = value.strip().strip('"') data[k] = v.strip().strip('"')
distro_id = data.get("ID", "").lower() distro_id = data.get("ID", "").lower()
distro_like = data.get("ID_LIKE", "").lower() distro_like = data.get("ID_LIKE", "").lower()
@@ -99,11 +135,11 @@ def parse_version(version):
return tuple(parts[:3]) if parts else (0,) return tuple(parts[:3]) if parts else (0,)
def ensure_packages(packages): def ensure_packages(packages, *, debug=False):
print("Updating APT metadata...") print("Updating APT metadata...")
run(["apt-get", "update"]) run(["apt-get", "update"], debug=debug)
print("Installing build and runtime dependencies...") print("Installing build and runtime dependencies...")
run(["apt-get", "install", "-y", *packages]) run(["apt-get", "install", "-y", *packages], debug=debug)
def ensure_dir(path, owner=None, group=None, mode=None): def ensure_dir(path, owner=None, group=None, mode=None):
@@ -114,14 +150,14 @@ def ensure_dir(path, owner=None, group=None, mode=None):
os.chmod(path, mode) os.chmod(path, mode)
def create_system_user(user, group, home, assume_yes=False): def create_system_user(user, group, home, assume_yes=False, debug=False):
try: try:
pwd.getpwnam(user) pwd.getpwnam(user)
print(f"User '{user}' already exists.") print(f"User '{user}' already exists.")
except KeyError: except KeyError:
if not prompt_yes_no(f"Create system user '{user}' with home '{home}'?", default=True, assume_yes=assume_yes): if not prompt_yes_no(f"Create system user '{user}' with home '{home}'?", default=True, assume_yes=assume_yes):
raise InstallError("User creation declined.") raise InstallError("User creation declined.")
run(["groupadd", "--system", group], check=False) run(["groupadd", "--system", group], check=False, debug=debug)
run([ run([
"useradd", "useradd",
"--system", "--system",
@@ -130,24 +166,31 @@ def create_system_user(user, group, home, assume_yes=False):
"--shell", "/usr/sbin/nologin", "--shell", "/usr/sbin/nologin",
"--gid", group, "--gid", group,
user, user,
]) ], debug=debug)
def clone_or_update_repo(repo_url, repo_dir, ref): def clone_or_update_repo(repo_url, repo_dir, ref, *, debug=False):
repo_dir = Path(repo_dir) repo_dir = Path(repo_dir)
if not repo_dir.exists(): if not repo_dir.exists():
run(["git", "clone", repo_url, str(repo_dir)]) with Spinner(f"Cloning {repo_dir.name}", enabled=not debug):
run(["git", "clone", repo_url, str(repo_dir)], debug=debug)
else: else:
print(f"Repository already exists: {repo_dir}") print(f"Repository already exists: {repo_dir}")
run(["git", "fetch", "--all", "--tags"], cwd=str(repo_dir)) with Spinner(f"Checking out {repo_dir.name} -> {ref}", enabled=not debug):
run(["git", "fetch", "--all", "--tags"], cwd=str(repo_dir), debug=debug)
run(["git", "fetch", "--all", "--tags"], cwd=str(repo_dir)) run(["git", "checkout", ref], cwd=str(repo_dir), debug=debug)
run(["git", "checkout", ref], cwd=str(repo_dir)) run(["git", "pull", "--ff-only"], cwd=str(repo_dir), check=False, debug=debug)
run(["git", "pull", "--ff-only"], cwd=str(repo_dir), check=False)
def download_file(url, destination): def download_file(url, destination, *, debug=False):
run(["curl", "-fL", url, "-o", str(destination)]) run(["curl", "-fL", url, "-o", str(destination)], debug=debug)
def extract_tarball(tarball, destination, *, debug=False):
if destination.exists():
shutil.rmtree(destination)
destination.mkdir(parents=True, exist_ok=True)
run(["tar", "-xzf", str(tarball), "-C", str(destination), "--strip-components=1"], debug=debug)
def find_xmlrpc_config(base_dir, preferred_install=None): def find_xmlrpc_config(base_dir, preferred_install=None):
@@ -180,20 +223,54 @@ def find_xmlrpc_config(base_dir, preferred_install=None):
return unique[0] if unique else None return unique[0] if unique else None
def verify_xmlrpc_environment(xmlrpc_config_path): def verify_xmlrpc_environment(xmlrpc_config_path, *, debug=False):
tool = Path(xmlrpc_config_path) tool = Path(xmlrpc_config_path)
if not tool.exists(): if not tool.exists():
raise InstallError(f"xmlrpc-c-config was not found: {tool}") raise InstallError(f"xmlrpc-c-config was not found: {tool}")
version = capture([str(tool), "--version"], check=True, debug=debug)
version = capture([str(tool), "--version"], check=True)
if parse_version(version) < (1, 11): if parse_version(version) < (1, 11):
raise InstallError( raise InstallError(f"xmlrpc-c version is too old: {version}. Version 1.11 or newer is required.")
f"xmlrpc-c version is too old: {version}. Version 1.11 or newer is required."
)
print(f"Detected xmlrpc-c version: {version} ({tool})") print(f"Detected xmlrpc-c version: {version} ({tool})")
return version
def build_xmlrpc_c(base_dir, xmlrpc_ref): def build_env(*prefixes, extra_env=None):
env = os.environ.copy()
include_dirs = []
lib_dirs = []
pkg_dirs = []
bin_dirs = []
for prefix in prefixes:
if not prefix:
continue
prefix = str(prefix)
include_dirs.append(f"-I{prefix}/include")
lib_dirs.append(f"-L{prefix}/lib")
pkg_dirs.append(f"{prefix}/lib/pkgconfig")
bin_dirs.append(f"{prefix}/bin")
if include_dirs:
env["CPPFLAGS"] = " ".join(include_dirs + [env.get("CPPFLAGS", "")]).strip()
env["CFLAGS"] = " ".join(include_dirs + [env.get("CFLAGS", "")]).strip()
if lib_dirs:
rpaths = [f"-Wl,-rpath,{d[2:]}" for d in lib_dirs]
env["LDFLAGS"] = " ".join(lib_dirs + rpaths + [env.get("LDFLAGS", "")]).strip()
if pkg_dirs:
env["PKG_CONFIG_PATH"] = ":".join(pkg_dirs + ([env.get("PKG_CONFIG_PATH")] if env.get("PKG_CONFIG_PATH") else []))
if bin_dirs:
env["PATH"] = ":".join(bin_dirs + [env.get("PATH", "")])
if extra_env:
env.update(extra_env)
return env
def build_xmlrpc_c(base_dir, xmlrpc_ref, *, debug=False):
source_root = Path(base_dir) / "xmlrpc-c-src" source_root = Path(base_dir) / "xmlrpc-c-src"
install_dir = Path(base_dir) / "xmlrpc-c_install" install_dir = Path(base_dir) / "xmlrpc-c_install"
build_root = Path(base_dir) / "_sources" build_root = Path(base_dir) / "_sources"
@@ -202,13 +279,10 @@ def build_xmlrpc_c(base_dir, xmlrpc_ref):
existing_config = find_xmlrpc_config(base_dir, install_dir) existing_config = find_xmlrpc_config(base_dir, install_dir)
if existing_config and str(existing_config).startswith(str(install_dir.resolve())): if existing_config and str(existing_config).startswith(str(install_dir.resolve())):
print(f"Reusing existing xmlrpc-c installation: {existing_config}") print(f"Reusing existing xmlrpc-c installation: {existing_config}")
verify_xmlrpc_environment(existing_config) version = verify_xmlrpc_environment(existing_config, debug=debug)
return install_dir return install_dir, version
ensure_dir(build_root) ensure_dir(build_root)
if source_root.exists():
shutil.rmtree(source_root)
source_root.mkdir(parents=True, exist_ok=True)
if xmlrpc_ref == "latest-stable": if xmlrpc_ref == "latest-stable":
url = "https://sourceforge.net/projects/xmlrpc-c/files/latest/download" url = "https://sourceforge.net/projects/xmlrpc-c/files/latest/download"
@@ -220,89 +294,170 @@ def build_xmlrpc_c(base_dir, xmlrpc_ref):
else: else:
url = xmlrpc_ref url = xmlrpc_ref
print(f"Downloading xmlrpc-c from: {url}") with Spinner("Downloading xmlrpc-c", enabled=not debug):
download_file(url, tarball) download_file(url, tarball, debug=debug)
run(["tar", "-xzf", str(tarball), "-C", str(source_root), "--strip-components=1"]) extract_tarball(tarball, source_root, debug=debug)
run(["./configure", f"--prefix={install_dir}"], cwd=str(source_root)) with Spinner("Configuring xmlrpc-c", enabled=not debug):
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root)) run(["./configure", f"--prefix={install_dir}"], cwd=str(source_root), debug=debug)
run(["make", "install"], cwd=str(source_root)) with Spinner("Building xmlrpc-c", enabled=not debug):
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root), debug=debug)
with Spinner("Installing xmlrpc-c", enabled=not debug):
run(["make", "install"], cwd=str(source_root), debug=debug)
xmlrpc_config = find_xmlrpc_config(base_dir, install_dir) xmlrpc_config = find_xmlrpc_config(base_dir, install_dir)
if not xmlrpc_config or not str(xmlrpc_config).startswith(str(install_dir.resolve())): if not xmlrpc_config or not str(xmlrpc_config).startswith(str(install_dir.resolve())):
raise InstallError( raise InstallError(f"Custom xmlrpc-c build finished, but xmlrpc-c-config was not found under {install_dir}.")
f"Custom xmlrpc-c build finished, but xmlrpc-c-config was not found under {install_dir}." version = verify_xmlrpc_environment(xmlrpc_config, debug=debug)
) return install_dir, version
verify_xmlrpc_environment(xmlrpc_config)
return install_dir
def build_libtorrent(base_dir, libtorrent_ref): def build_cares(base_dir, cares_version, *, debug=False):
source_root = Path(base_dir) / "c-ares-src"
install_dir = Path(base_dir) / "c-ares_install"
build_root = Path(base_dir) / "_sources"
tarball = build_root / f"c-ares-{cares_version}.tar.gz"
url = f"https://github.com/c-ares/c-ares/releases/download/v{cares_version}/c-ares-{cares_version}.tar.gz"
ensure_dir(build_root)
with Spinner("Downloading c-ares", enabled=not debug):
download_file(url, tarball, debug=debug)
extract_tarball(tarball, source_root, debug=debug)
with Spinner("Configuring c-ares", enabled=not debug):
run([
"cmake",
"-S", str(source_root),
"-B", str(source_root / "build"),
f"-DCMAKE_INSTALL_PREFIX={install_dir}",
"-DCARES_SHARED=ON",
"-DCARES_STATIC=OFF",
"-DCMAKE_BUILD_TYPE=Release",
], debug=debug)
with Spinner("Building c-ares", enabled=not debug):
run(["cmake", "--build", str(source_root / "build"), "--parallel", str(os.cpu_count() or 1)], debug=debug)
with Spinner("Installing c-ares", enabled=not debug):
run(["cmake", "--install", str(source_root / "build")], debug=debug)
return install_dir, cares_version
def build_curl(base_dir, curl_version, cares_install, *, debug=False):
source_root = Path(base_dir) / "curl-src"
install_dir = Path(base_dir) / "curl_install"
build_root = Path(base_dir) / "_sources"
tarball = build_root / f"curl-{curl_version}.tar.gz"
url = f"https://curl.se/download/curl-{curl_version}.tar.gz"
ensure_dir(build_root)
with Spinner("Downloading curl", enabled=not debug):
download_file(url, tarball, debug=debug)
extract_tarball(tarball, source_root, debug=debug)
env = build_env(cares_install)
buildconf_script = source_root / "buildconf"
with Spinner("Preparing curl build system", enabled=not debug):
if buildconf_script.exists():
run(["./buildconf"], cwd=str(source_root), env=env, debug=debug)
run(["make", "distclean"], cwd=str(source_root), env=env, check=False, debug=debug)
with Spinner("Configuring curl with c-ares", enabled=not debug):
run([
"./configure",
f"--prefix={install_dir}",
"--with-openssl",
f"--enable-ares={cares_install}",
"--disable-static",
"--enable-shared",
], cwd=str(source_root), env=env, debug=debug)
with Spinner("Building curl", enabled=not debug):
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root), env=env, debug=debug)
with Spinner("Installing curl", enabled=not debug):
run(["make", "install"], cwd=str(source_root), env=env, debug=debug)
version = capture([str(install_dir / "bin" / "curl"), "--version"], env=build_env(install_dir, cares_install), debug=debug)
return install_dir, version
def build_libtorrent(base_dir, libtorrent_ref, curl_install=None, cares_install=None, *, debug=False):
source_dir = Path(base_dir) / "libtorrent" source_dir = Path(base_dir) / "libtorrent"
install_dir = Path(base_dir) / "libtorrent_install" install_dir = Path(base_dir) / "libtorrent_install"
clone_or_update_repo("https://github.com/rakshasa/libtorrent.git", source_dir, libtorrent_ref, debug=debug)
clone_or_update_repo("https://github.com/rakshasa/libtorrent.git", source_dir, libtorrent_ref) prefixes = []
run(["autoreconf", "-i"], cwd=str(source_dir)) if curl_install:
run(["./configure", f"--prefix={install_dir}"], cwd=str(source_dir)) prefixes.append(curl_install)
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir)) if cares_install:
run(["make", "install"], cwd=str(source_dir)) prefixes.append(cares_install)
return install_dir env = build_env(*prefixes)
configure_cmd = ["./configure", f"--prefix={install_dir}"]
if curl_install:
curl_config = str(Path(curl_install) / "bin" / "curl-config")
env["CURL_CONFIG"] = curl_config
if Path(curl_config).exists():
configure_cmd.append(f"--with-curl={curl_config}")
env["LIBS"] = f"-L{Path(curl_install) / 'lib'} -lcurl " + env.get("LIBS", "")
if cares_install:
env["LIBS"] = f"-L{Path(cares_install) / 'lib'} -lcares " + env.get("LIBS", "")
with Spinner("Preparing libtorrent build system", enabled=not debug):
run(["autoreconf", "-i"], cwd=str(source_dir), env=env, debug=debug)
run(["make", "distclean"], cwd=str(source_dir), env=env, check=False, debug=debug)
with Spinner("Configuring libtorrent", enabled=not debug):
run(configure_cmd, cwd=str(source_dir), env=env, debug=debug)
with Spinner("Building libtorrent", enabled=not debug):
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir), env=env, debug=debug)
with Spinner("Installing libtorrent", enabled=not debug):
run(["make", "install"], cwd=str(source_dir), env=env, debug=debug)
version = capture(["git", "describe", "--tags", "--always"], cwd=str(source_dir), debug=debug)
return install_dir, version
def build_rtorrent(base_dir, rtorrent_ref, libtorrent_install, xmlrpc_install): def build_rtorrent(base_dir, rtorrent_ref, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False):
source_dir = Path(base_dir) / "rtorrent" source_dir = Path(base_dir) / "rtorrent"
install_dir = Path(base_dir) / "rtorrent_install" install_dir = Path(base_dir) / "rtorrent_install"
clone_or_update_repo("https://github.com/rakshasa/rtorrent.git", source_dir, rtorrent_ref) clone_or_update_repo("https://github.com/rakshasa/rtorrent.git", source_dir, rtorrent_ref, debug=debug)
xmlrpc_config = find_xmlrpc_config(base_dir, xmlrpc_install) xmlrpc_config = find_xmlrpc_config(base_dir, xmlrpc_install)
if not xmlrpc_config: if not xmlrpc_config:
raise InstallError( raise InstallError(f"Could not find custom xmlrpc-c-config under {base_dir}.")
f"Could not find custom xmlrpc-c-config under {base_dir}. "
"Build xmlrpc-c first or remove the broken installation and retry."
)
if not str(xmlrpc_config).startswith(str(Path(xmlrpc_install).resolve())): if not str(xmlrpc_config).startswith(str(Path(xmlrpc_install).resolve())):
raise InstallError( raise InstallError(f"Wrong xmlrpc-c-config selected: {xmlrpc_config}. Expected one under: {xmlrpc_install}")
f"Wrong xmlrpc-c-config selected: {xmlrpc_config}. Expected one under: {xmlrpc_install}"
)
verify_xmlrpc_environment(xmlrpc_config) verify_xmlrpc_environment(xmlrpc_config, debug=debug)
env = os.environ.copy() prefixes = [libtorrent_install, xmlrpc_install]
include_flags = f"-I{libtorrent_install}/include -I{xmlrpc_install}/include" if curl_install:
ld_flags = f"-L{libtorrent_install}/lib -L{xmlrpc_install}/lib" prefixes.append(curl_install)
existing_cflags = env.get("CFLAGS", "") if cares_install:
existing_cppflags = env.get("CPPFLAGS", "") prefixes.append(cares_install)
existing_ldflags = env.get("LDFLAGS", "") env = build_env(*prefixes)
env["CFLAGS"] = include_flags + (f" {existing_cflags}" if existing_cflags else "")
env["CPPFLAGS"] = include_flags + (f" {existing_cppflags}" if existing_cppflags else "")
env["LDFLAGS"] = ld_flags + (f" {existing_ldflags}" if existing_ldflags else "")
existing_pkg = env.get("PKG_CONFIG_PATH", "")
pkg_paths = [f"{libtorrent_install}/lib/pkgconfig", f"{xmlrpc_install}/lib/pkgconfig"]
env["PKG_CONFIG_PATH"] = ":".join(pkg_paths + ([existing_pkg] if existing_pkg else []))
env["PATH"] = f"{xmlrpc_config.parent}:" + env.get("PATH", "") env["PATH"] = f"{xmlrpc_config.parent}:" + env.get("PATH", "")
env["XMLRPC_C_CONFIG"] = str(xmlrpc_config) env["XMLRPC_C_CONFIG"] = str(xmlrpc_config)
resolved_xmlrpc_config = capture(["sh", "-c", "command -v xmlrpc-c-config"], env=env, check=False) with Spinner("Preparing rTorrent build system", enabled=not debug):
print(f"Resolved xmlrpc-c-config for build: {resolved_xmlrpc_config or 'not found'}") run(["autoreconf", "-i"], cwd=str(source_dir), env=env, debug=debug)
if resolved_xmlrpc_config != str(xmlrpc_config): run(["make", "distclean"], cwd=str(source_dir), env=env, check=False, debug=debug)
raise InstallError(
f"Wrong xmlrpc-c-config selected: {resolved_xmlrpc_config or 'not found'}. Expected: {xmlrpc_config}"
)
run(["autoreconf", "-i"], cwd=str(source_dir), env=env) configure_cmd = ["./configure", f"--prefix={install_dir}", "--with-xmlrpc-c"]
run(["make", "distclean"], cwd=str(source_dir), env=env, check=False) with Spinner("Configuring rTorrent", enabled=not debug):
run([ run(configure_cmd, cwd=str(source_dir), env=env, debug=debug)
"./configure", with Spinner("Building rTorrent", enabled=not debug):
f"--prefix={install_dir}", run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir), env=env, debug=debug)
"--with-xmlrpc-c", with Spinner("Installing rTorrent", enabled=not debug):
], cwd=str(source_dir), env=env) run(["make", "install"], cwd=str(source_dir), env=env, debug=debug)
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir), env=env)
run(["make", "install"], cwd=str(source_dir), env=env) runtime_prefixes = [libtorrent_install, xmlrpc_install]
return install_dir if curl_install:
runtime_prefixes.append(curl_install)
if cares_install:
runtime_prefixes.append(cares_install)
runtime_env = build_env(*runtime_prefixes)
runtime_env["LD_LIBRARY_PATH"] = ":".join([f"{p}/lib" for p in runtime_prefixes])
version = capture([str(install_dir / "bin" / "rtorrent"), "-h"], env=runtime_env, check=False, debug=debug)
return install_dir, version
def install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install): def install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False):
rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent" rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent"
if not rtorrent_bin.exists(): if not rtorrent_bin.exists():
raise InstallError(f"Compiled rtorrent binary not found: {rtorrent_bin}") raise InstallError(f"Compiled rtorrent binary not found: {rtorrent_bin}")
@@ -313,9 +468,14 @@ def install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install):
usr_local_bin.symlink_to(rtorrent_bin) usr_local_bin.symlink_to(rtorrent_bin)
print(f"Symlinked {usr_local_bin} -> {rtorrent_bin}") print(f"Symlinked {usr_local_bin} -> {rtorrent_bin}")
lib_dirs = [f"{libtorrent_install}/lib", f"{xmlrpc_install}/lib"]
if curl_install:
lib_dirs.append(f"{curl_install}/lib")
if cares_install:
lib_dirs.append(f"{cares_install}/lib")
ld_conf = Path("/etc/ld.so.conf.d/rtorrent-custom-libs.conf") ld_conf = Path("/etc/ld.so.conf.d/rtorrent-custom-libs.conf")
ld_conf.write_text(f"{libtorrent_install}/lib\n{xmlrpc_install}/lib\n") ld_conf.write_text("\n".join(lib_dirs) + "\n")
run(["ldconfig"]) run(["ldconfig"], debug=debug)
def write_service(service_path, binary_path, runtime_lib_dirs): def write_service(service_path, binary_path, runtime_lib_dirs):
@@ -358,6 +518,7 @@ network.port_range.set = {DEFAULT_TORRENT_PORT}-{DEFAULT_TORRENT_PORT}
network.port_random.set = no network.port_random.set = no
network.bind_address.set = 0.0.0.0 network.bind_address.set = 0.0.0.0
system.file.allocate.set = 1 system.file.allocate.set = 1
system.umask.set = 0022
dht.mode.set = disable dht.mode.set = disable
protocol.pex.set = no protocol.pex.set = no
trackers.use_udp.set = no trackers.use_udp.set = no
@@ -370,58 +531,91 @@ schedule2 = session_save,1200,43200,((session.save))
def prepare_user_dirs(user_home, username): def prepare_user_dirs(user_home, username):
dirs = [ for d in [Path(user_home) / "downloads", Path(user_home) / ".session", Path(user_home) / "watch"]:
Path(user_home) / "downloads",
Path(user_home) / ".session",
Path(user_home) / "watch",
]
for d in dirs:
ensure_dir(d, owner=username, group=username, mode=0o755) ensure_dir(d, owner=username, group=username, mode=0o755)
home_path = Path(user_home) shutil.chown(Path(user_home), user=username, group=username)
shutil.chown(home_path, user=username, group=username)
def enable_service(user): def enable_service(user, *, debug=False):
unit_name = f"rtorrent@{user}.service" unit_name = f"rtorrent@{user}.service"
run(["systemctl", "enable", "--now", unit_name]) run(["systemctl", "enable", "--now", unit_name], debug=debug)
print(f"Enabled and started {unit_name}") print(f"Enabled and started {unit_name}")
def verify_install(rtorrent_install, libtorrent_install, xmlrpc_install): def print_link_lines(title, lines):
print(title)
for line in lines:
print(line)
def verify_libtorrent_curl_integration(base_dir, libtorrent_install, curl_install, cares_install, *, debug=False):
libtorrent_so = next((p for p in sorted((Path(libtorrent_install) / "lib").glob("libtorrent.so*")) if p.is_file() and not p.is_symlink()), None)
if not libtorrent_so:
raise InstallError("Could not find compiled libtorrent shared object for verification.")
libtorrent_linked = capture(["ldd", str(libtorrent_so)], check=True, debug=debug)
curl_lines = [line for line in libtorrent_linked.splitlines() if "libcurl" in line.lower()]
print_link_lines("Linked libcurl lines (from libtorrent):", curl_lines)
expected_curl = str(Path(curl_install) / "lib")
if curl_lines:
if not any(expected_curl in line for line in curl_lines):
raise InstallError(f"libtorrent does not appear to be linked against the compiled libcurl from {expected_curl}.")
else:
config_log = Path(base_dir) / "libtorrent" / "config.log"
config_text = config_log.read_text(errors="ignore") if config_log.exists() else ""
curl_config = str(Path(curl_install) / "bin" / "curl-config")
if curl_config not in config_text and expected_curl not in config_text:
raise InstallError(
"libtorrent does not expose libcurl in ldd, and config.log does not show the custom curl path either. "
"The build likely used the system curl or no curl integration."
)
print("libtorrent does not show libcurl in ldd; accepting config.log evidence of custom curl usage.")
custom_curl = Path(curl_install) / "bin" / "curl"
curl_version = capture([str(custom_curl), "--version"], env=build_env(curl_install, cares_install), check=True, debug=debug)
print("Custom curl version:")
print(curl_version.splitlines()[0])
lower = curl_version.lower()
if "asynchdns" not in lower:
raise InstallError("Custom curl does not report AsynchDNS support.")
if "c-ares" not in lower and "ares" not in lower:
print("Warning: curl --version does not explicitly show c-ares. Continuing because AsynchDNS is present.")
if cares_install:
cares_lines = [line for line in libtorrent_linked.splitlines() if "cares" in line.lower()]
print_link_lines("Linked c-ares lines (from libtorrent):", cares_lines)
if not cares_lines:
print("c-ares is not visible in libtorrent ldd; this can still be valid when libcurl is resolved differently.")
def verify_install(base_dir, rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False):
rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent" rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent"
which_rtorrent = capture(["which", "rtorrent"], check=False) or "not found in PATH" which_rtorrent = capture(["which", "rtorrent"], check=False, debug=debug) or "not found in PATH"
print(f"Resolved rtorrent from PATH: {which_rtorrent}") print(f"Resolved rtorrent from PATH: {which_rtorrent}")
linked = capture(["ldd", str(rtorrent_bin)], check=True) linked = capture(["ldd", str(rtorrent_bin)], check=True, debug=debug)
libtorrent_lines = [line for line in linked.splitlines() if "libtorrent" in line] for libname, expected in [("libtorrent", str(Path(libtorrent_install) / "lib")), ("xmlrpc", str(Path(xmlrpc_install) / "lib"))]:
print("Linked libtorrent lines:") lines = [line for line in linked.splitlines() if libname in line]
for line in libtorrent_lines: print_link_lines(f"Linked {libname} lines:", lines)
print(line) if not any(expected in line for line in lines):
expected_libtorrent = str(Path(libtorrent_install) / "lib") raise InstallError(f"rtorrent does not appear to be linked against the compiled {libname} from {expected}.")
if not any(expected_libtorrent in line for line in libtorrent_lines):
raise InstallError("rtorrent does not appear to be linked against the compiled libtorrent from /opt.")
xmlrpc_lines = [line for line in linked.splitlines() if "xmlrpc" in line] if curl_install:
print("Linked xmlrpc-c lines:") verify_libtorrent_curl_integration(base_dir, libtorrent_install, curl_install, cares_install, debug=debug)
for line in xmlrpc_lines:
print(line)
expected_xmlrpc = str(Path(xmlrpc_install) / "lib")
if not any(expected_xmlrpc in line for line in xmlrpc_lines):
raise InstallError("rtorrent does not appear to be linked against the compiled xmlrpc-c from /opt.")
env = os.environ.copy() env = build_env(libtorrent_install, xmlrpc_install, curl_install, cares_install)
env["LANG"] = "C" env["LANG"] = "C"
env["LC_ALL"] = "C" env["LC_ALL"] = "C"
env["TERM"] = env.get("TERM", "xterm") env["TERM"] = env.get("TERM", "xterm")
env["LD_LIBRARY_PATH"] = f"{Path(libtorrent_install) / 'lib'}:{Path(xmlrpc_install) / 'lib'}" ld_paths = [str(Path(libtorrent_install) / "lib"), str(Path(xmlrpc_install) / "lib")]
probe = subprocess.run( if curl_install:
[str(rtorrent_bin), "-h"], ld_paths.append(str(Path(curl_install) / "lib"))
env=env, if cares_install:
check=False, ld_paths.append(str(Path(cares_install) / "lib"))
text=True, env["LD_LIBRARY_PATH"] = ":".join(ld_paths)
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, probe = subprocess.run([str(rtorrent_bin), "-h"], env=env, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
)
help_output = ((probe.stdout or "") + "\n" + (probe.stderr or "")).lower() help_output = ((probe.stdout or "") + "\n" + (probe.stderr or "")).lower()
if "xmlrpc-c" in help_output and "i8" in help_output: if "xmlrpc-c" in help_output and "i8" in help_output:
raise InstallError( raise InstallError(
@@ -431,18 +625,21 @@ def verify_install(rtorrent_install, libtorrent_install, xmlrpc_install):
def build_parser(): def build_parser():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Debian installer for xmlrpc-c + libtorrent + rTorrent under /opt with optional c-ares/custom curl support.")
description="Interactive Debian installer for xmlrpc-c + libtorrent + rTorrent under /opt with optional systemd setup."
)
parser.add_argument("--base-dir", default=DEFAULT_BASE_DIR, help=f"Base build/install directory (default: {DEFAULT_BASE_DIR})") parser.add_argument("--base-dir", default=DEFAULT_BASE_DIR, help=f"Base build/install directory (default: {DEFAULT_BASE_DIR})")
parser.add_argument("--libtorrent-ref", default=DEFAULT_LIBTORRENT_REF, help="Git branch, tag or commit for libtorrent (default: master)") parser.add_argument("--libtorrent-ref", default=DEFAULT_LIBTORRENT_REF, help=f"Git branch, tag or commit for libtorrent (default: {DEFAULT_LIBTORRENT_REF})")
parser.add_argument("--rtorrent-ref", default=DEFAULT_RTORRENT_REF, help="Git branch, tag or commit for rtorrent (default: master)") parser.add_argument("--rtorrent-ref", default=DEFAULT_RTORRENT_REF, help=f"Git branch, tag or commit for rtorrent (default: {DEFAULT_RTORRENT_REF})")
parser.add_argument("--xmlrpc-ref", default=DEFAULT_XMLRPC_REF, help="xmlrpc-c source version or URL (default: latest-stable)") parser.add_argument("--xmlrpc-ref", default=DEFAULT_XMLRPC_REF, help="xmlrpc-c source version or URL (default: latest-stable)")
parser.add_argument("--cares-ref", default=DEFAULT_CARES_REF, help=f"c-ares release version (default: {DEFAULT_CARES_REF})")
parser.add_argument("--curl-ref", default=DEFAULT_CURL_REF, help=f"curl release version (default: {DEFAULT_CURL_REF})")
parser.add_argument("--user", default=DEFAULT_USER, help=f"System user for the service (default: {DEFAULT_USER})") parser.add_argument("--user", default=DEFAULT_USER, help=f"System user for the service (default: {DEFAULT_USER})")
parser.add_argument("--group", default=DEFAULT_GROUP, help=f"System group for the service (default: {DEFAULT_GROUP})") parser.add_argument("--group", default=DEFAULT_GROUP, help=f"System group for the service (default: {DEFAULT_GROUP})")
parser.add_argument("--home", default=DEFAULT_HOME, help=f"Home directory for the service user (default: {DEFAULT_HOME})") parser.add_argument("--home", default=DEFAULT_HOME, help=f"Home directory for the service user (default: {DEFAULT_HOME})")
parser.add_argument("--only-build", action="store_true", help="Only build and install libtorrent/rTorrent under /opt. Skip user, config and systemd.") parser.add_argument("--only-build", action="store_true", help="Only build and install libtorrent/rTorrent under /opt. Skip user, config and systemd.")
parser.add_argument("--yes", action="store_true", help="Assume yes for interactive prompts.") parser.add_argument("--yes", action="store_true", help="Assume yes for interactive prompts.")
parser.add_argument("--debug", action="store_true", help="Show full command output during build steps.")
parser.add_argument("--without-cares", dest="use_cares", action="store_false", help="Skip building c-ares and custom curl.")
parser.set_defaults(use_cares=True)
return parser return parser
@@ -454,26 +651,20 @@ def main():
detect_debian() detect_debian()
packages = [ packages = [
"build-essential", "build-essential", "pkg-config", "libtool", "autoconf", "automake", "git", "ca-certificates",
"pkg-config", "libssl-dev", "libncurses5-dev", "libncursesw5-dev", "libexpat1-dev", "curl", "tar", "cmake",
"libtool", "libpsl-dev", "zlib1g-dev", "libbrotli-dev", "libzstd-dev"
"autoconf",
"automake",
"git",
"ca-certificates",
"libssl-dev",
"libcurl4-openssl-dev",
"libncurses5-dev",
"libncursesw5-dev",
"libexpat1-dev",
"curl",
"tar",
] ]
print("This script will:") print("This script will:")
print(f" - build xmlrpc-c from '{args.xmlrpc_ref}'") print(f" - build xmlrpc-c from '{args.xmlrpc_ref}'")
print(f" - build libtorrent from '{args.libtorrent_ref}'") print(f" - build libtorrent from '{args.libtorrent_ref}'")
print(f" - build rtorrent from '{args.rtorrent_ref}'") print(f" - build rtorrent from '{args.rtorrent_ref}'")
if args.use_cares:
print(f" - build c-ares from '{args.cares_ref}'")
print(f" - build curl from '{args.curl_ref}' with c-ares")
else:
print(" - skip c-ares/custom curl and use system curl")
print(f" - install everything under '{args.base_dir}'") print(f" - install everything under '{args.base_dir}'")
if args.only_build: if args.only_build:
print(" - skip service user, config and systemd setup") print(" - skip service user, config and systemd setup")
@@ -484,28 +675,58 @@ def main():
print("Aborted by user.") print("Aborted by user.")
return 1 return 1
ensure_packages(packages) ensure_packages(packages, debug=args.debug)
ensure_dir(args.base_dir) ensure_dir(args.base_dir)
xmlrpc_install = build_xmlrpc_c(args.base_dir, args.xmlrpc_ref) xmlrpc_install, xmlrpc_version = build_xmlrpc_c(args.base_dir, args.xmlrpc_ref, debug=args.debug)
libtorrent_install = build_libtorrent(args.base_dir, args.libtorrent_ref)
rtorrent_install = build_rtorrent(args.base_dir, args.rtorrent_ref, libtorrent_install, xmlrpc_install) cares_install = None
install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install) cares_version = None
verify_install(rtorrent_install, libtorrent_install, xmlrpc_install) curl_install = None
curl_version = None
if args.use_cares:
cares_install, cares_version = build_cares(args.base_dir, args.cares_ref, debug=args.debug)
curl_install, curl_version = build_curl(args.base_dir, args.curl_ref, cares_install, debug=args.debug)
libtorrent_install, libtorrent_version = build_libtorrent(
args.base_dir, args.libtorrent_ref, curl_install=curl_install, cares_install=cares_install, debug=args.debug
)
rtorrent_install, rtorrent_version = build_rtorrent(
args.base_dir, args.rtorrent_ref, libtorrent_install, xmlrpc_install, curl_install=curl_install,
cares_install=cares_install, debug=args.debug
)
install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=curl_install, cares_install=cares_install, debug=args.debug)
verify_install(args.base_dir, rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=curl_install, cares_install=cares_install, debug=args.debug)
if not args.only_build: if not args.only_build:
create_system_user(args.user, args.group, args.home, assume_yes=args.yes) create_system_user(args.user, args.group, args.home, assume_yes=args.yes, debug=args.debug)
prepare_user_dirs(args.home, args.user) prepare_user_dirs(args.home, args.user)
write_rtorrent_config(args.home, args.user) write_rtorrent_config(args.home, args.user)
runtime_lib_dirs = f"{libtorrent_install / 'lib'}:{xmlrpc_install / 'lib'}" runtime_lib_dirs = [f"{libtorrent_install}/lib", f"{xmlrpc_install}/lib"]
write_service(DEFAULT_SERVICE_PATH, "/usr/local/bin/rtorrent", runtime_lib_dirs) if curl_install:
enable_service(args.user) runtime_lib_dirs.append(f"{curl_install}/lib")
if cares_install:
runtime_lib_dirs.append(f"{cares_install}/lib")
write_service(DEFAULT_SERVICE_PATH, "/usr/local/bin/rtorrent", ":".join(runtime_lib_dirs))
enable_service(args.user, debug=args.debug)
print(f"\nService status hint: systemctl status rtorrent@{args.user}.service") print(f"\nService status hint: systemctl status rtorrent@{args.user}.service")
print("\nBuild summary")
print("-------------")
print(f"xmlrpc-c: {xmlrpc_version}")
print(f"libtorrent: {libtorrent_version}")
print(f"rtorrent: {rtorrent_version.splitlines()[0] if rtorrent_version else args.rtorrent_ref}")
if args.use_cares:
print(f"c-ares: {cares_version}")
print(f"curl: {curl_version.splitlines()[0] if curl_version else args.curl_ref}")
else:
print("c-ares: disabled")
print("curl: system")
print("binary: /usr/local/bin/rtorrent")
print(f"base dir: {args.base_dir}")
print("\nDone.") print("\nDone.")
print("rtorrent binary: /usr/local/bin/rtorrent")
print(f"libtorrent path: {libtorrent_install / 'lib'}")
print(f"xmlrpc-c path: {xmlrpc_install / 'lib'}")
return 0 return 0