first commit
This commit is contained in:
488
pytorrent/services/rtorrent/system.py
Normal file
488
pytorrent/services/rtorrent/system.py
Normal file
@@ -0,0 +1,488 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from threading import RLock
|
||||
|
||||
from .client import *
|
||||
from .config import default_download_path
|
||||
from ...utils import human_size
|
||||
|
||||
|
||||
def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions."""
|
||||
# Note: Directory browsing stays remote-side, matching the original monolithic service behavior.
|
||||
c = client_for(profile)
|
||||
base = _remote_clean_path(path or default_download_path(profile))
|
||||
script = (
|
||||
'base=$1; '
|
||||
'[ -d "$base" ] || exit 2; '
|
||||
'dfline=$(df -Pk "$base" 2>/dev/null | awk "NR==2{print \\$2,\\$3,\\$4,\\$5}"); '
|
||||
'dir_count=0; file_count=0; '
|
||||
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
||||
'[ -e "$p" ] || continue; '
|
||||
'if [ -d "$p" ]; then dir_count=$((dir_count+1)); name=${p##*/}; printf "D\\t%s\\t%s\\n" "$name" "$p"; '
|
||||
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
||||
'done; '
|
||||
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
||||
'[ -n "$dfline" ] && printf "F\\t%s\\n" "$dfline"'
|
||||
)
|
||||
output = _rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-browse", base)
|
||||
dirs = []
|
||||
dir_count = 0
|
||||
file_count = 0
|
||||
disk_total = disk_used = disk_free = 0
|
||||
disk_percent = 0
|
||||
for line in str(output or "").splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
marker, rest = line.split("\t", 1)
|
||||
if marker == "D" and "\t" in rest:
|
||||
name, full_path = rest.split("\t", 1)
|
||||
if name not in {".", ".."}:
|
||||
dirs.append({"name": name, "path": full_path})
|
||||
elif marker == "M" and "\t" in rest:
|
||||
first, second = rest.split("\t", 1)
|
||||
try:
|
||||
dir_count = int(first or 0)
|
||||
file_count = int(second or 0)
|
||||
except Exception:
|
||||
dir_count = file_count = 0
|
||||
elif marker == "F":
|
||||
parts = rest.split()
|
||||
if len(parts) >= 4:
|
||||
try:
|
||||
disk_total = int(parts[0]) * 1024
|
||||
disk_used = int(parts[1]) * 1024
|
||||
disk_free = int(parts[2]) * 1024
|
||||
disk_percent = int(str(parts[3]).rstrip("%") or 0)
|
||||
except Exception:
|
||||
disk_total = disk_used = disk_free = disk_percent = 0
|
||||
dirs.sort(key=lambda x: x["name"].lower())
|
||||
parent = posixpath.dirname(base.rstrip("/")) or "/"
|
||||
if parent == base:
|
||||
parent = base
|
||||
# Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts.
|
||||
return {
|
||||
"path": base,
|
||||
"parent": parent,
|
||||
"dirs": dirs[:300],
|
||||
"source": "rtorrent",
|
||||
"dir_count": dir_count,
|
||||
"file_count": file_count,
|
||||
"total": disk_total,
|
||||
"used": disk_used,
|
||||
"free": disk_free,
|
||||
"total_h": human_size(disk_total),
|
||||
"used_h": human_size(disk_used),
|
||||
"free_h": human_size(disk_free),
|
||||
"used_percent": disk_percent,
|
||||
}
|
||||
|
||||
def remote_public_ip(profile: dict, force: bool = False) -> str:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
now = time.monotonic()
|
||||
cached = _REMOTE_PUBLIC_IP_CACHE.get(profile_id)
|
||||
if cached and not force and now - cached[0] < _REMOTE_PUBLIC_IP_TTL_SECONDS:
|
||||
return cached[1]
|
||||
script = (
|
||||
'for url in https://ifconfig.co https://ifconfig.me https://ipapi.linuxiarz.pl http://ifconfig.co http://ifconfig.me; do '
|
||||
'ip=$(curl -fsS --max-time 8 "$url" 2>/dev/null | tr -d "\r" | head -n 1 | sed "s/[^0-9a-fA-F:.]//g"); '
|
||||
'if [ -n "$ip" ]; then printf "%s" "$ip"; exit 0; fi; '
|
||||
'done; exit 1'
|
||||
)
|
||||
value = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script) or "").strip()
|
||||
if not value:
|
||||
raise RuntimeError("Cannot read remote public IP")
|
||||
_REMOTE_PUBLIC_IP_CACHE[profile_id] = (now, value)
|
||||
return value
|
||||
|
||||
|
||||
def remote_system_usage(profile: dict, force: bool = False) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
now = time.monotonic()
|
||||
cached = _REMOTE_USAGE_CACHE.get(profile_id)
|
||||
if cached and not force and now - cached[0] < _REMOTE_USAGE_TTL_SECONDS:
|
||||
usage = dict(cached[1])
|
||||
usage["cached"] = True
|
||||
return usage
|
||||
script = (
|
||||
'read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat; '
|
||||
'total1=$((user+nice+system+idle+iowait+irq+softirq+steal)); idle1=$((idle+iowait)); '
|
||||
'sleep 1; '
|
||||
'read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat; '
|
||||
'total2=$((user+nice+system+idle+iowait+irq+softirq+steal)); idle2=$((idle+iowait)); '
|
||||
'dt=$((total2-total1)); di=$((idle2-idle1)); '
|
||||
'cpu_pct=$(awk -v dt="$dt" -v di="$di" "BEGIN { if (dt > 0) printf \"%.1f\", (dt-di)*100/dt; else printf \"0.0\" }"); '
|
||||
"mem_total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo); "
|
||||
"mem_avail=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo); "
|
||||
'ram_pct=$(awk -v t="$mem_total" -v a="$mem_avail" "BEGIN { if (t > 0) printf \"%.1f\", (t-a)*100/t; else printf \"0.0\" }"); '
|
||||
'printf "%s %s" "$cpu_pct" "$ram_pct"'
|
||||
)
|
||||
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script) or "").strip()
|
||||
parts = output.split()
|
||||
if len(parts) < 2:
|
||||
raise RuntimeError(f"Cannot read remote CPU/RAM usage: {output}")
|
||||
usage = {"cpu": float(parts[0]), "ram": float(parts[1]), "source": "rtorrent-remote", "usage_source": "rtorrent-remote", "cached": False}
|
||||
_REMOTE_USAGE_CACHE[profile_id] = (now, usage)
|
||||
return dict(usage)
|
||||
|
||||
|
||||
def _usage_dict(total: int, used: int, free: int) -> dict:
|
||||
total = max(0, int(total or 0))
|
||||
used = max(0, int(used or 0))
|
||||
free = max(0, int(free or 0))
|
||||
pct = round((used / total) * 100, 1) if total else 0.0
|
||||
return {
|
||||
"ok": True,
|
||||
"total": total,
|
||||
"used": used,
|
||||
"free": free,
|
||||
"total_h": human_size(total),
|
||||
"used_h": human_size(used),
|
||||
"free_h": human_size(free),
|
||||
"percent": pct,
|
||||
}
|
||||
|
||||
|
||||
def _statvfs_usage(path: str) -> dict:
|
||||
stat = os.statvfs(path)
|
||||
total = int(stat.f_blocks * stat.f_frsize)
|
||||
free = int(stat.f_bavail * stat.f_frsize)
|
||||
used = max(0, total - free)
|
||||
return _usage_dict(total, used, free)
|
||||
|
||||
|
||||
def _remote_df_usage(profile: dict, path: str) -> dict:
|
||||
# Note: Disk paths belong to the rTorrent host. Query df through rTorrent so NFS/Btrfs mounts are measured correctly.
|
||||
clean_path = _remote_clean_path(path or os.sep)
|
||||
cache_key = f"remote-df:{profile.get('id')}:{clean_path}"
|
||||
now = time.monotonic()
|
||||
cached = _DISK_USAGE_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < _DISK_USAGE_TTL_SECONDS:
|
||||
return dict(cached[1])
|
||||
script = (
|
||||
'path=$1; '
|
||||
'if [ ! -e "$path" ]; then echo "ERR\tmissing path"; exit 0; fi; '
|
||||
'line=$(df -Pk "$path" 2>/dev/null | tail -n 1); '
|
||||
'if [ -z "$line" ]; then echo "ERR\tdf failed"; exit 0; fi; '
|
||||
'set -- $line; pct=${5%\\%}; '
|
||||
'if [ -z "$2" ] || [ -z "$3" ] || [ -z "$4" ]; then echo "ERR\tdf parse failed"; exit 0; fi; '
|
||||
'printf "OK\t%s\t%s\t%s\t%s\t%s\n" "$2" "$3" "$4" "$pct" "$6"'
|
||||
)
|
||||
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script, "pytorrent-df", clean_path) or "").strip()
|
||||
first_line = output.splitlines()[0] if output else ""
|
||||
parts = first_line.split("\t")
|
||||
if len(parts) >= 6 and parts[0] == "OK":
|
||||
total = int(parts[1]) * 1024
|
||||
used = int(parts[2]) * 1024
|
||||
free = int(parts[3]) * 1024
|
||||
usage = _usage_dict(total, used, free)
|
||||
usage.update({"path": clean_path, "source_path": parts[5] or clean_path, "fallback": False, "measure_source": "rtorrent-df"})
|
||||
else:
|
||||
error = parts[1] if len(parts) > 1 else (output or "df returned no data")
|
||||
usage = {"ok": False, "path": clean_path, "source_path": clean_path, "error": error, "percent": 0, "measure_source": "rtorrent-df"}
|
||||
_DISK_USAGE_CACHE[cache_key] = (now, dict(usage))
|
||||
return usage
|
||||
|
||||
|
||||
def _disk_usage_for_path(profile: dict, path: str, allow_parent_fallback: bool = False) -> dict:
|
||||
clean_path = _remote_clean_path(path or os.sep)
|
||||
try:
|
||||
return _remote_df_usage(profile, clean_path)
|
||||
except Exception as remote_exc:
|
||||
try:
|
||||
usage = _statvfs_usage(clean_path)
|
||||
usage.update({"path": clean_path, "source_path": clean_path, "fallback": False, "measure_source": "local-statvfs", "warning": str(remote_exc)})
|
||||
return usage
|
||||
except Exception as first_exc:
|
||||
usage = {"ok": False, "path": clean_path, "source_path": clean_path, "error": str(first_exc), "warning": str(remote_exc), "percent": 0}
|
||||
if not allow_parent_fallback:
|
||||
return usage
|
||||
probe = os.path.abspath(clean_path or os.sep)
|
||||
seen = set()
|
||||
while probe and probe not in seen:
|
||||
seen.add(probe)
|
||||
parent = os.path.dirname(probe)
|
||||
if parent == probe:
|
||||
break
|
||||
probe = parent
|
||||
try:
|
||||
usage = _statvfs_usage(probe)
|
||||
usage.update({"path": clean_path, "source_path": probe, "fallback": True, "measure_source": "local-statvfs", "warning": str(first_exc)})
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return usage
|
||||
|
||||
|
||||
def disk_usage_for_default_path(profile: dict) -> dict:
|
||||
"""Filesystem usage for the rTorrent default download directory."""
|
||||
path = default_download_path(profile)
|
||||
cache_key = f"default-disk:{profile.get('id')}:{path}"
|
||||
now = time.monotonic()
|
||||
cached = _DISK_USAGE_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < _DISK_USAGE_TTL_SECONDS:
|
||||
return dict(cached[1])
|
||||
usage = _disk_usage_for_path(profile, path, allow_parent_fallback=True)
|
||||
_DISK_USAGE_CACHE[cache_key] = (now, dict(usage))
|
||||
return usage
|
||||
|
||||
|
||||
def disk_usage_for_paths(profile: dict, paths: list[str] | None = None, mode: str = 'default', selected_path: str = '') -> dict:
|
||||
# Note: Aggregate/selected modes measure exact user paths on the rTorrent host; they do not fall back to parent/root partitions.
|
||||
default_path = default_download_path(profile)
|
||||
mode = mode if mode in {'default', 'selected', 'aggregate'} else 'default'
|
||||
user_paths: list[str] = []
|
||||
for item in paths or []:
|
||||
path = _remote_clean_path(str(item or '').strip())
|
||||
if path and path not in user_paths:
|
||||
user_paths.append(path)
|
||||
selected_path = _remote_clean_path(str(selected_path or '').strip())
|
||||
if mode == 'selected':
|
||||
source_paths = [selected_path] if selected_path else list(user_paths)
|
||||
elif mode == 'aggregate':
|
||||
source_paths = list(user_paths)
|
||||
else:
|
||||
source_paths = [default_path]
|
||||
if mode in {'selected', 'aggregate'} and not source_paths:
|
||||
source_paths = [default_path]
|
||||
clean_paths: list[str] = []
|
||||
for item in source_paths:
|
||||
path = _remote_clean_path(str(item or '').strip())
|
||||
if path and path not in clean_paths:
|
||||
clean_paths.append(path)
|
||||
entries = [_disk_usage_for_path(profile, path, allow_parent_fallback=(mode == 'default')) for path in clean_paths]
|
||||
chosen = entries[0] if entries else _disk_usage_for_path(profile, default_path, allow_parent_fallback=True)
|
||||
if mode == 'selected' and selected_path:
|
||||
chosen = next((x for x in entries if x.get('path') == selected_path), chosen)
|
||||
elif mode == 'aggregate':
|
||||
ok_entries = [x for x in entries if x.get('ok')]
|
||||
total = sum(int(x.get('total') or 0) for x in ok_entries)
|
||||
used = sum(int(x.get('used') or 0) for x in ok_entries)
|
||||
free = sum(int(x.get('free') or 0) for x in ok_entries)
|
||||
chosen = _usage_dict(total, used, free) if ok_entries else {"ok": False, "total": 0, "used": 0, "free": 0, "total_h": "0 B", "used_h": "0 B", "free_h": "0 B", "percent": 0}
|
||||
chosen.update({'path': 'aggregate', 'source_path': 'aggregate', 'fallback': False, 'measure_source': 'rtorrent-df'})
|
||||
chosen = dict(chosen)
|
||||
chosen['mode'] = mode
|
||||
chosen['paths'] = entries
|
||||
return chosen
|
||||
|
||||
|
||||
|
||||
_STATUS_META_CACHE: dict[int, dict[str, Any]] = {}
|
||||
_STATUS_META_LOCK = RLock()
|
||||
|
||||
|
||||
def _profile_cache_key(profile: dict) -> int:
|
||||
return int(profile.get("id") or 0)
|
||||
|
||||
|
||||
def _adaptive_meta_ttl(duration_ms: float) -> float:
|
||||
# Note: Slow rTorrent metadata calls get a longer TTL, while fast servers keep the footer fresh.
|
||||
if duration_ms >= 5000:
|
||||
return 30.0
|
||||
if duration_ms >= 2000:
|
||||
return 15.0
|
||||
if duration_ms >= 800:
|
||||
return 8.0
|
||||
return 3.0
|
||||
|
||||
|
||||
def _cached_rtorrent_meta(profile: dict, c: Any) -> dict[str, Any]:
|
||||
profile_id = _profile_cache_key(profile)
|
||||
now = time.monotonic()
|
||||
with _STATUS_META_LOCK:
|
||||
cached = _STATUS_META_CACHE.get(profile_id)
|
||||
if cached and now < float(cached.get("expires_at") or 0):
|
||||
meta = dict(cached.get("value") or {})
|
||||
meta["status_meta_cache"] = {"hit": True, "ttl_seconds": cached.get("ttl_seconds"), "duration_ms": cached.get("duration_ms")}
|
||||
return meta
|
||||
started = time.monotonic()
|
||||
version = str(c.system.client_version())
|
||||
try:
|
||||
down_limit = int(c.throttle.global_down.max_rate())
|
||||
except Exception:
|
||||
down_limit = 0
|
||||
try:
|
||||
up_limit = int(c.throttle.global_up.max_rate())
|
||||
except Exception:
|
||||
up_limit = 0
|
||||
meta = {
|
||||
"version": version,
|
||||
"down_limit": down_limit,
|
||||
"up_limit": up_limit,
|
||||
"down_limit_h": human_rate(down_limit) if down_limit else "∞",
|
||||
"up_limit_h": human_rate(up_limit) if up_limit else "∞",
|
||||
"open_sockets": _safe_rtorrent_first_int(c, ("network.open_sockets",)),
|
||||
"max_open_sockets": _safe_rtorrent_first_int(c, ("network.max_open_sockets",)),
|
||||
"open_files": _safe_rtorrent_first_int(c, ("network.open_files", "network.current_open_files", "network.open_file_count")),
|
||||
"max_open_files": _safe_rtorrent_first_int(c, ("network.max_open_files",)),
|
||||
"open_http": _safe_rtorrent_first_int(c, ("network.http.open", "network.http.current_open", "network.http.current_opened", "network.http.open_sockets")),
|
||||
"max_open_http": _safe_rtorrent_first_int(c, ("network.http.max_open",)),
|
||||
"max_downloads_global": _safe_rtorrent_first_int(c, ("throttle.max_downloads.global",)),
|
||||
"max_uploads_global": _safe_rtorrent_first_int(c, ("throttle.max_uploads.global",)),
|
||||
"listen_port": _rtorrent_listen_port(c),
|
||||
"rtorrent_time": _safe_rtorrent_time(c),
|
||||
}
|
||||
duration_ms = round((time.monotonic() - started) * 1000.0, 2)
|
||||
ttl = _adaptive_meta_ttl(duration_ms)
|
||||
with _STATUS_META_LOCK:
|
||||
_STATUS_META_CACHE[profile_id] = {"value": dict(meta), "expires_at": now + ttl, "ttl_seconds": ttl, "duration_ms": duration_ms}
|
||||
meta["status_meta_cache"] = {"hit": False, "ttl_seconds": ttl, "duration_ms": duration_ms}
|
||||
return meta
|
||||
|
||||
|
||||
def clear_profile_runtime_caches(profile_id: int) -> dict[str, int]:
|
||||
"""Clear rTorrent runtime caches that are scoped to a single profile."""
|
||||
# Note: This is used by Cleanup to force fresh disk/status/remote readings without restarting pyTorrent.
|
||||
profile_id = int(profile_id or 0)
|
||||
removed = {"disk_usage": 0, "remote_usage": 0, "remote_public_ip": 0, "status_meta": 0}
|
||||
prefix_candidates = (f"default-disk:{profile_id}:", f"remote-df:{profile_id}:")
|
||||
for key in list(_DISK_USAGE_CACHE.keys()):
|
||||
if any(str(key).startswith(prefix) for prefix in prefix_candidates):
|
||||
_DISK_USAGE_CACHE.pop(key, None)
|
||||
removed["disk_usage"] += 1
|
||||
if _REMOTE_USAGE_CACHE.pop(profile_id, None) is not None:
|
||||
removed["remote_usage"] += 1
|
||||
if _REMOTE_PUBLIC_IP_CACHE.pop(profile_id, None) is not None:
|
||||
removed["remote_public_ip"] += 1
|
||||
with _STATUS_META_LOCK:
|
||||
if _STATUS_META_CACHE.pop(profile_id, None) is not None:
|
||||
removed["status_meta"] += 1
|
||||
return removed
|
||||
|
||||
def _safe_rtorrent_int(callable_obj, default=None):
|
||||
"""Return an integer rTorrent metric without failing the whole status poll."""
|
||||
try:
|
||||
value = callable_obj()
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_rtorrent_value(callable_obj, default=None):
|
||||
"""Return any rTorrent metric without failing the whole status poll."""
|
||||
try:
|
||||
value = callable_obj()
|
||||
return default if value is None else value
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
|
||||
def _rtorrent_read_candidates(method_name: str) -> tuple[str, ...]:
|
||||
"""Return getter variants used by different rTorrent XMLRPC builds."""
|
||||
name = str(method_name or "").strip()
|
||||
if not name:
|
||||
return tuple()
|
||||
candidates = [name]
|
||||
if not name.endswith("="):
|
||||
candidates.append(f"{name}=")
|
||||
else:
|
||||
candidates.append(name.rstrip("="))
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _safe_rtorrent_first_int(c, method_names, default=None):
|
||||
"""Try several rTorrent XMLRPC getter names and return the first integer value."""
|
||||
for method_name in method_names:
|
||||
for candidate in _rtorrent_read_candidates(method_name):
|
||||
value = _safe_rtorrent_int(lambda name=candidate: c.call(name), None)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _safe_rtorrent_first_value(c, method_names, default=None):
|
||||
"""Try several rTorrent XMLRPC getter names and return the first non-empty value."""
|
||||
for method_name in method_names:
|
||||
for candidate in _rtorrent_read_candidates(method_name):
|
||||
value = _safe_rtorrent_value(lambda name=candidate: c.call(name), None)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _rtorrent_listen_port(c):
|
||||
"""Return the configured incoming port, preferring network.port_range over port-open state."""
|
||||
port_range = _safe_rtorrent_first_value(c, ("network.port_range",))
|
||||
if port_range:
|
||||
first = str(port_range).split("-", 1)[0].strip()
|
||||
if first:
|
||||
return first
|
||||
value = _safe_rtorrent_first_value(c, ("network.port_open", "network.open_port"))
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
def _safe_rtorrent_time(c):
|
||||
"""Read rTorrent server time when supported; otherwise let the browser clock remain authoritative."""
|
||||
candidates = (
|
||||
lambda: c.system.time_seconds(),
|
||||
lambda: c.system.time(),
|
||||
)
|
||||
for candidate in candidates:
|
||||
value = _safe_rtorrent_int(candidate)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def system_status(profile: dict, rows: list[dict] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
meta = _cached_rtorrent_meta(profile, c)
|
||||
if rows is None:
|
||||
from .torrents import list_torrents
|
||||
rows = list_torrents(profile)
|
||||
else:
|
||||
rows = list(rows)
|
||||
# Note: ruTorrent-style footer metadata is cached adaptively; live speeds still come from fresh torrent rows.
|
||||
checking_count = sum(1 for t in rows if t.get("status") == "Checking" or int(t.get("hashing") or 0) > 0)
|
||||
active_downloads = sum(1 for t in rows if not t["complete"] and t["state"] and not t.get("paused") and t.get("status") != "Checking")
|
||||
active_uploads = sum(1 for t in rows if t["complete"] and t["state"] and not t.get("paused"))
|
||||
return {
|
||||
"ok": True,
|
||||
"version": meta.get("version"),
|
||||
"total": len(rows),
|
||||
"active": sum(1 for t in rows if t["state"]),
|
||||
"seeding": sum(1 for t in rows if t["complete"] and t["state"] and not t.get("paused")),
|
||||
"leeching": sum(1 for t in rows if not t["complete"] and t["state"] and not t.get("paused") and t.get("status") != "Checking"),
|
||||
"checking": checking_count,
|
||||
"paused": sum(1 for t in rows if t.get("paused")),
|
||||
"stopped": sum(1 for t in rows if not t["state"]),
|
||||
"down_rate": sum(t["down_rate"] for t in rows),
|
||||
"down_rate_h": human_rate(sum(t["down_rate"] for t in rows)),
|
||||
"up_rate": sum(t["up_rate"] for t in rows),
|
||||
"up_rate_h": human_rate(sum(t["up_rate"] for t in rows)),
|
||||
"down_limit": meta.get("down_limit", 0),
|
||||
"up_limit": meta.get("up_limit", 0),
|
||||
"down_limit_h": meta.get("down_limit_h", "∞"),
|
||||
"up_limit_h": meta.get("up_limit_h", "∞"),
|
||||
"total_down": sum(t["down_total"] for t in rows),
|
||||
"total_up": sum(t["up_total"] for t in rows),
|
||||
"total_down_h": human_size(sum(t["down_total"] for t in rows)),
|
||||
"total_up_h": human_size(sum(t["up_total"] for t in rows)),
|
||||
"open_sockets": meta.get("open_sockets"),
|
||||
"max_open_sockets": meta.get("max_open_sockets"),
|
||||
"open_files": meta.get("open_files"),
|
||||
"max_open_files": meta.get("max_open_files"),
|
||||
"open_http": meta.get("open_http"),
|
||||
"max_open_http": meta.get("max_open_http"),
|
||||
"active_downloads": active_downloads,
|
||||
"max_downloads_global": meta.get("max_downloads_global"),
|
||||
"active_uploads": active_uploads,
|
||||
"max_uploads_global": meta.get("max_uploads_global"),
|
||||
"listen_port": meta.get("listen_port"),
|
||||
"rtorrent_time": meta.get("rtorrent_time"),
|
||||
"status_meta_cache": meta.get("status_meta_cache", {}),
|
||||
"disk": disk_usage_for_default_path(profile),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Export private cache-backed helpers where the old monolith exposed them through services.rtorrent.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
Reference in New Issue
Block a user