Files
pyTorrent/pytorrent/services/rtorrent/config.py
Mateusz Gruszczyński 80c71c8d79 rebuild rtorrent config
2026-05-27 22:31:22 +02:00

555 lines
22 KiB
Python

from __future__ import annotations
from .client import *
RTORRENT_CONFIG_FIELDS = [
{
"group": "Directories",
"key": "directory.default",
"label": "Default download directory",
"type": "text",
"description": "Main destination for new downloads added without an explicit directory.",
"recommendation": "Use a stable absolute path on storage with enough free space; avoid changing it while active torrents use relative paths.",
},
{
"group": "Directories",
"key": "session.path",
"label": "Session path",
"type": "text",
"description": "Directory where rTorrent stores session state, resume data and internal torrent metadata.",
"recommendation": "Keep it on reliable local storage and include it in backups before maintenance.",
},
{
"group": "Directories",
"key": "system.cwd",
"label": "Working directory",
"type": "text",
"readonly": True,
"description": "Current rTorrent process working directory reported by rTorrent.",
"recommendation": "Read-only diagnostic value; change it in the service or startup configuration if needed.",
},
{
"group": "Network",
"key": "network.port_range",
"label": "Incoming port range",
"type": "text",
"placeholder": "49164-49164",
"description": "TCP port or range used for incoming peer connections.",
"recommendation": "Use a fixed forwarded port, for example 49164-49164, for stable connectivity.",
},
{
"group": "Network",
"key": "network.port_random",
"label": "Random incoming port",
"type": "bool",
"description": "Lets rTorrent select a random incoming port on startup.",
"recommendation": "Disable it when using router/NAT forwarding; fixed ports are easier to monitor.",
},
{
"group": "Network",
"key": "network.bind_address",
"label": "Bind address",
"type": "text",
"placeholder": "0.0.0.0",
"description": "Local interface address used for peer traffic binding.",
"recommendation": "Leave empty unless the host has multiple interfaces or policy routing.",
},
{
"group": "Network",
"key": "network.local_address",
"label": "Announced local address",
"type": "text",
"description": "Address rTorrent may announce as its local network address.",
"recommendation": "Usually leave empty; set only when a specific advertised address is required.",
},
{
"group": "Network",
"key": "network.max_open_files",
"label": "Max open files",
"type": "number",
"description": "Maximum number of files rTorrent can keep open at once.",
"recommendation": "Raise together with the OS file descriptor limit on large seeds.",
},
{
"group": "Network",
"key": "network.max_open_sockets",
"label": "Max open sockets",
"type": "number",
"description": "Upper bound for peer and tracker sockets opened by rTorrent.",
"recommendation": "Keep below OS limits; increase gradually when many torrents are active.",
},
{
"group": "Network",
"key": "network.http.max_open",
"label": "Max HTTP connections",
"type": "number",
"description": "Maximum simultaneous HTTP connections for tracker and metadata requests.",
"recommendation": "Moderate values reduce tracker pressure; increase only if tracker requests queue up.",
},
{
"group": "Network",
"key": "network.http.ssl_verify_peer",
"label": "Verify SSL peers",
"type": "bool",
"description": "Controls certificate verification for HTTPS tracker connections.",
"recommendation": "Keep enabled unless a private tracker has a known certificate problem.",
},
{
"group": "Network",
"key": "network.xmlrpc.size_limit",
"label": "XML-RPC upload size limit",
"type": "text",
"placeholder": "16M",
"description": "Maximum XML-RPC payload size accepted by rTorrent.",
"recommendation": "Keep enough headroom for large UI responses; avoid very high values on public endpoints.",
},
{
"group": "Peers",
"key": "throttle.min_peers.normal",
"label": "Min peers while downloading",
"type": "number",
"description": "Minimum peer target for incomplete torrents.",
"recommendation": "Use a conservative floor; too high values can waste sockets on weak swarms.",
},
{
"group": "Peers",
"key": "throttle.max_peers.normal",
"label": "Max peers while downloading",
"type": "number",
"description": "Maximum peer target for incomplete torrents.",
"recommendation": "Increase for fast lines, but keep total sockets and CPU usage under control.",
},
{
"group": "Peers",
"key": "throttle.min_peers.seed",
"label": "Min peers while seeding",
"type": "number",
"description": "Minimum peer target for complete torrents.",
"recommendation": "Lower than download min peers is usually enough for long-term seeding.",
},
{
"group": "Peers",
"key": "throttle.max_peers.seed",
"label": "Max peers while seeding",
"type": "number",
"description": "Maximum peer target for complete torrents.",
"recommendation": "Avoid excessive values on many seeding torrents because sockets multiply quickly.",
},
{
"group": "Peers",
"key": "trackers.numwant",
"label": "Tracker numwant",
"type": "number",
"description": "Number of peers requested from trackers per announce where supported.",
"recommendation": "Use moderate values; many trackers cap this server-side anyway.",
},
{
"group": "Throttle",
"key": "throttle.global_down.max_rate",
"label": "Global download limit B/s",
"type": "number",
"description": "Global download speed cap in bytes per second. Zero usually means unlimited.",
"recommendation": "Leave unlimited or cap below line speed if other services share the connection.",
},
{
"group": "Throttle",
"key": "throttle.global_up.max_rate",
"label": "Global upload limit B/s",
"type": "number",
"description": "Global upload speed cap in bytes per second. Zero usually means unlimited.",
"recommendation": "Keep below real upstream capacity to avoid bufferbloat and slow downloads.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads.global",
"label": "Max active downloads",
"type": "number",
"description": "Maximum number of downloading torrents active at once.",
"recommendation": "Match disk and network capacity; fewer active downloads often finish faster.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.global",
"label": "Max active uploads",
"type": "number",
"description": "Maximum number of uploading torrents active at once.",
"recommendation": "Keep enough slots for ratio goals without overloading disks and sockets.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads.div",
"label": "Max downloads per throttle",
"type": "number",
"description": "Per-throttle download slot divisor used by rTorrent throttling logic.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.div",
"label": "Max uploads per throttle",
"type": "number",
"description": "Per-throttle upload slot divisor used by rTorrent throttling logic.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "DHT / PEX",
"key": "dht.mode",
"label": "DHT mode",
"type": "text",
"placeholder": "disable/off/auto/on",
"description": "Controls Distributed Hash Table usage for peer discovery.",
"recommendation": "Private-tracker setups often disable DHT; public torrents usually benefit from auto/on.",
},
{
"group": "DHT / PEX",
"key": "dht.port",
"label": "DHT port",
"type": "number",
"description": "UDP port used by DHT traffic.",
"recommendation": "Use the same forwarded port strategy as incoming TCP when DHT is enabled.",
},
{
"group": "DHT / PEX",
"key": "protocol.pex",
"label": "Peer exchange",
"type": "bool",
"description": "Enables Peer Exchange peer discovery between connected peers.",
"recommendation": "Disable for strict private-tracker policies; enable for public swarms if allowed.",
},
{
"group": "DHT / PEX",
"key": "trackers.use_udp",
"label": "UDP trackers",
"type": "bool",
"description": "Allows rTorrent to use UDP trackers where supported.",
"recommendation": "Keep enabled for public torrents unless the network blocks UDP tracker traffic.",
},
{
"group": "Protocol",
"key": "protocol.encryption.set",
"label": "Encryption flags",
"type": "text",
"placeholder": "allow_incoming,try_outgoing,enable_retry",
"description": "Encryption policy flags for peer connections.",
"recommendation": "Prefer permissive settings unless a tracker or network requires strict encryption.",
},
{
"group": "Protocol",
"key": "protocol.connection.leech",
"label": "Leech connection type",
"type": "text",
"placeholder": "leech",
"description": "Connection behavior profile used by incomplete torrents.",
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
},
{
"group": "Protocol",
"key": "protocol.connection.seed",
"label": "Seed connection type",
"type": "text",
"placeholder": "seed",
"description": "Connection behavior profile used by complete torrents.",
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
},
{
"group": "Files",
"key": "pieces.hash.on_completion",
"label": "Hash check on completion",
"type": "bool",
"description": "Runs a hash verification after a torrent completes.",
"recommendation": "Enable for data integrity when storage is unreliable; disable if completion checks are too expensive.",
},
{
"group": "Files",
"key": "pieces.preload.type",
"label": "Pieces preload type",
"type": "number",
"description": "Controls how rTorrent preloads torrent pieces from disk.",
"recommendation": "Keep default unless you are tuning disk cache behavior for a known workload.",
},
{
"group": "Files",
"key": "pieces.preload.min_size",
"label": "Pieces preload min size",
"type": "number",
"description": "Minimum piece size threshold for preload behavior.",
"recommendation": "Keep default unless large-piece torrents show disk latency issues.",
},
{
"group": "Files",
"key": "pieces.preload.min_rate",
"label": "Pieces preload min rate",
"type": "number",
"description": "Minimum transfer rate threshold for preloading pieces.",
"recommendation": "Tune only after measuring disk read pressure.",
},
{
"group": "Files",
"key": "pieces.memory.max",
"label": "Pieces memory max",
"type": "text",
"placeholder": "512M",
"description": "Maximum memory rTorrent may use for piece handling where supported.",
"recommendation": "Avoid values that compete with OS page cache; increase only on hosts with spare RAM.",
},
{
"group": "Files",
"key": "system.file.allocate",
"label": "File allocation",
"type": "number",
"description": "Controls preallocation behavior for downloaded files.",
"recommendation": "Preallocation can reduce fragmentation but may slow adding very large torrents.",
},
{
"group": "Files",
"key": "system.file.max_size",
"label": "Max file size",
"type": "number",
"description": "Maximum single file size rTorrent accepts where supported.",
"recommendation": "Leave default unless you intentionally need to block oversized files.",
},
{
"group": "System",
"key": "system.umask",
"label": "File umask",
"type": "text",
"placeholder": "0002",
"description": "Permission mask applied to files created by rTorrent.",
"recommendation": "Use 0002 for shared media groups, 0022 for private single-user setups.",
},
{
"group": "System",
"key": "system.hostname",
"label": "Hostname",
"type": "text",
"readonly": True,
"description": "Hostname reported by the rTorrent runtime.",
"recommendation": "Read-only diagnostic value.",
},
{
"group": "System",
"key": "system.client_version",
"label": "Client version",
"type": "text",
"readonly": True,
"description": "rTorrent client version reported through XML-RPC.",
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
},
{
"group": "System",
"key": "system.library_version",
"label": "Library version",
"type": "text",
"readonly": True,
"description": "libTorrent library version used by rTorrent.",
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
},
]
def _normalize_config_value(meta: dict, value):
if meta.get("type") == "bool":
return "1" if str(value).lower() in {"1", "true", "yes", "on"} or value is True else "0"
if meta.get("type") == "number":
return str(int(value or 0))
return str(value or "").strip()
def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]:
with connect() as conn:
rows = conn.execute(
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE profile_id=?",
(int(profile_id),),
).fetchall()
return {r["key"]: r for r in rows}
def get_config(profile: dict) -> dict:
c = client_for(profile)
saved = saved_config_overrides(int(profile["id"]))
fields = []
for meta in RTORRENT_CONFIG_FIELDS:
item = dict(meta)
saved_item = saved.get(meta["key"])
try:
item["value"] = _normalize_config_value(meta, c.call(meta["key"]))
item["current_value"] = item["value"]
item["ok"] = True
except Exception as exc:
item["value"] = ""
item["current_value"] = ""
item["ok"] = False
item["error"] = str(exc)
if saved_item:
saved_value = _normalize_config_value(meta, saved_item.get("value"))
baseline_raw = saved_item.get("baseline_value")
if baseline_raw not in (None, ""):
baseline_value = _normalize_config_value(meta, baseline_raw)
else:
baseline_value = _normalize_config_value(meta, item.get("current_value"))
item["saved"] = True
item["saved_value"] = saved_value
item["baseline_value"] = baseline_value
item["apply_on_start"] = bool(saved_item.get("apply_on_start"))
item["changed"] = saved_value != baseline_value
fields.append(item)
return {"fields": fields, "apply_on_start": any(bool(v.get("apply_on_start")) for v in saved.values())}
def default_download_path(profile: dict) -> str:
"""Return rTorrent default download directory for the active profile."""
c = client_for(profile)
errors = []
for method in ("directory.default", "system.cwd"):
try:
value = str(c.call(method) or "").strip()
if value:
return value
except Exception as exc:
errors.append(f"{method}: {exc}")
raise RuntimeError("Cannot read rTorrent default download directory: " + "; ".join(errors))
def generate_config_text(values: dict) -> str:
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
lines = []
for key, value in (values or {}).items():
meta = known.get(key)
if not meta or meta.get("readonly"):
continue
normalized = _normalize_config_value(meta, value)
if meta.get("type") == "text" and any(ch.isspace() for ch in normalized):
normalized = '"' + normalized.replace('\\', '\\\\').replace('"', '\\"') + '"'
lines.append(f"{key}.set = {normalized}")
return "\n".join(lines) + ("\n" if lines else "")
def _read_rtorrent_config_value(client, key: str, meta: dict) -> str:
return _normalize_config_value(meta, client.call(key))
def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]:
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
now = utcnow()
profile_id = int(profile["id"])
baseline_values = baseline_values or {}
clear_set = set(clear_keys or [])
stored = []
with connect() as conn:
for key in clear_set:
if key in known:
conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(profile_id, key),
)
for key, value in (values or {}).items():
if key in clear_set:
continue
meta = known.get(key)
if not meta or meta.get("readonly"):
continue
normalized = _normalize_config_value(meta, value)
existing = conn.execute(
"SELECT baseline_value FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(profile_id, key),
).fetchone()
existing_baseline = existing.get("baseline_value") if existing else None
# Keep the first reference value forever until the override is cleared.
# Without this, a second save could treat already-overridden rTorrent
# values as the new baseline and the UI would stop marking them as changed.
if existing_baseline not in (None, ""):
baseline = _normalize_config_value(meta, existing_baseline)
else:
baseline = _normalize_config_value(meta, baseline_values.get(key)) if key in baseline_values else None
if baseline not in (None, "") and normalized == baseline:
conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(profile_id, key),
)
continue
conn.execute(
"INSERT OR REPLACE INTO rtorrent_config_overrides(profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?)",
(profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
)
stored.append(key)
conn.execute(
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE profile_id=?",
(1 if apply_on_start else 0, now, profile_id),
)
return stored
def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_start: bool = False, clear_keys: list[str] | None = None) -> dict:
updated, errors = [], []
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
c = client_for(profile)
baseline_values = {}
for key, raw_value in (values or {}).items():
meta = known.get(key)
if not meta or meta.get("readonly"):
continue
try:
baseline_values[key] = _read_rtorrent_config_value(c, key, meta)
except Exception:
pass
stored = store_config_overrides(profile, values, apply_on_start, baseline_values, clear_keys)
if not apply_now:
return {"ok": True, "updated": [], "stored": stored, "errors": []}
for key, raw_value in (values or {}).items():
if key not in known:
continue
meta = known[key]
if meta.get("readonly"):
continue
value = _normalize_config_value(meta, raw_value)
rpc_value = int(value) if meta.get("type") in {"bool", "number"} else value
try:
try:
c.call(key + ".set", "", rpc_value)
except Exception:
c.call(key + ".set", rpc_value)
updated.append(key)
except Exception as exc:
errors.append({"key": key, "error": str(exc)})
return {"ok": not errors, "updated": updated, "stored": stored, "errors": errors}
def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict:
"""Remove saved UI overrides and return the freshly read rTorrent config."""
# Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent.
profile_id = int(profile["id"])
with connect() as conn:
row = conn.execute(
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE profile_id=?",
(profile_id,),
).fetchone()
removed = int((row or {}).get("count") or 0)
conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE profile_id=?",
(profile_id,),
)
config = get_config(profile)
config["reset_removed"] = removed
return config
def apply_startup_overrides(profile: dict) -> dict:
rows = saved_config_overrides(int(profile["id"]))
values = {k: v.get("value") for k, v in rows.items() if v.get("apply_on_start")}
if not values:
return {"ok": True, "updated": [], "errors": [], "skipped": True}
return set_config(profile, values, apply_now=True, apply_on_start=True)
# Note: Keep split module exports compatible with the previous single rtorrent.py module.
__all__ = [
name for name in globals()
if not name.startswith("__") and name not in {"annotations"}
]