Files
pyTorrent/pytorrent/services/rtorrent/config.py
Mateusz Gruszczyński 70a9344cdd changes in db
2026-05-26 09:25:47 +02:00

253 lines
13 KiB
Python

from __future__ import annotations
from .client import *
RTORRENT_CONFIG_FIELDS = [
{"group": "Directories", "key": "directory.default", "label": "Default download directory", "type": "text"},
{"group": "Directories", "key": "session.path", "label": "Session path", "type": "text"},
{"group": "Directories", "key": "system.cwd", "label": "Working directory", "type": "text", "readonly": True},
{"group": "Network", "key": "network.port_range", "label": "Incoming port range", "type": "text", "placeholder": "49164-49164"},
{"group": "Network", "key": "network.port_random", "label": "Random incoming port", "type": "bool"},
{"group": "Network", "key": "network.bind_address", "label": "Bind address", "type": "text", "placeholder": "0.0.0.0"},
{"group": "Network", "key": "network.local_address", "label": "Local address", "type": "text"},
{"group": "Network", "key": "network.max_open_files", "label": "Max open files", "type": "number"},
{"group": "Network", "key": "network.max_open_sockets", "label": "Max open sockets", "type": "number"},
{"group": "Network", "key": "network.http.max_open", "label": "Max HTTP connections", "type": "number"},
{"group": "Network", "key": "network.http.ssl_verify_peer", "label": "Verify SSL peers", "type": "bool"},
{"group": "Network", "key": "network.xmlrpc.size_limit", "label": "XML-RPC upload size limit", "type": "text", "placeholder": "16M"},
{"group": "Peers", "key": "throttle.min_peers.normal", "label": "Min peers downloading", "type": "number"},
{"group": "Peers", "key": "throttle.max_peers.normal", "label": "Max peers downloading", "type": "number"},
{"group": "Peers", "key": "throttle.min_peers.seed", "label": "Min peers seeding", "type": "number"},
{"group": "Peers", "key": "throttle.max_peers.seed", "label": "Max peers seeding", "type": "number"},
{"group": "Peers", "key": "trackers.numwant", "label": "Tracker numwant", "type": "number"},
{"group": "Throttle", "key": "throttle.global_down.max_rate", "label": "Global download limit B/s", "type": "number"},
{"group": "Throttle", "key": "throttle.global_up.max_rate", "label": "Global upload limit B/s", "type": "number"},
{"group": "Throttle", "key": "throttle.max_downloads.global", "label": "Max active downloads", "type": "number"},
{"group": "Throttle", "key": "throttle.max_uploads.global", "label": "Max active uploads", "type": "number"},
{"group": "Throttle", "key": "throttle.max_downloads.div", "label": "Max downloads per throttle", "type": "number"},
{"group": "Throttle", "key": "throttle.max_uploads.div", "label": "Max uploads per throttle", "type": "number"},
{"group": "DHT / PEX", "key": "dht.mode", "label": "DHT mode", "type": "text", "placeholder": "disable/off/auto/on"},
{"group": "DHT / PEX", "key": "dht.port", "label": "DHT port", "type": "number"},
{"group": "DHT / PEX", "key": "protocol.pex", "label": "Peer exchange", "type": "bool"},
{"group": "Protocol", "key": "protocol.encryption.set", "label": "Encryption flags", "type": "text", "placeholder": "allow_incoming,try_outgoing,enable_retry"},
{"group": "Protocol", "key": "protocol.connection.leech", "label": "Leech connection type", "type": "text", "placeholder": "leech"},
{"group": "Protocol", "key": "protocol.connection.seed", "label": "Seed connection type", "type": "text", "placeholder": "seed"},
{"group": "Files", "key": "pieces.hash.on_completion", "label": "Hash check on completion", "type": "bool"},
{"group": "Files", "key": "pieces.preload.type", "label": "Pieces preload type", "type": "number"},
{"group": "Files", "key": "pieces.preload.min_size", "label": "Pieces preload min size", "type": "number"},
{"group": "Files", "key": "pieces.preload.min_rate", "label": "Pieces preload min rate", "type": "number"},
{"group": "Files", "key": "system.file.allocate", "label": "File allocation", "type": "number"},
{"group": "Files", "key": "system.file.max_size", "label": "Max file size", "type": "number"},
{"group": "System", "key": "system.umask", "label": "File umask", "type": "text", "placeholder": "0002"},
{"group": "System", "key": "system.hostname", "label": "Hostname", "type": "text", "readonly": True},
{"group": "System", "key": "system.client_version", "label": "Client version", "type": "text", "readonly": True},
{"group": "System", "key": "system.library_version", "label": "Library version", "type": "text", "readonly": True},
]
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"}
]