feat: init project

This commit is contained in:
dnslin
2025-12-11 17:54:06 +08:00
commit 2498e69e9e
23 changed files with 1147 additions and 0 deletions

5
src/aria2/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Aria2 operations module - installer and service management."""
from src.aria2.installer import Aria2Installer
from src.aria2.service import Aria2ServiceManager
__all__ = ["Aria2Installer", "Aria2ServiceManager"]

246
src/aria2/installer.py Normal file
View File

@@ -0,0 +1,246 @@
"""Aria2 installer - download, install, and configure aria2."""
from __future__ import annotations
import asyncio
import functools
import json
import shutil
import tarfile
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from urllib import error, request
from src.core import (
ARIA2_BIN,
ARIA2_CONFIG_DIR,
ARIA2_CONF,
ARIA2_LOG,
ARIA2_SESSION,
Aria2Config,
Aria2Error,
ConfigError,
DownloadError,
detect_arch,
detect_os,
generate_rpc_secret,
is_aria2_installed,
)
class Aria2Installer:
GITHUB_API = "https://api.github.com/repos/P3TERX/Aria2-Pro-Core/releases/latest"
GITHUB_MIRROR = "https://gh-api.p3terx.com/repos/P3TERX/Aria2-Pro-Core/releases/latest"
CONFIG_URLS = [
"https://p3terx.github.io/aria2.conf",
"https://cdn.jsdelivr.net/gh/P3TERX/aria2.conf@master",
]
CONFIG_FILES = ["aria2.conf", "script.conf", "dht.dat", "dht6.dat"]
def __init__(self, config: Aria2Config | None = None):
self.config = config or Aria2Config()
self.os_type = detect_os()
self.arch = detect_arch()
self._executor = ThreadPoolExecutor(max_workers=4)
async def get_latest_version(self) -> str:
"""从 GitHub API 获取最新版本号"""
loop = asyncio.get_running_loop()
last_error: Exception | None = None
for url in (self.GITHUB_API, self.GITHUB_MIRROR):
try:
data = await loop.run_in_executor(
self._executor, functools.partial(self._fetch_url, url)
)
payload = json.loads(data.decode("utf-8"))
tag_name = payload.get("tag_name")
if not tag_name:
raise DownloadError("tag_name missing in GitHub API response")
return tag_name
except Exception as exc: # noqa: PERF203
last_error = exc
continue
raise DownloadError(f"Failed to fetch latest version: {last_error}") from last_error
async def download_binary(self, version: str | None = None) -> Path:
"""下载并解压 aria2 静态二进制到 ~/.local/bin/"""
resolved_version = version or await self.get_latest_version()
version_name = resolved_version.lstrip("v")
archive_name = f"aria2-{version_name}-static-linux-{self.arch}.tar.gz"
download_url = (
f"https://github.com/P3TERX/Aria2-Pro-Core/releases/download/"
f"{resolved_version}/{archive_name}"
)
loop = asyncio.get_running_loop()
with tempfile.TemporaryDirectory() as tmpdir:
tmp_dir_path = Path(tmpdir)
archive_path = tmp_dir_path / archive_name
extract_dir = tmp_dir_path / "extract"
extract_dir.mkdir(parents=True, exist_ok=True)
try:
data = await loop.run_in_executor(
self._executor, functools.partial(self._fetch_url, download_url)
)
await loop.run_in_executor(
self._executor, functools.partial(self._write_file, archive_path, data)
)
except Exception as exc: # noqa: PERF203
raise DownloadError(f"Failed to download aria2 binary: {exc}") from exc
try:
binary_path = await loop.run_in_executor(
self._executor, functools.partial(self._extract_binary, archive_path, extract_dir)
)
except Exception as exc: # noqa: PERF203
raise DownloadError(f"Failed to extract aria2 binary: {exc}") from exc
try:
ARIA2_BIN.parent.mkdir(parents=True, exist_ok=True)
if ARIA2_BIN.exists():
ARIA2_BIN.unlink()
shutil.move(str(binary_path), ARIA2_BIN)
ARIA2_BIN.chmod(0o755)
except Exception as exc: # noqa: PERF203
raise DownloadError(f"Failed to install aria2 binary: {exc}") from exc
return ARIA2_BIN
async def download_config(self) -> None:
"""下载配置模板文件"""
ARIA2_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
loop = asyncio.get_running_loop()
for filename in self.CONFIG_FILES:
last_error: Exception | None = None
for base in self.CONFIG_URLS:
url = f"{base.rstrip('/')}/{filename}"
try:
data = await loop.run_in_executor(
self._executor, functools.partial(self._fetch_url, url)
)
target = ARIA2_CONFIG_DIR / filename
await loop.run_in_executor(
self._executor, functools.partial(self._write_file, target, data)
)
last_error = None
break
except Exception as exc: # noqa: PERF203
last_error = exc
continue
if last_error is not None:
raise DownloadError(f"Failed to download {filename}: {last_error}") from last_error
def render_config(self) -> None:
"""渲染配置文件,注入用户参数"""
if not ARIA2_CONF.exists():
raise ConfigError("Config template not found. Run download_config first.")
try:
content = ARIA2_CONF.read_text(encoding="utf-8")
except OSError as exc:
raise ConfigError(f"Failed to read config: {exc}") from exc
rpc_secret = self.config.rpc_secret or generate_rpc_secret()
self.config.rpc_secret = rpc_secret
replacements = {
"dir=": str(self.config.download_dir),
"rpc-listen-port=": str(self.config.rpc_port),
"rpc-secret=": rpc_secret,
"max-concurrent-downloads=": str(self.config.max_concurrent_downloads),
"max-connection-per-server=": str(self.config.max_connection_per_server),
}
new_lines: list[str] = []
for line in content.splitlines():
stripped = line.lstrip()
replaced = False
for key, value in replacements.items():
if stripped.startswith(key):
prefix = line[: len(line) - len(stripped)]
new_lines.append(f"{prefix}{key}{value}")
replaced = True
break
if not replaced:
new_lines.append(line)
try:
ARIA2_CONF.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
ARIA2_SESSION.touch(exist_ok=True)
self.config.download_dir.mkdir(parents=True, exist_ok=True)
ARIA2_LOG.touch(exist_ok=True)
except OSError as exc:
raise ConfigError(f"Failed to render config: {exc}") from exc
async def install(self, version: str | None = None) -> dict:
"""完整安装流程"""
resolved_version = version or await self.get_latest_version()
await self.download_binary(resolved_version)
await self.download_config()
self.render_config()
return {
"version": resolved_version,
"binary": str(ARIA2_BIN),
"config_dir": str(ARIA2_CONFIG_DIR),
"config": str(ARIA2_CONF),
"session": str(ARIA2_SESSION),
"installed": is_aria2_installed(),
}
def uninstall(self) -> None:
"""卸载 aria2"""
errors: list[Exception] = []
try:
if ARIA2_BIN.exists():
ARIA2_BIN.unlink()
except Exception as exc: # noqa: PERF203
errors.append(exc)
try:
if ARIA2_CONFIG_DIR.exists():
shutil.rmtree(ARIA2_CONFIG_DIR)
except Exception as exc: # noqa: PERF203
errors.append(exc)
try:
service_path = Path.home() / ".config" / "systemd" / "user" / "aria2.service"
if service_path.exists():
service_path.unlink()
except Exception as exc: # noqa: PERF203
errors.append(exc)
if errors:
messages = "; ".join(str(err) for err in errors)
raise Aria2Error(f"Failed to uninstall aria2: {messages}")
def _fetch_url(self, url: str) -> bytes:
"""阻塞式 URL 获取,放在线程池中运行"""
req = request.Request(url, headers={"User-Agent": "aria2-installer"})
try:
with request.urlopen(req, timeout=30) as resp:
if getattr(resp, "status", 200) >= 400:
raise DownloadError(f"HTTP {resp.status} for {url}")
return resp.read()
except (error.HTTPError, error.URLError) as exc:
raise DownloadError(f"Network error for {url}: {exc}") from exc
@staticmethod
def _write_file(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
@staticmethod
def _extract_binary(archive_path: Path, extract_dir: Path) -> Path:
with tarfile.open(archive_path, "r:gz") as tar:
tar.extractall(extract_dir)
for candidate in extract_dir.rglob("aria2c"):
if candidate.is_file():
return candidate
raise DownloadError("aria2c binary not found in archive")

170
src/aria2/service.py Normal file
View File

@@ -0,0 +1,170 @@
"""Aria2 service manager - systemd service lifecycle management."""
from __future__ import annotations
import os
import subprocess
from src.core import (
ARIA2_BIN,
ARIA2_CONF,
ARIA2_LOG,
ARIA2_SERVICE,
SYSTEMD_USER_DIR,
ServiceError,
NotInstalledError,
is_aria2_installed,
)
SYSTEMD_SERVICE_TEMPLATE = """[Unit]
Description=Aria2 Download Manager
After=network.target
[Service]
Type=simple
ExecStart={aria2_bin} --conf-path={aria2_conf}
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
"""
class Aria2ServiceManager:
def __init__(self) -> None:
pass
def _run_systemctl(self, *args: str) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
["systemctl", "--user", *args],
capture_output=True,
text=True,
check=True,
)
except FileNotFoundError as exc:
raise ServiceError("systemctl command not found") from exc
except subprocess.CalledProcessError as exc:
output = exc.stderr.strip() or exc.stdout.strip() or str(exc)
raise ServiceError(output) from exc
def _ensure_service_file(self) -> None:
try:
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
content = SYSTEMD_SERVICE_TEMPLATE.format(
aria2_bin=str(ARIA2_BIN),
aria2_conf=str(ARIA2_CONF),
)
ARIA2_SERVICE.write_text(content, encoding="utf-8")
self._run_systemctl("daemon-reload")
except OSError as exc:
raise ServiceError(f"Failed to write service file: {exc}") from exc
def start(self) -> None:
if not is_aria2_installed():
raise NotInstalledError("aria2 is not installed")
self._ensure_service_file()
self._run_systemctl("start", "aria2")
def stop(self) -> None:
self._run_systemctl("stop", "aria2")
def restart(self) -> None:
self._run_systemctl("restart", "aria2")
def enable(self) -> None:
self._run_systemctl("enable", "aria2")
def disable(self) -> None:
self._run_systemctl("disable", "aria2")
def status(self) -> dict:
installed = is_aria2_installed()
pid = self.get_pid() if installed else None
try:
active_proc = subprocess.run(
["systemctl", "--user", "is-active", "aria2"],
capture_output=True,
text=True,
check=False,
)
enabled_proc = subprocess.run(
["systemctl", "--user", "is-enabled", "aria2"],
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError as exc:
raise ServiceError("systemctl command not found") from exc
running = active_proc.returncode == 0
enabled = enabled_proc.returncode == 0
return {
"installed": installed,
"running": running,
"pid": pid,
"enabled": enabled,
}
def get_pid(self) -> int | None:
try:
result = subprocess.run(
["pgrep", "-u", str(os.getuid()), "-f", "aria2c"],
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
result = None
if result and result.returncode == 0:
for line in result.stdout.splitlines():
line = line.strip()
if line.isdigit():
return int(line)
try:
ps_result = subprocess.run(
["ps", "-C", "aria2c", "-o", "pid="],
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
return None
for line in ps_result.stdout.splitlines():
line = line.strip()
if line.isdigit():
return int(line)
return None
def view_log(self, lines: int = 50) -> str:
if lines <= 0 or not ARIA2_LOG.exists():
return ""
try:
content = ARIA2_LOG.read_text(encoding="utf-8", errors="ignore")
except OSError as exc:
raise ServiceError(f"Failed to read log: {exc}") from exc
log_lines = content.splitlines(keepends=True)
return "".join(log_lines[-lines:])
def clear_log(self) -> None:
try:
ARIA2_LOG.parent.mkdir(parents=True, exist_ok=True)
ARIA2_LOG.write_text("", encoding="utf-8")
except OSError as exc:
raise ServiceError(f"Failed to clear log: {exc}") from exc
def remove_service(self) -> None:
self.stop()
try:
ARIA2_SERVICE.unlink(missing_ok=True)
except OSError as exc:
raise ServiceError(f"Failed to remove service file: {exc}") from exc
self._run_systemctl("daemon-reload")