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