256 lines
13 KiB
Python
256 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]:
|
|
user_id = user_id or default_user_id()
|
|
with connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
|
(user_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}
|
|
user_id = default_user_id()
|
|
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 user_id=? AND profile_id=? AND key=?",
|
|
(user_id, 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 user_id=? AND profile_id=? AND key=?",
|
|
(user_id, 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 user_id=? AND profile_id=? AND key=?",
|
|
(user_id, profile_id, key),
|
|
)
|
|
continue
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)",
|
|
(user_id, 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 user_id=? AND profile_id=?",
|
|
(1 if apply_on_start else 0, now, user_id, 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.
|
|
user_id = user_id or default_user_id()
|
|
profile_id = int(profile["id"])
|
|
with connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
|
(user_id, profile_id),
|
|
).fetchone()
|
|
removed = int((row or {}).get("count") or 0)
|
|
conn.execute(
|
|
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
|
(user_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"}
|
|
]
|