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"} ]