first commit
This commit is contained in:
10
pytorrent/services/rtorrent/README.md
Normal file
10
pytorrent/services/rtorrent/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# rTorrent service modules
|
||||
|
||||
The old `pytorrent/services/rtorrent.py` monolith is end-of-life.
|
||||
Do not recreate it and do not add new rTorrent logic outside this directory.
|
||||
|
||||
Use focused modules in `pytorrent/services/rtorrent/` instead:
|
||||
- `client.py` for SCGI/XMLRPC transport and shared caches.
|
||||
- `system.py` for status, footer metrics, disk and remote host usage.
|
||||
- `torrents.py` for torrent list and torrent operations.
|
||||
- `files.py`, `config.py`, `diagnostics.py` for their dedicated areas.
|
||||
14
pytorrent/services/rtorrent/__init__.py
Normal file
14
pytorrent/services/rtorrent/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith.
|
||||
# All rTorrent code belongs in this package directory.
|
||||
|
||||
# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent.
|
||||
# Compatibility note: module __all__ definitions include selected private helpers used by existing routes.
|
||||
from .client import *
|
||||
from .system import *
|
||||
from .diagnostics import *
|
||||
from .files import *
|
||||
from .config import *
|
||||
from .torrents import *
|
||||
from .chunks import *
|
||||
207
pytorrent/services/rtorrent/chunks.py
Normal file
207
pytorrent/services/rtorrent/chunks.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
|
||||
|
||||
_HEX_RE = re.compile(r"[0-9a-fA-F]")
|
||||
|
||||
|
||||
def _clean_hex_bitfield(value) -> str:
|
||||
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
||||
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
|
||||
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
||||
|
||||
|
||||
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
||||
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
||||
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
|
||||
bits: list[int] = []
|
||||
for char in _clean_hex_bitfield(value):
|
||||
nibble = int(char, 16)
|
||||
bits.extend([
|
||||
1 if nibble & 0b1000 else 0,
|
||||
1 if nibble & 0b0100 else 0,
|
||||
1 if nibble & 0b0010 else 0,
|
||||
1 if nibble & 0b0001 else 0,
|
||||
])
|
||||
if limit is not None and limit >= 0:
|
||||
if len(bits) < limit:
|
||||
bits.extend([0] * (limit - len(bits)))
|
||||
return bits[:limit]
|
||||
return bits
|
||||
|
||||
|
||||
def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
|
||||
"""Classify a visual chunk cell for CSS and filtering."""
|
||||
if total <= 0:
|
||||
return "missing"
|
||||
if completed >= total:
|
||||
return "complete"
|
||||
if completed <= 0:
|
||||
return "seen" if seen else "missing"
|
||||
return "partial"
|
||||
|
||||
|
||||
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
||||
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
|
||||
if max_cells <= 0 or len(cells) <= max_cells:
|
||||
return cells
|
||||
grouped: list[dict] = []
|
||||
scale = len(cells) / float(max_cells)
|
||||
for out_idx in range(max_cells):
|
||||
start = int(math.floor(out_idx * scale))
|
||||
end = int(math.floor((out_idx + 1) * scale))
|
||||
part = cells[start:max(end, start + 1)]
|
||||
if not part:
|
||||
continue
|
||||
completed = sum(int(c.get("completed") or 0) for c in part)
|
||||
total = sum(int(c.get("total") or 0) for c in part)
|
||||
seen = any(bool(c.get("seen")) for c in part)
|
||||
percent = round((completed / total) * 100.0, 2) if total > 0 else 0.0
|
||||
grouped.append({
|
||||
"index": out_idx,
|
||||
"first_chunk": int(part[0].get("first_chunk", 0)),
|
||||
"last_chunk": int(part[-1].get("last_chunk", 0)),
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
"percent": percent,
|
||||
"seen": seen,
|
||||
"status": _chunk_status(completed, total, seen),
|
||||
"grouped": True,
|
||||
"unit_count": len(part),
|
||||
})
|
||||
return grouped
|
||||
|
||||
|
||||
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
||||
"""Create one raw cell per real torrent piece."""
|
||||
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
|
||||
cells: list[dict] = []
|
||||
for idx in range(max(0, int(total_chunks or 0))):
|
||||
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
||||
seen = idx < len(seen_bits) and bool(seen_bits[idx])
|
||||
cells.append({
|
||||
"index": idx,
|
||||
"first_chunk": idx,
|
||||
"last_chunk": idx,
|
||||
"completed": completed,
|
||||
"total": 1,
|
||||
"percent": 100.0 if completed else 0.0,
|
||||
"seen": seen,
|
||||
"status": _chunk_status(completed, 1, seen),
|
||||
"grouped": False,
|
||||
"unit_count": 1,
|
||||
})
|
||||
return cells
|
||||
|
||||
|
||||
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
||||
"""Return ruTorrent-like visual chunk data for one torrent."""
|
||||
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
|
||||
c = client_for(profile)
|
||||
values = {
|
||||
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
||||
"seen": "",
|
||||
"chunk_size": 0,
|
||||
"size_chunks": 0,
|
||||
"completed_chunks": 0,
|
||||
"chunks_hashed": 0,
|
||||
}
|
||||
optional_calls = {
|
||||
"seen": "d.chunks_seen",
|
||||
"chunk_size": "d.chunk_size",
|
||||
"size_chunks": "d.size_chunks",
|
||||
"completed_chunks": "d.completed_chunks",
|
||||
"chunks_hashed": "d.chunks_hashed",
|
||||
}
|
||||
for key, method in optional_calls.items():
|
||||
try:
|
||||
raw = c.call(method, torrent_hash)
|
||||
values[key] = _clean_hex_bitfield(raw) if key == "seen" else int(raw or 0)
|
||||
except Exception:
|
||||
values[key] = "" if key == "seen" else 0
|
||||
|
||||
total_chunks = int(values["size_chunks"] or 0)
|
||||
completed = int(values["completed_chunks"] or 0)
|
||||
if total_chunks <= 0:
|
||||
total_chunks = max(completed, len(values["bitfield"]) * 4)
|
||||
|
||||
have_bits = _hex_to_bits(values["bitfield"], total_chunks)
|
||||
seen_bits = _hex_to_bits(values["seen"], total_chunks)
|
||||
cells = _build_piece_cells(total_chunks, have_bits, seen_bits)
|
||||
|
||||
visual_cells = _group_cells(cells, max(64, min(10000, int(max_cells or 2048))))
|
||||
return {
|
||||
"hash": torrent_hash,
|
||||
"chunk_size": int(values["chunk_size"] or 0),
|
||||
"chunk_size_h": human_size(values["chunk_size"] or 0),
|
||||
"size_chunks": total_chunks,
|
||||
"completed_chunks": completed,
|
||||
"chunks_hashed": int(values["chunks_hashed"] or 0),
|
||||
"bitfield_units": len(have_bits),
|
||||
"visual_cells": len(visual_cells),
|
||||
"grouped": len(visual_cells) != len(cells),
|
||||
"cells": visual_cells,
|
||||
"summary": {
|
||||
"complete": sum(1 for c in visual_cells if c.get("status") == "complete"),
|
||||
"partial": sum(1 for c in visual_cells if c.get("status") == "partial"),
|
||||
"missing": sum(1 for c in visual_cells if c.get("status") == "missing"),
|
||||
"seen": sum(1 for c in visual_cells if c.get("status") == "seen"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk: int, last_chunk: int) -> list[dict]:
|
||||
"""Find files whose rTorrent chunk range overlaps the selected visual cells."""
|
||||
# Note: rTorrent exposes file chunk coverage through f.range_first and f.range_second; the second value is exclusive.
|
||||
rows = c.f.multicall(torrent_hash, "", "f.path=", "f.range_first=", "f.range_second=", "f.priority=")
|
||||
matches = []
|
||||
for idx, row in enumerate(rows):
|
||||
start = int(row[1] or 0)
|
||||
end_exclusive = int(row[2] or 0)
|
||||
end = max(start, end_exclusive - 1)
|
||||
if start <= last_chunk and end >= first_chunk:
|
||||
matches.append({
|
||||
"index": idx,
|
||||
"path": str(row[0] or ""),
|
||||
"range_first": start,
|
||||
"range_second": end_exclusive,
|
||||
"priority": int(row[3] or 0),
|
||||
})
|
||||
return matches
|
||||
|
||||
|
||||
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
||||
"""Run safe actions related to visual chunk selection."""
|
||||
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
|
||||
payload = payload or {}
|
||||
action = str(action or "").strip().lower()
|
||||
c = client_for(profile)
|
||||
if action == "recheck":
|
||||
c.call("d.check_hash", torrent_hash)
|
||||
return {"action": action, "message": "Torrent hash check queued", "scope": "torrent"}
|
||||
if action == "prioritize_files":
|
||||
first_chunk = max(0, int(payload.get("first_chunk") or 0))
|
||||
last_chunk = max(first_chunk, int(payload.get("last_chunk") if payload.get("last_chunk") is not None else first_chunk))
|
||||
priority = max(0, min(3, int(payload.get("priority") or 2)))
|
||||
matches = _files_touching_chunks(c, torrent_hash, first_chunk, last_chunk)
|
||||
if not matches:
|
||||
return {"action": action, "updated": [], "errors": [{"error": "No files overlap selected chunk range"}]}
|
||||
result = set_file_priorities(profile, torrent_hash, [{"index": m["index"], "priority": priority} for m in matches])
|
||||
try:
|
||||
c.call("d.update_priorities", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
result.update({"action": action, "files": matches, "priority": priority, "first_chunk": first_chunk, "last_chunk": last_chunk})
|
||||
return result
|
||||
raise ValueError("Unknown chunk action")
|
||||
|
||||
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
364
pytorrent/services/rtorrent/client.py
Normal file
364
pytorrent/services/rtorrent/client.py
Normal file
@@ -0,0 +1,364 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import posixpath
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
from xmlrpc.client import Binary, dumps, loads
|
||||
from pathlib import Path as LocalPath
|
||||
from ...utils import human_rate, human_size
|
||||
from ...db import connect, default_user_id, utcnow
|
||||
from ...config import PYTORRENT_TMP_DIR, REMOTE_READ_CHUNK_BYTES
|
||||
|
||||
|
||||
class ScgiMethod:
|
||||
def __init__(self, client: "ScgiRtorrentClient", name: str):
|
||||
self.client = client
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return ScgiMethod(self.client, f"{self.name}.{name}")
|
||||
|
||||
def __call__(self, *args):
|
||||
return self.client.call(self.name, *args)
|
||||
|
||||
|
||||
class ScgiRtorrentClient:
|
||||
"""XML-RPC over SCGI client for rTorrent network.scgi.open_port."""
|
||||
|
||||
def __init__(self, url: str, timeout: int = 5):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "scgi":
|
||||
raise ValueError("SCGI URL must start with scgi://")
|
||||
if not parsed.hostname or not parsed.port:
|
||||
raise ValueError("SCGI URL must include host and port, e.g. scgi://127.0.0.1:5000/RPC2")
|
||||
self.host = parsed.hostname
|
||||
self.port = parsed.port
|
||||
self.timeout = timeout
|
||||
self.path = parsed.path or "/RPC2"
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return ScgiMethod(self, name)
|
||||
|
||||
def call(self, method_name: str, *args):
|
||||
body = dumps(args, methodname=method_name, allow_none=True).encode("utf-8")
|
||||
headers = {
|
||||
"CONTENT_LENGTH": str(len(body)),
|
||||
"SCGI": "1",
|
||||
"REQUEST_METHOD": "POST",
|
||||
"REQUEST_URI": self.path,
|
||||
"SCRIPT_NAME": self.path,
|
||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||
"CONTENT_TYPE": "text/xml",
|
||||
}
|
||||
header_blob = b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in headers.items())
|
||||
payload = str(len(header_blob)).encode("ascii") + b":" + header_blob + b"," + body
|
||||
attempts = _scgi_retry_attempts()
|
||||
last_exc = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
|
||||
sock.settimeout(self.timeout)
|
||||
sock.sendall(payload)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
response = b"".join(chunks)
|
||||
if not response:
|
||||
raise ConnectionError("Empty response from rTorrent SCGI")
|
||||
if b"\r\n\r\n" in response:
|
||||
response = response.split(b"\r\n\r\n", 1)[1]
|
||||
elif b"\n\n" in response:
|
||||
response = response.split(b"\n\n", 1)[1]
|
||||
result, _ = loads(response)
|
||||
return result[0] if len(result) == 1 else result
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt >= attempts or not _is_transient_scgi_error(exc):
|
||||
raise
|
||||
time.sleep(_scgi_retry_delay(attempt))
|
||||
raise last_exc or ConnectionError("rTorrent SCGI call failed")
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Shared runtime caches and post-check state live in the client module so split service modules keep the same process-wide behavior as the old monolith.
|
||||
_DISK_USAGE_CACHE: dict[str, tuple[float, dict]] = {}
|
||||
_DISK_USAGE_TTL_SECONDS = 30.0
|
||||
_REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
|
||||
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
||||
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
||||
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
|
||||
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
|
||||
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
||||
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
||||
_POST_CHECK_WATCH: dict[int, dict[str, float]] = {}
|
||||
|
||||
def _scgi_retry_attempts() -> int:
|
||||
# Note: Short retry/backoff protects bulk operations from temporary Errno 111 during high rTorrent load.
|
||||
try:
|
||||
return max(1, min(10, int(os.environ.get("PYTORRENT_SCGI_RETRIES", "5"))))
|
||||
except Exception:
|
||||
return 5
|
||||
|
||||
|
||||
def _scgi_retry_delay(attempt: int) -> float:
|
||||
return min(5.0, 0.35 * (2 ** max(0, attempt - 1)))
|
||||
|
||||
|
||||
def _is_transient_scgi_error(exc: Exception) -> bool:
|
||||
# Note: Retry covers common temporary SCGI/socket errors but does not hide semantic XML-RPC errors.
|
||||
if isinstance(exc, (ConnectionRefusedError, ConnectionResetError, TimeoutError, socket.timeout)):
|
||||
return True
|
||||
err_no = getattr(exc, "errno", None)
|
||||
if err_no in {errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH}:
|
||||
return True
|
||||
msg = str(exc).lower()
|
||||
return any(text in msg for text in ("connection refused", "connection reset", "timed out", "timeout", "empty response", "pipe creation failed", "resource temporarily unavailable", "try again", "temporarily unavailable"))
|
||||
|
||||
|
||||
def client_for(profile: dict) -> ScgiRtorrentClient:
|
||||
return ScgiRtorrentClient(profile["scgi_url"], int(profile.get("timeout_seconds") or 5))
|
||||
|
||||
|
||||
_UNSUPPORTED_EXEC_METHODS: set[str] = set()
|
||||
_EXEC_TARGET_STYLE: dict[str, int] = {}
|
||||
|
||||
def _rt_execute_preview(method_name: str, call_args: tuple) -> str:
|
||||
# Note: The compact RPC summary removes long scripts from error messages while keeping the method and first arguments for diagnostics.
|
||||
preview = ", ".join(repr(x) for x in call_args[:3])
|
||||
if len(call_args) > 3:
|
||||
preview += ", ..."
|
||||
return f"{method_name}({preview})"
|
||||
|
||||
|
||||
def _rt_execute_target_variants(method: str, args: tuple) -> list[tuple]:
|
||||
# Note: Depending on version, rTorrent XML-RPC either requires or rejects an empty target; cache the working variant per method.
|
||||
variants = [("", *args), args]
|
||||
preferred = _EXEC_TARGET_STYLE.get(method)
|
||||
if preferred is not None and 0 <= preferred < len(variants):
|
||||
return [variants[preferred]] + [v for i, v in enumerate(variants) if i != preferred]
|
||||
return variants
|
||||
|
||||
|
||||
def _is_rt_method_missing(exc: Exception) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return "not defined" in msg or "no such method" in msg or "unknown method" in msg
|
||||
|
||||
|
||||
def _rt_execute_methods(method: str) -> list[str]:
|
||||
# Note: execute2.* is tried only when the base execute.* method does not exist to avoid false retry errors.
|
||||
methods = [method]
|
||||
if method.startswith("execute."):
|
||||
fallback = method.replace("execute.", "execute2.", 1)
|
||||
if fallback not in _UNSUPPORTED_EXEC_METHODS:
|
||||
methods.append(fallback)
|
||||
return methods
|
||||
|
||||
|
||||
def _rt_execute(c: ScgiRtorrentClient, method: str, *args):
|
||||
"""Run rTorrent execute.* as the rTorrent user across XML-RPC variants."""
|
||||
errors: list[str] = []
|
||||
attempts = _scgi_retry_attempts()
|
||||
for attempt in range(1, attempts + 1):
|
||||
errors.clear()
|
||||
transient_seen = False
|
||||
primary_missing = False
|
||||
for method_index, method_name in enumerate(_rt_execute_methods(method)):
|
||||
if method_name in _UNSUPPORTED_EXEC_METHODS:
|
||||
continue
|
||||
if method_index > 0 and not primary_missing:
|
||||
continue
|
||||
for call_args in _rt_execute_target_variants(method_name, args):
|
||||
try:
|
||||
result = c.call(method_name, *call_args)
|
||||
if method_name == method:
|
||||
_EXEC_TARGET_STYLE[method_name] = 0 if call_args and call_args[0] == "" else 1
|
||||
return result
|
||||
except Exception as exc:
|
||||
if _is_rt_method_missing(exc):
|
||||
_UNSUPPORTED_EXEC_METHODS.add(method_name)
|
||||
if method_name == method:
|
||||
primary_missing = True
|
||||
errors.append(f"{method_name}: method not defined")
|
||||
break
|
||||
transient_seen = transient_seen or _is_transient_scgi_error(exc)
|
||||
errors.append(f"{_rt_execute_preview(method_name, call_args)}: {exc}")
|
||||
if transient_seen and attempt < attempts:
|
||||
time.sleep(_scgi_retry_delay(attempt))
|
||||
continue
|
||||
break
|
||||
raise RuntimeError("rTorrent execute failed: " + "; ".join(errors))
|
||||
|
||||
|
||||
def _is_rt_timeout_error(exc: Exception) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return isinstance(exc, (TimeoutError, socket.timeout)) or "timed out" in msg or "timeout" in msg
|
||||
|
||||
|
||||
def _rt_execute_allow_timeout(c: ScgiRtorrentClient, method: str, *args):
|
||||
try:
|
||||
return _rt_execute(c, method, *args)
|
||||
except Exception as exc:
|
||||
if _is_rt_timeout_error(exc):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _remote_clean_path(path: str) -> str:
|
||||
path = str(path or "").strip()
|
||||
return posixpath.normpath(path) if path else path
|
||||
|
||||
|
||||
def _remote_join(*parts: str) -> str:
|
||||
cleaned = [str(p).strip().rstrip("/") for p in parts if str(p).strip()]
|
||||
return posixpath.normpath(posixpath.join(*cleaned)) if cleaned else ""
|
||||
|
||||
|
||||
def _run_remote_move(c: ScgiRtorrentClient, src: str, dst: str, poll_interval: float = 2.0) -> None:
|
||||
"""Run a remote mv without binding the transfer time to the SCGI timeout."""
|
||||
token = uuid.uuid4().hex
|
||||
status_path = f"/tmp/pytorrent-move-{token}.status"
|
||||
start_script = (
|
||||
'src=$1; dst=$2; status=$3; tmp=${status}.tmp; '
|
||||
'rm -f "$status" "$tmp"; '
|
||||
'( '
|
||||
'rc=0; '
|
||||
'parent=${dst%/*}; '
|
||||
'if [ -z "$dst" ] || [ "$dst" = "/" ]; then echo "unsafe destination: $dst" >&2; rc=5; fi; '
|
||||
'if [ $rc -eq 0 ] && [ -n "$parent" ] && [ "$parent" != "$dst" ]; then mkdir -p "$parent" || rc=$?; fi; '
|
||||
'if [ $rc -eq 0 ] && [ "$src" = "$dst" ]; then :; '
|
||||
'elif [ $rc -eq 0 ] && { [ -e "$dst" ] || [ -L "$dst" ]; } && [ ! -e "$src" ] && [ ! -L "$src" ]; then :; '
|
||||
'elif [ $rc -eq 0 ] && [ ! -e "$src" ] && [ ! -L "$src" ]; then echo "source missing: $src" >&2; rc=3; '
|
||||
'elif [ $rc -eq 0 ] && { [ -e "$dst" ] || [ -L "$dst" ]; }; then rm -rf -- "$dst" && mv -f -- "$src" "$dst" || rc=$?; '
|
||||
'elif [ $rc -eq 0 ]; then mv -f -- "$src" "$dst" || rc=$?; '
|
||||
'fi; '
|
||||
'if [ $rc -eq 0 ]; then printf "OK\n" > "$status"; '
|
||||
'else printf "ERR %s\n" "$rc" > "$status"; fi; '
|
||||
'if [ -s "$tmp" ]; then cat "$tmp" >> "$status"; fi; '
|
||||
'rm -f "$tmp" '
|
||||
') > "$tmp" 2>&1 &'
|
||||
)
|
||||
poll_script = 'status=$1; [ -f "$status" ] && cat "$status" || true'
|
||||
cleanup_script = 'rm -f "$1"'
|
||||
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "sh", "-c", start_script, "pytorrent-move-start", src, dst, status_path)
|
||||
|
||||
while True:
|
||||
time.sleep(max(0.25, poll_interval))
|
||||
try:
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-move-poll", status_path) or "").strip()
|
||||
except Exception as exc:
|
||||
# Note: During bulk moves, rTorrent may briefly not create the execute.capture pipe; polling waits and retries.
|
||||
if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc):
|
||||
continue
|
||||
raise
|
||||
if not output:
|
||||
continue
|
||||
try:
|
||||
_rt_execute(c, "execute.throw", "sh", "-c", cleanup_script, "pytorrent-move-clean", status_path)
|
||||
except Exception:
|
||||
pass
|
||||
first_line = output.splitlines()[0].strip()
|
||||
if first_line == "OK":
|
||||
return
|
||||
if first_line.startswith("ERR"):
|
||||
details = "\n".join(output.splitlines()[1:]).strip()
|
||||
raise RuntimeError(details or first_line)
|
||||
raise RuntimeError(output)
|
||||
|
||||
|
||||
def _torrent_data_path(c: ScgiRtorrentClient, torrent_hash: str) -> str:
|
||||
"""Return data path as rTorrent sees it; do not touch pyTorrent local FS."""
|
||||
try:
|
||||
src = str(c.call("d.base_path", torrent_hash) or "").strip()
|
||||
if src:
|
||||
return src
|
||||
except Exception:
|
||||
pass
|
||||
directory = str(c.call("d.directory", torrent_hash) or "").strip()
|
||||
name = str(c.call("d.name", torrent_hash) or "").strip()
|
||||
try:
|
||||
is_multi = int(c.call("d.is_multi_file", torrent_hash) or 0)
|
||||
except Exception:
|
||||
is_multi = 0
|
||||
if is_multi:
|
||||
return directory
|
||||
if directory and name:
|
||||
return _remote_join(directory, name)
|
||||
return directory
|
||||
|
||||
|
||||
def _safe_rm_rf_path(path: str) -> str:
|
||||
path = _remote_clean_path(path)
|
||||
if not path or path in {"/", "."}:
|
||||
raise ValueError("Refusing to remove an unsafe data path")
|
||||
if path.rstrip("/").count("/") < 1:
|
||||
raise ValueError(f"Refusing to remove an unsafe data path: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0) -> None:
|
||||
# Note: rm -rf runs in the background on the rTorrent side, so long deletes do not hold a single SCGI connection.
|
||||
token = uuid.uuid4().hex
|
||||
status_path = f"/tmp/pytorrent-rm-{token}.status"
|
||||
script = (
|
||||
'target=$1; status=$2; tmp=${status}.tmp; '
|
||||
'rm -f "$status" "$tmp"; '
|
||||
'( rc=0; '
|
||||
'if [ -z "$target" ] || [ "$target" = "/" ] || [ "$target" = "." ]; then echo "unsafe remove target: $target" >&2; rc=5; '
|
||||
'else rm -rf -- "$target" || rc=$?; fi; '
|
||||
'if [ $rc -eq 0 ]; then printf "OK\n" > "$status"; else printf "ERR %s\n" "$rc" > "$status"; fi; '
|
||||
'if [ -s "$tmp" ]; then cat "$tmp" >> "$status"; fi; '
|
||||
'rm -f "$tmp" ) > "$tmp" 2>&1 &'
|
||||
)
|
||||
poll_script = 'status=$1; [ -f "$status" ] && cat "$status" || true'
|
||||
cleanup_script = 'rm -f "$1"'
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "sh", "-c", script, "pytorrent-rm-start", path, status_path)
|
||||
while True:
|
||||
time.sleep(max(0.25, poll_interval))
|
||||
try:
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-rm-poll", status_path) or "").strip()
|
||||
except Exception as exc:
|
||||
# Note: Remove uses the same safe polling as move, so a temporary missing pipe does not fail the whole queue.
|
||||
if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc):
|
||||
continue
|
||||
raise
|
||||
if not output:
|
||||
continue
|
||||
try:
|
||||
_rt_execute(c, "execute.throw", "sh", "-c", cleanup_script, "pytorrent-rm-clean", status_path)
|
||||
except Exception:
|
||||
pass
|
||||
first_line = output.splitlines()[0].strip()
|
||||
if first_line == "OK":
|
||||
return
|
||||
if first_line.startswith("ERR"):
|
||||
details = "\n".join(output.splitlines()[1:]).strip()
|
||||
raise RuntimeError(details or first_line)
|
||||
raise RuntimeError(output)
|
||||
|
||||
|
||||
def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
data_path = _safe_rm_rf_path(_torrent_data_path(c, torrent_hash))
|
||||
try:
|
||||
c.call("d.stop", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.call("d.close", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
_run_remote_rm(c, data_path)
|
||||
return {"hash": torrent_hash, "removed_path": data_path}
|
||||
|
||||
|
||||
|
||||
# Note: Focused rTorrent modules share low-level helpers with wildcard imports; keep private helper names available internally.
|
||||
__all__ = [name for name in globals() if not name.startswith('__')]
|
||||
255
pytorrent/services/rtorrent/config.py
Normal file
255
pytorrent/services/rtorrent/config.py
Normal file
@@ -0,0 +1,255 @@
|
||||
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"}
|
||||
]
|
||||
118
pytorrent/services/rtorrent/diagnostics.py
Normal file
118
pytorrent/services/rtorrent/diagnostics.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
import shlex
|
||||
|
||||
def scgi_diagnostics(profile: dict) -> dict:
|
||||
c = client_for(profile)
|
||||
started = time.perf_counter()
|
||||
body = dumps((), methodname="system.client_version", allow_none=True).encode("utf-8")
|
||||
headers = {
|
||||
"CONTENT_LENGTH": str(len(body)),
|
||||
"SCGI": "1",
|
||||
"REQUEST_METHOD": "POST",
|
||||
"REQUEST_URI": c.path,
|
||||
"SCRIPT_NAME": c.path,
|
||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||
"CONTENT_TYPE": "text/xml",
|
||||
}
|
||||
header_blob = b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in headers.items())
|
||||
payload = str(len(header_blob)).encode("ascii") + b":" + header_blob + b"," + body
|
||||
metrics = {
|
||||
"url": profile.get("scgi_url"),
|
||||
"host": c.host,
|
||||
"port": c.port,
|
||||
"path": c.path,
|
||||
"timeout_seconds": c.timeout,
|
||||
"request_bytes": len(payload),
|
||||
}
|
||||
connect_started = time.perf_counter()
|
||||
with socket.create_connection((c.host, c.port), timeout=c.timeout) as sock:
|
||||
sock.settimeout(c.timeout)
|
||||
metrics["connect_ms"] = round((time.perf_counter() - connect_started) * 1000, 2)
|
||||
send_started = time.perf_counter()
|
||||
sock.sendall(payload)
|
||||
metrics["send_ms"] = round((time.perf_counter() - send_started) * 1000, 2)
|
||||
chunks: list[bytes] = []
|
||||
first_byte_at = None
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if chunk and first_byte_at is None:
|
||||
first_byte_at = time.perf_counter()
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
response = b"".join(chunks)
|
||||
metrics["response_bytes"] = len(response)
|
||||
metrics["first_byte_ms"] = round(((first_byte_at or time.perf_counter()) - started) * 1000, 2)
|
||||
metrics["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||
if not response:
|
||||
raise ConnectionError("Empty response from rTorrent SCGI")
|
||||
xml_response = response
|
||||
if b"\r\n\r\n" in xml_response:
|
||||
xml_response = xml_response.split(b"\r\n\r\n", 1)[1]
|
||||
elif b"\n\n" in xml_response:
|
||||
xml_response = xml_response.split(b"\n\n", 1)[1]
|
||||
result, _ = loads(xml_response)
|
||||
metrics["xml_bytes"] = len(xml_response)
|
||||
metrics["client_version"] = str(result[0]) if result else ""
|
||||
metrics["ok"] = True
|
||||
return metrics
|
||||
|
||||
|
||||
|
||||
def profile_diagnostics(profile: dict) -> dict:
|
||||
"""Lightweight per-profile diagnostics for save/test UI."""
|
||||
started = time.perf_counter()
|
||||
result = {"profile_id": profile.get("id"), "ok": False, "checks": {}}
|
||||
try:
|
||||
c = client_for(profile)
|
||||
version = str(c.call("system.client_version") or "")
|
||||
library = ""
|
||||
try:
|
||||
library = str(c.call("system.library_version") or "")
|
||||
except Exception:
|
||||
library = ""
|
||||
paths = {}
|
||||
for key, method in (("default_directory", "directory.default"), ("cwd", "system.cwd")):
|
||||
try:
|
||||
paths[key] = str(c.call(method) or "")
|
||||
except Exception as exc:
|
||||
paths[key] = {"error": str(exc)}
|
||||
write_permissions = {}
|
||||
free_disk = {}
|
||||
base = paths.get("default_directory") if isinstance(paths.get("default_directory"), str) else ""
|
||||
if base:
|
||||
try:
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"test -w {shlex.quote(base)} && printf writable || printf readonly")
|
||||
write_permissions[base] = str(out or "").strip() or "unknown"
|
||||
except Exception as exc:
|
||||
write_permissions[base] = f"error: {exc}"
|
||||
try:
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"df -Pk {shlex.quote(base)} | tail -1 | awk '{{print $4}}'")
|
||||
kb = int(str(out or "0").strip() or 0)
|
||||
free_disk[base] = {"free_bytes": kb * 1024, "free_h": human_size(kb * 1024)}
|
||||
except Exception as exc:
|
||||
free_disk[base] = {"error": str(exc)}
|
||||
result.update({
|
||||
"ok": True,
|
||||
"status": "online",
|
||||
"version": version,
|
||||
"library_version": library,
|
||||
"base_paths": paths,
|
||||
"write_permissions": write_permissions,
|
||||
"free_disk": free_disk,
|
||||
"response_time_ms": round((time.perf_counter() - started) * 1000, 2),
|
||||
})
|
||||
except Exception as exc:
|
||||
result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)})
|
||||
if result.get("ok") and result.get("response_time_ms", 0) > 1500:
|
||||
result["status"] = "slow"
|
||||
return result
|
||||
|
||||
|
||||
# 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"}
|
||||
]
|
||||
353
pytorrent/services/rtorrent/files.py
Normal file
353
pytorrent/services/rtorrent/files.py
Normal file
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
|
||||
def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
rows = client_for(profile).f.multicall(torrent_hash, "", "f.path=", "f.size_bytes=", "f.completed_chunks=", "f.size_chunks=", "f.priority=")
|
||||
files = []
|
||||
for idx, r in enumerate(rows):
|
||||
size = int(r[1] or 0)
|
||||
completed_chunks = int(r[2] or 0)
|
||||
size_chunks = int(r[3] or 0)
|
||||
progress = 100.0 if size <= 0 else round((completed_chunks / size_chunks) * 100, 2) if size_chunks else 0.0
|
||||
files.append({
|
||||
"index": idx,
|
||||
"path": r[0],
|
||||
"size": size,
|
||||
"size_h": human_size(size),
|
||||
"completed_chunks": completed_chunks,
|
||||
"size_chunks": size_chunks,
|
||||
"progress": min(100.0, max(0.0, progress)),
|
||||
"priority": int(r[4] or 0),
|
||||
})
|
||||
return files
|
||||
|
||||
|
||||
def torrent_file_tree(profile: dict, torrent_hash: str) -> dict:
|
||||
# Note: The tree is built from rTorrent file paths without changing the existing flat file API.
|
||||
root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
parts = [part for part in str(item.get("path") or "").split("/") if part]
|
||||
node = root
|
||||
prefix: list[str] = []
|
||||
for part in parts[:-1]:
|
||||
prefix.append(part)
|
||||
children = node.setdefault("children", {})
|
||||
node = children.setdefault(part, {"name": part, "path": "/".join(prefix), "type": "directory", "size": 0, "children": {}})
|
||||
name = parts[-1] if parts else str(item.get("path") or f"file-{item.get('index')}")
|
||||
child = dict(item)
|
||||
child.update({"name": name, "type": "file"})
|
||||
node.setdefault("children", {})[name] = child
|
||||
def finalize(node: dict) -> dict:
|
||||
if node.get("type") == "file":
|
||||
return node
|
||||
children = [finalize(v) for v in node.get("children", {}).values()]
|
||||
children.sort(key=lambda x: (x.get("type") != "directory", str(x.get("name") or "").lower()))
|
||||
node["children"] = children
|
||||
node["size"] = sum(int(c.get("size") or 0) for c in children)
|
||||
node["size_h"] = human_size(node["size"])
|
||||
return node
|
||||
return finalize(root)
|
||||
|
||||
|
||||
|
||||
def _torrent_file_remote_path(profile: dict, torrent_hash: str, index: int) -> tuple[dict, str]:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
selected = next((f for f in files if int(f.get("index", -1)) == int(index)), None)
|
||||
if selected is None:
|
||||
available = ", ".join(str(f.get("index")) for f in files[:20]) or "none"
|
||||
raise ValueError(f"File index {index} not found. Available indexes: {available}")
|
||||
base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
|
||||
rel = str(selected.get("path") or "").lstrip("/")
|
||||
if len(files) == 1 and base and not base.endswith("/"):
|
||||
path = base
|
||||
else:
|
||||
path = _remote_join(base, rel)
|
||||
return selected, path
|
||||
|
||||
|
||||
def download_tmp_dir() -> str:
|
||||
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return str(PYTORRENT_TMP_DIR)
|
||||
|
||||
|
||||
def _remote_readability_error(c: ScgiRtorrentClient, source_path: str) -> str | None:
|
||||
script = (
|
||||
'p=$1; '
|
||||
'command -v base64 >/dev/null 2>&1 || { echo "base64 command not found on rTorrent host"; exit 0; }; '
|
||||
'[ -e "$p" ] || { echo "source file does not exist"; exit 0; }; '
|
||||
'[ -f "$p" ] || { echo "source path is not a regular file"; exit 0; }; '
|
||||
'[ -r "$p" ] || { echo "source file is not readable by rTorrent"; exit 0; }; '
|
||||
'echo OK'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-download-check", source_path) or "").strip()
|
||||
return None if output == "OK" else (output or "source file cannot be read by rTorrent")
|
||||
|
||||
|
||||
def remote_file_readability_error(profile: dict, source_path: str) -> str | None:
|
||||
return _remote_readability_error(client_for(profile), source_path)
|
||||
|
||||
|
||||
def iter_remote_file_chunks(profile: dict, source_path: str, size: int | None = None, chunk_size: int | None = None):
|
||||
c = client_for(profile)
|
||||
clean = _remote_clean_path(source_path)
|
||||
err = _remote_readability_error(c, clean)
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
block_size = max(65536, int(chunk_size or REMOTE_READ_CHUNK_BYTES or 1048576))
|
||||
offset = 0
|
||||
emitted = 0
|
||||
script = (
|
||||
'p=$1; bs=$2; skip=$3; '
|
||||
'command -v base64 >/dev/null 2>&1 || { printf "ERR\tbase64 command not found on rTorrent host"; exit 0; }; '
|
||||
'[ -r "$p" ] || { printf "ERR\tsource file is not readable by rTorrent"; exit 0; }; '
|
||||
'dd if="$p" bs="$bs" skip="$skip" count=1 2>/dev/null | base64 | tr -d "\n"'
|
||||
)
|
||||
while size is None or emitted < int(size):
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-download-read", clean, str(block_size), str(offset)) or "")
|
||||
if output.startswith("ERR\t"):
|
||||
raise RuntimeError(output.split("\t", 1)[1] or "remote read failed")
|
||||
if not output:
|
||||
break
|
||||
try:
|
||||
chunk = __import__("base64").b64decode(output, validate=False)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"remote read returned invalid base64: {exc}") from exc
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
emitted += len(chunk)
|
||||
offset += 1
|
||||
if size is not None and emitted >= int(size):
|
||||
break
|
||||
|
||||
|
||||
def torrent_download_file_info(profile: dict, torrent_hash: str, index: int) -> dict:
|
||||
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
|
||||
err = remote_file_readability_error(profile, remote_path)
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
return {**selected, "remote_path": remote_path, "download_name": LocalPath(str(selected.get("path") or remote_path)).name}
|
||||
|
||||
|
||||
def torrent_download_zip_items(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> list[dict]:
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
items = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
_, remote_path = _torrent_file_remote_path(profile, torrent_hash, int(item["index"]))
|
||||
err = remote_file_readability_error(profile, remote_path)
|
||||
if err:
|
||||
raise RuntimeError(f"{item.get('path') or item.get('index')}: {err}")
|
||||
items.append({**item, "remote_path": remote_path})
|
||||
if not items:
|
||||
raise ValueError("No files selected")
|
||||
return items
|
||||
|
||||
|
||||
def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str:
|
||||
token = uuid.uuid4().hex
|
||||
safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80]
|
||||
target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}"
|
||||
script = (
|
||||
'src=$1; dst=$2; '
|
||||
'if [ ! -f "$src" ]; then echo "ERR\tmissing source"; exit 0; fi; '
|
||||
'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; '
|
||||
'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", source_path, target) or "").strip()
|
||||
parts = (output.splitlines()[0] if output else "").split("\t", 2)
|
||||
if len(parts) >= 2 and parts[0] == "OK":
|
||||
return parts[1]
|
||||
detail = parts[2] if len(parts) > 2 else (parts[1] if len(parts) > 1 else output)
|
||||
raise RuntimeError(detail or "Cannot stage file through rTorrent")
|
||||
|
||||
|
||||
def _remote_stage_zip(c: ScgiRtorrentClient, files: list[dict], suffix: str = ".zip") -> str:
|
||||
if not files:
|
||||
raise ValueError("No files selected")
|
||||
token = uuid.uuid4().hex
|
||||
tmp_base = download_tmp_dir().rstrip("/")
|
||||
list_path = f"{tmp_base}/pytorrent-zip-list-{token}.txt"
|
||||
zip_path = f"{tmp_base}/pytorrent-download-{token}{suffix}"
|
||||
lines = []
|
||||
for item in files:
|
||||
src = str(item.get("remote_path") or "")
|
||||
arc = str(item.get("path") or LocalPath(src).name).lstrip("/") or LocalPath(src).name
|
||||
lines.append(src.replace("\t", " ") + "\t" + arc.replace("\t", " "))
|
||||
list_data = "\n".join(lines)
|
||||
script = (
|
||||
'list=$1; zip=$2; data=$3; umask 022; printf "%s\n" "$data" > "$list"; '
|
||||
'rm -f "$zip"; tmpdir=$(mktemp -d /tmp/pytorrent-zip-XXXXXX) || exit 3; '
|
||||
'rc=0; while IFS=$(printf "\\t") read -r src arc; do '
|
||||
'[ -n "$src" ] || continue; '
|
||||
'if [ ! -f "$src" ]; then echo "missing source: $src" >&2; rc=4; break; fi; '
|
||||
'case "$arc" in /*|../*|*/../*) echo "unsafe zip path: $arc" >&2; rc=5; break;; esac; '
|
||||
'dir=${arc%/*}; if [ "$dir" != "$arc" ]; then mkdir -p "$tmpdir/$dir" || { rc=$?; break; }; fi; cp -- "$src" "$tmpdir/$arc" || { rc=$?; break; }; '
|
||||
'done; if [ $rc -eq 0 ]; then (cd "$tmpdir" && zip -qr "$zip" .) || rc=$?; fi; '
|
||||
'rm -rf "$tmpdir" "$list"; '
|
||||
'if [ $rc -eq 0 ] && [ -f "$zip" ]; then chmod 0644 "$zip" 2>/dev/null || true; printf "OK\t%s\n" "$zip"; else printf "ERR\t%s\n" "$rc"; fi'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-zip", list_path, zip_path, list_data) or "").strip()
|
||||
parts = (output.splitlines()[0] if output else "").split("\t", 1)
|
||||
if len(parts) == 2 and parts[0] == "OK":
|
||||
return parts[1]
|
||||
raise RuntimeError(output or "Cannot create ZIP through rTorrent")
|
||||
|
||||
|
||||
def _remote_remove_staged(profile: dict, path: str) -> None:
|
||||
clean = str(path or "")
|
||||
tmp_prefix = download_tmp_dir().rstrip("/") + "/pytorrent-download-"
|
||||
if not clean.startswith(tmp_prefix):
|
||||
return
|
||||
try:
|
||||
_rt_execute(client_for(profile), "execute.throw", "rm", "-f", clean)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def torrent_staged_file_path(profile: dict, torrent_hash: str, index: int) -> dict:
|
||||
c = client_for(profile)
|
||||
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
|
||||
suffix = LocalPath(str(selected.get("path") or "file")).suffix
|
||||
staged = _remote_stage_path(c, remote_path, suffix)
|
||||
return {**selected, "remote_path": remote_path, "staged_path": staged, "download_name": LocalPath(str(selected.get("path") or staged)).name}
|
||||
|
||||
|
||||
def torrent_staged_zip_path(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
items = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
_, remote_path = _torrent_file_remote_path(profile, torrent_hash, int(item["index"]))
|
||||
items.append({**item, "remote_path": remote_path})
|
||||
staged = _remote_stage_zip(c, items)
|
||||
return {"staged_path": staged, "count": len(items)}
|
||||
|
||||
|
||||
def _torrent_raw_from_method(c: ScgiRtorrentClient, torrent_hash: str) -> bytes | None:
|
||||
for method in ("d.get_metafile", "d.metafile"):
|
||||
try:
|
||||
value = c.call(method, torrent_hash)
|
||||
except Exception:
|
||||
continue
|
||||
if hasattr(value, "data"):
|
||||
data = value.data
|
||||
elif isinstance(value, bytes):
|
||||
data = value
|
||||
elif isinstance(value, str):
|
||||
data = value.encode("latin-1", "ignore")
|
||||
else:
|
||||
data = None
|
||||
if data:
|
||||
return bytes(data)
|
||||
return None
|
||||
|
||||
|
||||
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str:
|
||||
for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"):
|
||||
try:
|
||||
value = str(c.call(method, torrent_hash) or "").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def export_torrent_file(profile: dict, torrent_hash: str) -> dict:
|
||||
c = client_for(profile)
|
||||
name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash
|
||||
filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name
|
||||
raw = _torrent_raw_from_method(c, torrent_hash)
|
||||
if raw:
|
||||
target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent"
|
||||
target.write_bytes(raw)
|
||||
return {"path": str(target), "download_name": filename, "local": True}
|
||||
source = _torrent_source_file(c, torrent_hash)
|
||||
if not source:
|
||||
raise RuntimeError("Cannot find torrent source file in rTorrent")
|
||||
staged = _remote_stage_path(c, source, ".torrent")
|
||||
return {"path": staged, "download_name": filename, "local": False}
|
||||
|
||||
|
||||
def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict:
|
||||
"""Set rTorrent file priorities for one torrent.
|
||||
|
||||
Note: Keeps the existing /files/priority API behavior and returns per-file errors
|
||||
instead of failing the whole batch on one invalid item.
|
||||
"""
|
||||
c = client_for(profile)
|
||||
updated = []
|
||||
errors = []
|
||||
for item in files or []:
|
||||
try:
|
||||
index = int(item.get("index"))
|
||||
priority = int(item.get("priority"))
|
||||
if priority < 0 or priority > 3:
|
||||
raise ValueError("Priority must be between 0 and 3")
|
||||
target = f"{torrent_hash}:f{index}"
|
||||
c.call("f.priority.set", target, priority)
|
||||
updated.append({"index": index, "priority": priority})
|
||||
except Exception as exc:
|
||||
errors.append({"item": item, "error": str(exc)})
|
||||
return {"updated": updated, "errors": errors}
|
||||
|
||||
def set_folder_priority(profile: dict, torrent_hash: str, folder_path: str, priority: int) -> dict:
|
||||
# Note: Folder priority applies the same rTorrent file priority to every descendant path.
|
||||
folder = str(folder_path or "").strip().strip("/")
|
||||
updates = []
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
path = str(item.get("path") or "").strip("/")
|
||||
if not folder or path == folder or path.startswith(folder + "/"):
|
||||
updates.append({"index": item["index"], "priority": int(priority)})
|
||||
if not updates:
|
||||
return {"updated": [], "errors": [{"folder": folder_path, "error": "No files matched folder"}]}
|
||||
return set_file_priorities(profile, torrent_hash, updates)
|
||||
|
||||
|
||||
def torrent_local_file_path(profile: dict, torrent_hash: str, index: int) -> str:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
selected = next((f for f in files if int(f.get("index", -1)) == int(index)), None)
|
||||
if not selected:
|
||||
raise ValueError("File index not found")
|
||||
base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
|
||||
rel = str(selected.get("path") or "").lstrip("/")
|
||||
if len(files) == 1 and base and not base.endswith("/"):
|
||||
path = base
|
||||
else:
|
||||
path = _remote_join(base, rel)
|
||||
# Note: HTTP file serving is enabled only for local profiles to avoid pretending remote files exist locally.
|
||||
if int(profile.get("is_remote") or 0):
|
||||
raise ValueError("HTTP file download is available only for local rTorrent profiles")
|
||||
local = LocalPath(path).resolve()
|
||||
if not local.exists() or not local.is_file():
|
||||
raise FileNotFoundError(f"Local file is not available: {local}")
|
||||
return str(local)
|
||||
|
||||
|
||||
def torrent_local_file_paths(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> list[dict]:
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
out = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
out.append({**item, "local_path": torrent_local_file_path(profile, torrent_hash, int(item["index"]))})
|
||||
return out
|
||||
|
||||
|
||||
|
||||
|
||||
# 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"}
|
||||
]
|
||||
4
pytorrent/services/rtorrent/shared.py
Normal file
4
pytorrent/services/rtorrent/shared.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Note: Backward-compatible internal alias for modules created during refactor.
|
||||
from .client import *
|
||||
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"}
|
||||
]
|
||||
879
pytorrent/services/rtorrent/torrents.py
Normal file
879
pytorrent/services/rtorrent/torrents.py
Normal file
@@ -0,0 +1,879 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
from .system import disk_usage_for_default_path
|
||||
|
||||
|
||||
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _parse_xmlrpc_size_limit(value) -> int:
|
||||
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K."""
|
||||
# Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes.
|
||||
text = str(value or '').strip().lower()
|
||||
if not text:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
multiplier = 1
|
||||
if text[-1:] in {'k', 'm', 'g'}:
|
||||
suffix = text[-1]
|
||||
text = text[:-1]
|
||||
multiplier = {'k': 1024, 'm': 1024 * 1024, 'g': 1024 * 1024 * 1024}[suffix]
|
||||
try:
|
||||
return max(1, int(float(text) * multiplier))
|
||||
except Exception:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
|
||||
|
||||
def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
"""Return the current rTorrent XML-RPC request size limit."""
|
||||
# Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC.
|
||||
try:
|
||||
raw = client_for(profile).call('network.xmlrpc.size_limit')
|
||||
limit = _parse_xmlrpc_size_limit(raw)
|
||||
return {'ok': True, 'raw': str(raw), 'bytes': limit, 'human': human_size(limit)}
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'raw': '', 'bytes': XMLRPC_DEFAULT_SIZE_LIMIT_BYTES, 'human': human_size(XMLRPC_DEFAULT_SIZE_LIMIT_BYTES), 'error': str(exc)}
|
||||
|
||||
|
||||
def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int:
|
||||
"""Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file."""
|
||||
# Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file.
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f'd.directory.set={directory}')
|
||||
if label:
|
||||
commands.append(f'd.custom1.set={label}')
|
||||
method = 'load.raw' if file_priorities else ('load.raw_start' if start else 'load.raw')
|
||||
return len(dumps(("", Binary(data), *commands), methodname=method, allow_none=True).encode('utf-8'))
|
||||
|
||||
|
||||
def validate_torrent_upload_size(profile: dict, data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> dict:
|
||||
"""Check whether a .torrent upload fits the active rTorrent XML-RPC size limit."""
|
||||
limit = xmlrpc_size_limit(profile)
|
||||
request_bytes = estimate_torrent_upload_request_size(data, start, directory, label, file_priorities)
|
||||
allowed = request_bytes <= int(limit.get('bytes') or XMLRPC_DEFAULT_SIZE_LIMIT_BYTES)
|
||||
return {
|
||||
'ok': allowed,
|
||||
'request_bytes': request_bytes,
|
||||
'request_h': human_size(request_bytes),
|
||||
'limit_bytes': int(limit.get('bytes') or XMLRPC_DEFAULT_SIZE_LIMIT_BYTES),
|
||||
'limit_h': limit.get('human') or human_size(XMLRPC_DEFAULT_SIZE_LIMIT_BYTES),
|
||||
'limit_raw': limit.get('raw') or '',
|
||||
'limit_read_ok': bool(limit.get('ok')),
|
||||
'limit_error': limit.get('error') or '',
|
||||
'setting': 'network.xmlrpc.size_limit',
|
||||
'suggested_value': '16M',
|
||||
}
|
||||
|
||||
|
||||
def _mark_post_check_watch(profile_id: int, torrent_hash: str) -> None:
|
||||
if not torrent_hash:
|
||||
return
|
||||
_POST_CHECK_WATCH.setdefault(int(profile_id), {})[str(torrent_hash)] = time.time()
|
||||
|
||||
|
||||
def _clear_post_check_watch(profile_id: int, torrent_hash: str) -> None:
|
||||
profile_watch = _POST_CHECK_WATCH.get(int(profile_id))
|
||||
if not profile_watch:
|
||||
return
|
||||
profile_watch.pop(str(torrent_hash), None)
|
||||
if not profile_watch:
|
||||
_POST_CHECK_WATCH.pop(int(profile_id), None)
|
||||
|
||||
|
||||
def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
|
||||
profile_watch = _POST_CHECK_WATCH.get(int(profile_id)) or {}
|
||||
started_at = profile_watch.get(str(torrent_hash))
|
||||
if not started_at:
|
||||
return False
|
||||
age = time.time() - started_at
|
||||
if age > _POST_CHECK_WATCH_TTL_SECONDS:
|
||||
_clear_post_check_watch(profile_id, torrent_hash)
|
||||
return False
|
||||
# Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet.
|
||||
return age >= _POST_CHECK_WATCH_MIN_SECONDS
|
||||
|
||||
|
||||
def _label_names(value: str) -> list[str]:
|
||||
names: list[str] = []
|
||||
for part in str(value or "").replace(";", ",").replace("|", ",").split(","):
|
||||
label = part.strip()
|
||||
if label and label not in names:
|
||||
names.append(label)
|
||||
return names
|
||||
|
||||
|
||||
def _label_value(labels: list[str]) -> str:
|
||||
return ", ".join([label for label in labels if str(label or "").strip()])
|
||||
|
||||
|
||||
def _without_post_check_download_label(value: str | None) -> str:
|
||||
return _label_value([label for label in _label_names(str(value or "")) if label != POST_CHECK_DOWNLOAD_LABEL])
|
||||
|
||||
|
||||
def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, current_label: str | None = None) -> bool:
|
||||
label_source = current_label
|
||||
if label_source is None:
|
||||
try:
|
||||
label_source = str(c.call("d.custom1", str(torrent_hash or "")) or "")
|
||||
except Exception:
|
||||
label_source = ""
|
||||
labels = _label_names(str(label_source or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
# Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue.
|
||||
c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
|
||||
return True
|
||||
|
||||
|
||||
def _message_indicates_active_check(message: str) -> bool:
|
||||
msg = str(message or "").lower()
|
||||
if not msg:
|
||||
return False
|
||||
finished_markers = ("complete", "completed", "finished", "success", "succeeded", "failed", "done")
|
||||
if any(marker in msg for marker in finished_markers):
|
||||
return False
|
||||
active_markers = ("checking", "hashing", "hash check queued", "hash check scheduled", "check hash queued", "recheck queued", "rechecking")
|
||||
return any(marker in msg for marker in active_markers)
|
||||
|
||||
|
||||
def _row_progress_complete(row: dict) -> bool:
|
||||
size = int(row.get("size") or 0)
|
||||
completed = int(row.get("completed_bytes") or 0)
|
||||
return bool(row.get("complete")) or (size > 0 and completed >= size) or float(row.get("progress") or 0) >= 100.0
|
||||
|
||||
|
||||
def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool:
|
||||
labels = _label_names(str(row.get("label") or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
status = str(row.get("status") or "").lower()
|
||||
started_after_wait = bool(int(row.get("state") or 0)) and status != "checking"
|
||||
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
|
||||
return False
|
||||
# Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
|
||||
clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
|
||||
row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
|
||||
return True
|
||||
|
||||
|
||||
def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict[str, dict] | None = None) -> list[dict]:
|
||||
"""Start complete torrents after check; stop and label incomplete ones for Smart Queue."""
|
||||
previous_rows = previous_rows or {}
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
c = client_for(profile)
|
||||
changes: list[dict] = []
|
||||
for row in rows:
|
||||
h = str(row.get("hash") or "")
|
||||
prev = previous_rows.get(h) or {}
|
||||
try:
|
||||
if h and _cleanup_post_check_label_if_ready(c, row):
|
||||
changes.append({"hash": h, "action": "remove_post_check_label"})
|
||||
except Exception as exc:
|
||||
changes.append({"hash": h, "action": "remove_post_check_label_failed", "error": str(exc)})
|
||||
was_checking = str(prev.get("status") or "") == "Checking" or int(prev.get("hashing") or 0) > 0
|
||||
watched_recheck = _is_post_check_watched(profile_id, h)
|
||||
is_checking = str(row.get("status") or "") == "Checking" or int(row.get("hashing") or 0) > 0
|
||||
if not h or not (was_checking or watched_recheck) or is_checking:
|
||||
continue
|
||||
complete = _row_progress_complete(row)
|
||||
try:
|
||||
if complete:
|
||||
# Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
|
||||
start_result = start_or_resume_hash(c, h)
|
||||
clear_post_check_download_label(c, h, str(row.get("label") or ""))
|
||||
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
|
||||
changes.append({"hash": h, "action": "start_seed_after_check", "complete": True, "result": start_result})
|
||||
else:
|
||||
labels = _label_names(str(row.get("label") or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
labels.append(POST_CHECK_DOWNLOAD_LABEL)
|
||||
label_value = _label_value(labels)
|
||||
# Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
|
||||
c.call("d.stop", h)
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
except Exception:
|
||||
pass
|
||||
c.call("d.custom1.set", h, label_value)
|
||||
row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value})
|
||||
changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
|
||||
_clear_post_check_watch(profile_id, h)
|
||||
except Exception as exc:
|
||||
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
|
||||
return changes
|
||||
|
||||
|
||||
TORRENT_FIELDS = [
|
||||
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
||||
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
|
||||
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
|
||||
"d.custom=py_ratio_group", "d.message=", "d.hashing=", "d.is_active=", "d.is_multi_file=",
|
||||
]
|
||||
|
||||
TORRENT_OPTIONAL_FIELDS = [
|
||||
"d.timestamp.finished=",
|
||||
]
|
||||
|
||||
|
||||
def human_duration(seconds: int) -> str:
|
||||
# Note: Download ETA is derived locally from remaining bytes and current download speed.
|
||||
seconds = max(0, int(seconds or 0))
|
||||
if seconds <= 0:
|
||||
return '-'
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, _ = divmod(rem, 60)
|
||||
if days:
|
||||
return f"{days}d {hours}h"
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def normalize_row(row: list) -> dict:
|
||||
size = int(row[4] or 0)
|
||||
completed = int(row[5] or 0)
|
||||
progress = 100.0 if size <= 0 and int(row[3] or 0) else round((completed / size) * 100, 2) if size else 0.0
|
||||
ratio_raw = int(row[6] or 0)
|
||||
down_rate = int(row[8] or 0)
|
||||
up_rate = int(row[7] or 0)
|
||||
remaining_bytes = max(0, size - completed)
|
||||
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not int(row[3] or 0) else 0
|
||||
directory = str(row[14] or "")
|
||||
base_path = str(row[15] or "")
|
||||
is_multi_file = int(row[22] or 0) if len(row) > 22 else 0
|
||||
completed_at = int(row[23] or 0) if len(row) > 23 else 0
|
||||
|
||||
# Show the selected download location only. Hide the torrent root
|
||||
# directory for multi-file torrents and the filename for single-file
|
||||
# torrents. Data deletion still uses the full d.base_path elsewhere.
|
||||
if base_path and base_path != "/":
|
||||
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
elif directory and is_multi_file and directory != "/":
|
||||
display_parent = posixpath.dirname(directory.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
elif directory:
|
||||
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
|
||||
else:
|
||||
display_path = ""
|
||||
msg = str(row[19] or "")
|
||||
msg_l = msg.lower()
|
||||
hashing = int(row[20] or 0) if len(row) > 20 else 0
|
||||
is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0)
|
||||
state = int(row[2] or 0)
|
||||
complete = int(row[3] or 0)
|
||||
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
|
||||
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
|
||||
is_paused = bool(state) and not bool(is_active) and not is_checking
|
||||
status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
to_download_bytes = remaining_bytes if not complete else 0
|
||||
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
|
||||
return {
|
||||
"hash": str(row[0] or ""),
|
||||
"name": str(row[1] or ""),
|
||||
"state": state,
|
||||
"active": is_active,
|
||||
"paused": is_paused,
|
||||
"complete": complete,
|
||||
"size": size,
|
||||
"size_h": human_size(size),
|
||||
"completed_bytes": completed,
|
||||
"progress": progress,
|
||||
"ratio": round(ratio_raw / 1000, 3),
|
||||
"up_rate": up_rate,
|
||||
"up_rate_h": human_rate(up_rate),
|
||||
"down_rate": down_rate,
|
||||
"down_rate_h": human_rate(down_rate),
|
||||
"eta_seconds": eta_seconds,
|
||||
"eta_h": human_duration(eta_seconds) if eta_seconds else "-",
|
||||
"up_total": int(row[9] or 0),
|
||||
"up_total_h": human_size(row[9] or 0),
|
||||
"down_total": int(row[10] or 0),
|
||||
"down_total_h": human_size(row[10] or 0),
|
||||
"to_download": to_download_bytes,
|
||||
"to_download_h": human_size(to_download_bytes) if to_download_bytes else "",
|
||||
"peers": int(row[11] or 0),
|
||||
"seeds": int(row[12] or 0),
|
||||
"priority": int(row[13] or 0),
|
||||
"path": display_path,
|
||||
"created": int(row[16] or 0),
|
||||
"completed_at": completed_at,
|
||||
"label": str(row[17] or ""),
|
||||
"ratio_group": str(row[18] or ""),
|
||||
"message": msg,
|
||||
"status": status,
|
||||
"hashing": hashing,
|
||||
}
|
||||
|
||||
|
||||
def list_torrents(profile: dict) -> list[dict]:
|
||||
c = client_for(profile)
|
||||
try:
|
||||
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
|
||||
except Exception:
|
||||
# Keep compatibility with older rTorrent builds that do not expose optional timestamp fields.
|
||||
rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
|
||||
return [normalize_row(list(row)) for row in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
fields = [
|
||||
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=",
|
||||
"p.up_rate=", "p.port=", "p.is_encrypted=", "p.is_incoming=",
|
||||
"p.is_snubbed=", "p.is_banned=",
|
||||
]
|
||||
try:
|
||||
rows = client_for(profile).p.multicall(torrent_hash, "", *fields)
|
||||
except Exception:
|
||||
fields = ["p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", "p.up_rate=", "p.port=", "p.is_encrypted="]
|
||||
rows = client_for(profile).p.multicall(torrent_hash, "", *fields)
|
||||
peers = []
|
||||
for idx, r in enumerate(rows):
|
||||
peers.append({
|
||||
"index": idx,
|
||||
"ip": r[0],
|
||||
"client": r[1],
|
||||
"completed": int(r[2] or 0),
|
||||
"down_rate": int(r[3] or 0),
|
||||
"down_rate_h": human_rate(r[3] or 0),
|
||||
"up_rate": int(r[4] or 0),
|
||||
"up_rate_h": human_rate(r[4] or 0),
|
||||
"port": int(r[5] or 0),
|
||||
"encrypted": bool(r[6]) if len(r) > 6 else False,
|
||||
"incoming": bool(r[7]) if len(r) > 7 else False,
|
||||
"snubbed": bool(r[8]) if len(r) > 8 else False,
|
||||
"banned": bool(r[9]) if len(r) > 9 else False,
|
||||
})
|
||||
return peers
|
||||
|
||||
|
||||
|
||||
|
||||
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
|
||||
errors = []
|
||||
for method, args in candidates:
|
||||
try:
|
||||
result = c.call(method, *args)
|
||||
return {"ok": True, "method": method, "result": result}
|
||||
except Exception as exc:
|
||||
errors.append(f"{method}: {exc}")
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
|
||||
|
||||
def _tracker_domain(url: str) -> str:
|
||||
raw = str(url or '').strip()
|
||||
if not raw:
|
||||
return ''
|
||||
parsed = urlparse(raw if '://' in raw else f'http://{raw}')
|
||||
host = (parsed.hostname or '').lower().strip('.')
|
||||
if host.startswith('www.'):
|
||||
host = host[4:]
|
||||
return host
|
||||
|
||||
|
||||
def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict:
|
||||
"""Return tracker domains grouped by torrent for the sidebar filter."""
|
||||
# Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list.
|
||||
hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
|
||||
if not hashes:
|
||||
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')]
|
||||
hashes = hashes[:max(1, int(limit or 1000))]
|
||||
by_hash: dict[str, list[dict]] = {}
|
||||
counts: dict[str, dict] = {}
|
||||
errors = []
|
||||
for h in hashes:
|
||||
try:
|
||||
items = []
|
||||
seen = set()
|
||||
for tr in torrent_trackers(profile, h):
|
||||
url = str(tr.get('url') or '')
|
||||
domain = _tracker_domain(url)
|
||||
if not domain or domain in seen:
|
||||
continue
|
||||
seen.add(domain)
|
||||
item = {'domain': domain, 'url': url}
|
||||
items.append(item)
|
||||
row = counts.setdefault(domain, {'domain': domain, 'url': url, 'count': 0})
|
||||
row['count'] += 1
|
||||
by_hash[h] = items
|
||||
except Exception as exc:
|
||||
errors.append({'hash': h, 'error': str(exc)})
|
||||
by_hash[h] = []
|
||||
trackers = sorted(counts.values(), key=lambda x: (-int(x.get('count') or 0), str(x.get('domain') or '')))
|
||||
return {'hashes': by_hash, 'trackers': trackers, 'errors': errors, 'scanned': len(hashes)}
|
||||
|
||||
def _safe_tracker_call(c: ScgiRtorrentClient, method: str, target: str, default=None):
|
||||
try:
|
||||
return c.call(method, target)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _tracker_target(torrent_hash: str, index: int) -> str:
|
||||
return f"{torrent_hash}:t{int(index)}"
|
||||
|
||||
def _tracker_int(value, default=None):
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _tracker_rows(c: ScgiRtorrentClient, torrent_hash: str) -> list[list]:
|
||||
fields = ("t.url=", "t.is_enabled=", "t.scrape_complete=", "t.scrape_incomplete=", "t.scrape_downloaded=")
|
||||
errors: list[str] = []
|
||||
for args in ((torrent_hash, "", *fields), ("", torrent_hash, *fields)):
|
||||
try:
|
||||
rows = c.call("t.multicall", *args)
|
||||
return [list(r) for r in (rows or [])]
|
||||
except Exception as exc:
|
||||
errors.append(f"t.multicall{args[:2]}: {exc}")
|
||||
# Note: Fallback keeps the sidebar tracker filter usable on rTorrent builds without t.multicall scrape fields.
|
||||
total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or 0
|
||||
rows: list[list] = []
|
||||
for index in range(max(0, total)):
|
||||
target = _tracker_target(torrent_hash, index)
|
||||
url = _safe_tracker_call(c, "t.url", target, "")
|
||||
if not url:
|
||||
for args in ((torrent_hash, index), ("", torrent_hash, index)):
|
||||
try:
|
||||
url = c.call("t.url", *args)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if url:
|
||||
enabled = _safe_tracker_call(c, "t.is_enabled", target, 1)
|
||||
rows.append([url, enabled, None, None, None])
|
||||
if rows:
|
||||
return rows
|
||||
raise RuntimeError("Cannot read trackers: " + "; ".join(errors))
|
||||
|
||||
|
||||
def torrent_trackers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
c = client_for(profile)
|
||||
rows = _tracker_rows(c, torrent_hash)
|
||||
trackers = []
|
||||
for idx, r in enumerate(rows):
|
||||
target = _tracker_target(torrent_hash, idx)
|
||||
last_announce = _safe_tracker_call(c, "t.activity_time_last", target, 0)
|
||||
scrape_time = _safe_tracker_call(c, "t.scrape_time_last", target, 0)
|
||||
if not last_announce:
|
||||
last_announce = scrape_time
|
||||
next_announce = _safe_tracker_call(c, "t.activity_time_next", target, 0)
|
||||
raw_seeds = _tracker_int(r[2], None)
|
||||
raw_peers = _tracker_int(r[3], None)
|
||||
raw_downloaded = _tracker_int(r[4], None)
|
||||
has_scrape = bool(_tracker_int(scrape_time, 0)) or raw_seeds not in (None, 0) or raw_peers not in (None, 0) or raw_downloaded not in (None, 0)
|
||||
trackers.append({
|
||||
"index": idx,
|
||||
"url": str(r[0] or ""),
|
||||
"enabled": bool(r[1]),
|
||||
"seeds": raw_seeds if has_scrape else None,
|
||||
"peers": raw_peers if has_scrape else None,
|
||||
"downloaded": raw_downloaded if has_scrape else None,
|
||||
"has_scrape": has_scrape,
|
||||
"last_announce": int(last_announce or 0),
|
||||
"next_announce": int(next_announce or 0),
|
||||
})
|
||||
return trackers
|
||||
|
||||
def tracker_action(profile: dict, torrent_hash: str, action_name: str, payload: dict | None = None) -> dict:
|
||||
payload = payload or {}
|
||||
c = client_for(profile)
|
||||
if action_name == "reannounce":
|
||||
return _call_first(c, [
|
||||
("d.tracker_announce", (torrent_hash,)),
|
||||
("d.tracker_announce", ("", torrent_hash)),
|
||||
("d.tracker_announce.force", (torrent_hash,)),
|
||||
])
|
||||
if action_name == "add":
|
||||
url = str(payload.get("url") or "").strip()
|
||||
if not url:
|
||||
raise ValueError("Missing tracker URL")
|
||||
return _call_first(c, [
|
||||
("d.tracker.insert", (torrent_hash, "", url)),
|
||||
("d.tracker.insert", (torrent_hash, 0, url)),
|
||||
("d.tracker.insert", ("", torrent_hash, "", url)),
|
||||
])
|
||||
if action_name in {"delete", "remove"}:
|
||||
# Note: Deleting trackers is guarded to keep at least one tracker attached to the torrent.
|
||||
index = int(payload.get("index", -1))
|
||||
if index < 0:
|
||||
raise ValueError("Invalid tracker index")
|
||||
total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or len(torrent_trackers(profile, torrent_hash))
|
||||
if total <= 1:
|
||||
raise ValueError("Cannot delete the last tracker")
|
||||
if index >= total:
|
||||
raise ValueError("Invalid tracker index")
|
||||
return _call_first(c, [
|
||||
("d.tracker.remove", (torrent_hash, index)),
|
||||
("d.tracker.remove", (torrent_hash, "", index)),
|
||||
("d.tracker.erase", (torrent_hash, index)),
|
||||
("d.tracker.erase", (torrent_hash, "", index)),
|
||||
("d.tracker.delete", (torrent_hash, index)),
|
||||
("d.tracker.delete", (torrent_hash, "", index)),
|
||||
])
|
||||
raise ValueError(f"Unknown tracker action: {action_name}")
|
||||
|
||||
|
||||
|
||||
def _int_rpc(c: ScgiRtorrentClient, method: str, h: str, default: int = 0) -> int:
|
||||
try:
|
||||
return int(c.call(method, h) or 0)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> str:
|
||||
try:
|
||||
return str(c.call(method, h) or '')
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
||||
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
||||
state = _int_rpc(c, 'd.state', h)
|
||||
active = _int_rpc(c, 'd.is_active', h)
|
||||
opened = _int_rpc(c, 'd.is_open', h)
|
||||
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
|
||||
return {
|
||||
'state': state,
|
||||
'open': opened,
|
||||
'active': active,
|
||||
'paused': bool(state and opened and not active),
|
||||
'stopped': not bool(state),
|
||||
'message': _str_rpc(c, 'd.message', h),
|
||||
}
|
||||
|
||||
|
||||
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Pause an active rTorrent item without stopping or closing it."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
try:
|
||||
if before.get('stopped'):
|
||||
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
|
||||
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
# Note: Smart Queue frees a slot with d.pause, not d.stop, so later d.resume behaves like ruTorrent.
|
||||
c.call('d.pause', h)
|
||||
result['commands'].append('d.pause')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Stop an active rTorrent item without using pause semantics."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched.
|
||||
c.call('d.stop', h)
|
||||
result['commands'].append('d.stop')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Resume only a paused rTorrent item; never convert it through stop/start."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
|
||||
return result
|
||||
if before.get('active'):
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
|
||||
# because those commands belong to Stopped/Open state, not a clean Paused state.
|
||||
c.call('d.resume', h)
|
||||
result['commands'].append('d.resume')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start: bool = False) -> dict:
|
||||
"""Start stopped torrents or resume real paused torrents.
|
||||
|
||||
Smart Queue passes prefer_start=True for candidates that were selected as stopped.
|
||||
This avoids treating rTorrent's intermediate open/inactive state after a check as
|
||||
a user pause and sending only d.resume, which can leave items pending forever.
|
||||
"""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
|
||||
if before.get('active'):
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
|
||||
if before.get('paused') and not prefer_start:
|
||||
# Note: Manual Start keeps the clean pause-to-resume path. Do not classify every
|
||||
# state=1/active=0 item as paused; after auto-check this can be only a transient
|
||||
# open/inactive rTorrent state and needs d.open + d.start.
|
||||
resumed = resume_paused_hash(c, h)
|
||||
resumed['mode'] = 'resume_paused'
|
||||
return resumed
|
||||
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
try:
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||
try:
|
||||
c.call('d.try_start', h)
|
||||
result['commands'].append('d.try_start')
|
||||
except Exception as exc2:
|
||||
result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}')
|
||||
result['ok'] = False
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = result.get('ok', True)
|
||||
return result
|
||||
|
||||
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
|
||||
payload = payload or {}
|
||||
resume_state = resume_state or {}
|
||||
completed_hashes = set(str(x) for x in (resume_state.get("completed_hashes") or []))
|
||||
previous_results = list(resume_state.get("results") or [])
|
||||
|
||||
def mark_done(torrent_hash: str, item: dict, results: list) -> None:
|
||||
completed_hashes.add(str(torrent_hash))
|
||||
state = {"completed_hashes": sorted(completed_hashes), "results": results}
|
||||
if checkpoint:
|
||||
checkpoint(state, len(completed_hashes), len(torrent_hashes))
|
||||
|
||||
def pending_hashes() -> list[str]:
|
||||
return [h for h in torrent_hashes if str(h) not in completed_hashes]
|
||||
|
||||
c = client_for(profile)
|
||||
methods = {
|
||||
"stop": "d.stop",
|
||||
"recheck": "d.check_hash",
|
||||
"reannounce": "d.tracker_announce",
|
||||
"remove": "d.erase",
|
||||
}
|
||||
if name == "set_label":
|
||||
label = str(payload.get("label") or "").strip()
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
c.call("d.custom1.set", h, label)
|
||||
item = {"hash": h, "label": label}
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "label": label, "results": results}
|
||||
if name == "set_ratio_group":
|
||||
group = str(payload.get("ratio_group") or "").strip()
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
c.call("d.custom.set", h, "py_ratio_group", group)
|
||||
item = {"hash": h, "ratio_group": group}
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "ratio_group": group, "results": results}
|
||||
if name == "move":
|
||||
path = _remote_clean_path(payload.get("path") or "")
|
||||
move_data = bool(payload.get("move_data"))
|
||||
recheck = bool(payload.get("recheck", move_data))
|
||||
keep_seeding = bool(payload.get("keep_seeding"))
|
||||
# Note: Automations can force seeding after a physical move even if the torrent was not active before.
|
||||
if not path:
|
||||
raise ValueError("Missing path")
|
||||
results = previous_results
|
||||
if move_data:
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "mkdir", "-p", path)
|
||||
for h in pending_hashes():
|
||||
item = {"hash": h, "path": path, "move_data": move_data, "keep_seeding": keep_seeding}
|
||||
try:
|
||||
was_state = int(c.call("d.state", h) or 0)
|
||||
except Exception:
|
||||
was_state = 0
|
||||
try:
|
||||
was_active = int(c.call("d.is_active", h) or 0)
|
||||
except Exception:
|
||||
was_active = was_state
|
||||
if move_data:
|
||||
if was_state == 0:
|
||||
c.call("d.directory.set", h, path)
|
||||
item["move_data"] = False
|
||||
item["skipped"] = "state is 0; data is not present, only directory updated"
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
continue
|
||||
src = _remote_clean_path(_torrent_data_path(c, h))
|
||||
if not src:
|
||||
raise ValueError(f"Cannot determine source path for {h}")
|
||||
dst = _remote_join(path, posixpath.basename(src.rstrip("/")))
|
||||
if src != dst:
|
||||
try:
|
||||
c.call("d.stop", h)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
except Exception:
|
||||
pass
|
||||
_run_remote_move(c, src, dst)
|
||||
item["moved_from"] = src
|
||||
item["moved_to"] = dst
|
||||
else:
|
||||
item["skipped"] = "source and destination are the same"
|
||||
c.call("d.directory.set", h, path)
|
||||
if recheck:
|
||||
try:
|
||||
c.call("d.check_hash", h)
|
||||
except Exception as exc:
|
||||
item["recheck_error"] = str(exc)
|
||||
if keep_seeding or was_state or was_active:
|
||||
try:
|
||||
c.call("d.start", h)
|
||||
item["started_after_move"] = True
|
||||
except Exception as exc:
|
||||
item["start_after_move_error"] = str(exc)
|
||||
else:
|
||||
c.call("d.directory.set", h, path)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "results": results}
|
||||
if name == "pause":
|
||||
# Note: The app pause action is now a pure d.pause so later resume works without stop/start.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = pause_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name in {"resume", "unpause"}:
|
||||
# Note: Resume/Unpause uses only d.resume for Paused state.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = resume_paused_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name == "start":
|
||||
# Note: Start separates Stopped from Paused; paused items go through d.resume, stopped items through d.start.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = start_or_resume_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
|
||||
method = methods.get(name)
|
||||
if not method:
|
||||
raise ValueError(f"Unknown action: {name}")
|
||||
remove_data = bool(payload.get("remove_data")) if name == "remove" else False
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = {"hash": h}
|
||||
if remove_data:
|
||||
item = _remove_torrent_data(c, h)
|
||||
c.call(method, h)
|
||||
if name == "recheck":
|
||||
# Note: Recheck is tracked so even very fast checks still receive the after-check start/stop policy.
|
||||
_mark_post_check_watch(int(profile.get("id") or 0), h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": remove_data, "results": results}
|
||||
|
||||
def add_magnet(profile: dict, uri: str, start: bool = True, directory: str = "", label: str = "") -> dict:
|
||||
c = client_for(profile)
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f"d.directory.set={directory}")
|
||||
if label:
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
if start:
|
||||
c.load.start_verbose("", uri, *commands)
|
||||
else:
|
||||
c.load.normal("", uri, *commands)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def set_limits(profile: dict, down: int | None, up: int | None):
|
||||
"""Set global speed limits in bytes/s.
|
||||
|
||||
rTorrent XML-RPC setters need an empty target string as the first
|
||||
argument. Without it rTorrent returns: target must be a string.
|
||||
"""
|
||||
c = client_for(profile)
|
||||
if down is not None:
|
||||
c.call("throttle.global_down.max_rate.set", "", int(down))
|
||||
if up is not None:
|
||||
c.call("throttle.global_up.max_rate.set", "", int(up))
|
||||
return {"ok": True, "down": int(down or 0), "up": int(up or 0)}
|
||||
|
||||
|
||||
def add_torrent_raw(profile: dict, data: bytes, start: bool = True, directory: str = "", label: str = "", file_priorities: list[dict] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f"d.directory.set={directory}")
|
||||
if label:
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
# Note: File selection before start loads the torrent stopped, changes priorities, then starts it if requested.
|
||||
method = "load.raw" if file_priorities else ("load.raw_start" if start else "load.raw")
|
||||
c.call(method, "", Binary(data), *commands)
|
||||
info_hash = ""
|
||||
if file_priorities:
|
||||
try:
|
||||
from ..torrent_meta import parse_torrent
|
||||
info_hash = parse_torrent(data).get("info_hash") or ""
|
||||
set_file_priorities(profile, info_hash, file_priorities)
|
||||
if start:
|
||||
c.call("d.start", info_hash)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "info_hash": info_hash, "error": str(exc)}
|
||||
return {"ok": True, "info_hash": info_hash}
|
||||
|
||||
|
||||
|
||||
# Note: Export all service functions, including compatibility helpers used by routes and older imports.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
Reference in New Issue
Block a user