soulsync/core/torrent_clients/aria2.py
BoulderBadgeDad 4d2772765c Add Aria2 to the torrent client list (Shdjfgatdif's request)
New Aria2 JSON-RPC adapter, alongside qBittorrent / Transmission / Deluge. Aria2's
RPC (default :6800/jsonrpc) maps cleanly onto the uniform adapter contract:
- the --rpc-secret token leads every call as "token:<secret>" (no username — the
  secret uses the existing password field),
- addUri returns a GID (our torrent id); tellStatus → TorrentStatus with state
  mapping (active→downloading, or seeding once the payload is complete; waiting→
  queued; etc.),
- remove picks forceRemove vs removeDownloadResult by status, and (since aria2
  doesn't delete files on remove) unlinks the file paths itself for delete_files,
- bare-host URLs get /jsonrpc appended.

Wired into adapter_for_type + the Settings dropdown (with a help note: port 6800,
secret in the Password field). All adapter methods go through the same interface,
so the stall/orphan handling and downloads pipeline work unchanged.

Tests (9): registry wiring, state mapping (incl. active→seeding), token-prefixed
params, /jsonrpc fixup, status parse (+ name fallback, no div-by-zero). 126 torrent
tests green; ruff clean.
2026-06-11 22:29:40 -07:00

218 lines
9.3 KiB
Python

"""Aria2 JSON-RPC adapter (Shdjfgatdif's request).
Auth model: aria2 uses a single RPC ``--rpc-secret`` token (no username). It is
passed as the FIRST positional param of every call as the string
``token:<secret>``. The RPC endpoint is ``<host>:6800/jsonrpc``.
A few aria2 quirks the adapter smooths over:
- the secret maps onto SoulSync's ``password`` field (aria2 has no username),
- ``addUri`` returns a GID — that's our torrent id,
- aria2 does NOT delete files on remove; for ``delete_files`` we read the file
paths first and unlink them ourselves,
- a finished/errored download is removed via ``removeDownloadResult`` (force/Remove
only work on active/waiting/paused), so the adapter picks the right one.
Reference: https://aria2.github.io/manual/en/html/aria2c.html#rpc-interface
"""
from __future__ import annotations
import asyncio
import base64
import os
from typing import List, Optional
import requests as http_requests
from config.settings import config_manager
from core.torrent_clients.base import TorrentStatus, normalize_client_url
from utils.logging_config import get_logger
logger = get_logger("torrent.aria2")
# aria2 download status → adapter-uniform state. 'active' is resolved separately
# (it covers both downloading and post-complete seeding for BT).
_ARIA2_STATE = {
'waiting': 'queued',
'paused': 'paused',
'error': 'error',
'removed': 'error',
'complete': 'completed',
}
def _map_state(status: str, completed: int, total: int) -> str:
if status == 'active':
# A BT download that finished its payload keeps reporting 'active' while
# it seeds — differentiate by progress.
if total and completed >= total:
return 'seeding'
return 'downloading'
return _ARIA2_STATE.get(status, 'error')
class Aria2Adapter:
"""Aria2 JSON-RPC adapter."""
DEFAULT_TIMEOUT = 15
_STATUS_KEYS = ['gid', 'status', 'totalLength', 'completedLength', 'downloadSpeed',
'uploadSpeed', 'connections', 'numSeeders', 'dir', 'files',
'errorMessage', 'bittorrent']
def __init__(self) -> None:
self._load_config()
def _load_config(self) -> None:
url = normalize_client_url(config_manager.get('torrent_client.url', ''))
# aria2's RPC endpoint is /jsonrpc — append if the user pasted a bare host.
if url and not url.rstrip('/').endswith('/jsonrpc'):
url = url.rstrip('/') + '/jsonrpc'
self._url = url
# aria2 has no username; the RPC secret maps onto the password field.
self._secret = config_manager.get('torrent_client.password', '') or ''
self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync'
self._save_path = config_manager.get('torrent_client.save_path', '') or ''
def reload_settings(self) -> None:
self._load_config()
def is_configured(self) -> bool:
return bool(self._url)
def _params(self, *params) -> list:
"""token:<secret> must lead every call when a secret is configured."""
return ([f"token:{self._secret}"] if self._secret else []) + list(params)
def _rpc(self, method: str, *params):
if not self._url:
return None
payload = {'jsonrpc': '2.0', 'id': 'soulsync', 'method': method,
'params': self._params(*params)}
try:
resp = http_requests.post(self._url, json=payload, timeout=self.DEFAULT_TIMEOUT)
if not resp.ok:
logger.warning("aria2 RPC %s returned HTTP %s", method, resp.status_code)
return None
data = resp.json()
if isinstance(data, dict) and data.get('error'):
logger.warning("aria2 RPC %s error: %s", method, data.get('error'))
return None
return data.get('result') if isinstance(data, dict) else None
except Exception as e:
logger.error("aria2 RPC %s failed: %s", method, e)
return None
async def check_connection(self) -> bool:
if not self.is_configured():
return False
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: self._rpc('aria2.getVersion') is not None)
# ── add ──
async def add_torrent(self, url_or_magnet: str, category: str = "soulsync",
save_path: Optional[str] = None) -> Optional[str]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._add_uri_sync, url_or_magnet, save_path)
def _opts(self, save_path: Optional[str]) -> dict:
opts: dict = {}
if save_path or self._save_path:
opts['dir'] = save_path or self._save_path
return opts
def _add_uri_sync(self, uri: str, save_path: Optional[str]) -> Optional[str]:
result = self._rpc('aria2.addUri', [uri], self._opts(save_path))
return str(result) if result else None # GID
async def add_torrent_file(self, file_bytes: bytes, category: str = "soulsync",
save_path: Optional[str] = None) -> Optional[str]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._add_file_sync, file_bytes, save_path)
def _add_file_sync(self, file_bytes: bytes, save_path: Optional[str]) -> Optional[str]:
b64 = base64.b64encode(file_bytes).decode('ascii')
result = self._rpc('aria2.addTorrent', b64, [], self._opts(save_path))
return str(result) if result else None
# ── status ──
async def get_status(self, torrent_id: str) -> Optional[TorrentStatus]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_status_sync, torrent_id)
def _get_status_sync(self, gid: str) -> Optional[TorrentStatus]:
result = self._rpc('aria2.tellStatus', gid, self._STATUS_KEYS)
return self._parse_status(result) if result else None
async def get_all(self) -> List[TorrentStatus]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_all_sync)
def _get_all_sync(self) -> List[TorrentStatus]:
active = self._rpc('aria2.tellActive', self._STATUS_KEYS) or []
waiting = self._rpc('aria2.tellWaiting', 0, 1000, self._STATUS_KEYS) or []
stopped = self._rpc('aria2.tellStopped', 0, 1000, self._STATUS_KEYS) or []
return [self._parse_status(i) for i in (list(active) + list(waiting) + list(stopped))]
def _parse_status(self, item: dict) -> TorrentStatus:
total = int(item.get('totalLength') or 0)
completed = int(item.get('completedLength') or 0)
status = item.get('status') or 'error'
progress = (completed / total) if total > 0 else 0.0
# Name: BT info name, else the first file's basename.
name = ''
bt = item.get('bittorrent')
if isinstance(bt, dict):
name = (bt.get('info') or {}).get('name') or ''
files = item.get('files') or []
if not name and files:
name = os.path.basename((files[0] or {}).get('path') or '')
file_paths = [f.get('path') for f in files if f.get('path')] or None
return TorrentStatus(
id=str(item.get('gid') or ''),
name=name,
state=_map_state(status, completed, total),
progress=progress,
size=total,
downloaded=completed,
download_speed=int(item.get('downloadSpeed') or 0),
upload_speed=int(item.get('uploadSpeed') or 0),
seeders=int(item.get('numSeeders') or 0),
peers=int(item.get('connections') or 0),
save_path=item.get('dir'),
files=file_paths,
error=item.get('errorMessage') or None,
)
# ── remove / pause / resume ──
async def remove(self, torrent_id: str, delete_files: bool = False) -> bool:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._remove_sync, torrent_id, delete_files)
def _remove_sync(self, gid: str, delete_files: bool) -> bool:
st = self._rpc('aria2.tellStatus', gid, ['status', 'files']) or {}
status = st.get('status')
paths = ([f.get('path') for f in (st.get('files') or []) if f.get('path')]
if delete_files else [])
if status in ('active', 'waiting', 'paused'):
ok = self._rpc('aria2.forceRemove', gid) is not None
self._rpc('aria2.removeDownloadResult', gid) # clear the result row
else:
# complete / error / removed → force/Remove would error; clear the row.
ok = self._rpc('aria2.removeDownloadResult', gid) is not None
if delete_files:
for p in paths:
try:
if p and os.path.isfile(p):
os.remove(p)
except Exception: # noqa: S110 — partial data delete is best-effort
pass
return ok
async def pause(self, torrent_id: str) -> bool:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: self._rpc('aria2.pause', torrent_id) is not None)
async def resume(self, torrent_id: str) -> bool:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: self._rpc('aria2.unpause', torrent_id) is not None)