fix planner

This commit is contained in:
Mateusz Gruszczyński
2026-06-17 09:02:41 +02:00
parent 99692ef217
commit b98505fd31
65 changed files with 82 additions and 279 deletions
-10
View File
@@ -1,10 +0,0 @@
# 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.
+1 -6
View File
@@ -1,14 +1,9 @@
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 *
from .chunks import *
-7
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import math
import re
from .client import *
@@ -11,13 +10,11 @@ _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)
@@ -47,7 +44,6 @@ def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
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] = []
@@ -79,7 +75,6 @@ def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
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
@@ -101,7 +96,6 @@ def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[
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)),
@@ -177,7 +171,6 @@ def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk
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)
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import errno
import os
import posixpath
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from .client import *
RTORRENT_CONFIG_FIELDS = [
@@ -1,5 +1,4 @@
from __future__ import annotations
from .client import *
from .. import poller_control
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from .client import *
from ...config import BASE_DIR
@@ -25,7 +24,6 @@ def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
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]
-2
View File
@@ -1,4 +1,2 @@
from __future__ import annotations
# Note: Backward-compatible internal alias for modules created during refactor.
from .client import *
-5
View File
@@ -1,8 +1,6 @@
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
@@ -10,7 +8,6 @@ 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 = (
@@ -44,7 +41,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
name, full_path = parts[0], parts[1]
is_empty = len(parts) > 2 and parts[2] == "1"
if name not in {".", ".."}:
# Note: Empty status is returned with every directory so the path picker can enable safe inline rename.
dirs.append({"name": name, "path": full_path, "empty": is_empty})
elif marker == "M" and "\t" in rest:
first, second = rest.split("\t", 1)
@@ -67,7 +63,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
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,
+1 -31
View File
@@ -1,18 +1,14 @@
from __future__ import annotations
import time
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
@@ -29,7 +25,6 @@ def _parse_xmlrpc_size_limit(value) -> int:
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)
@@ -40,7 +35,6 @@ def xmlrpc_size_limit(profile: dict) -> dict:
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}')
@@ -93,7 +87,6 @@ def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
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
@@ -124,7 +117,6 @@ def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, cu
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
@@ -151,11 +143,9 @@ def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool
if POST_CHECK_DOWNLOAD_LABEL not in labels:
return False
status = str(row.get("status") or "").lower()
# Note: rTorrent may report state=1 after a recheck even when the download is not really active yet.
started_after_wait = bool(int(row.get("state") or 0)) and bool(int(row.get("active") 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
@@ -183,7 +173,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
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 ""))})
@@ -193,7 +182,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
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)
@@ -229,7 +217,6 @@ LIVE_TORRENT_FIELDS = [
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 '-'
@@ -256,12 +243,8 @@ def normalize_row(row: list) -> dict:
base_path = str(row[15] or "")
state = int(row[2] or 0)
complete = int(row[3] or 0)
# Note: is_multi_file is needed before status calculation because the display path hides the torrent root for multi-file payloads.
is_multi_file = int(row[24] or 0) if len(row) > 24 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
@@ -280,20 +263,15 @@ def normalize_row(row: list) -> dict:
is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state)
last_activity = int(row[25] or 0) if len(row) > 25 else 0
if not last_activity and (down_rate > 0 or up_rate > 0):
# Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp.
last_activity = int(time.time())
completed_at = int(row[26] or 0) if len(row) > 26 else 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)
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
# Note: rTorrent exposes queued/inactive torrents with the same runtime flags that older UI code called paused.
# The app marks only explicit user Pause requests with py_manual_pause so queued rows stay separate.
is_paused = manual_pause and not is_checking and not post_check
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
# Note: Post-check and Queued are application-level UI statuses; rTorrent itself mainly exposes flags.
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued 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 ""),
@@ -338,7 +316,6 @@ def normalize_row(row: list) -> dict:
def normalize_live_row(row: list) -> dict:
"""Normalize the small row used by the fast live stats poller."""
# Note: The live poller intentionally reads only volatile fields so the main list poller can run less often.
size = int(row[3] or 0)
completed = int(row[4] or 0)
complete = int(row[2] or 0)
@@ -406,13 +383,10 @@ def list_torrents(profile: dict) -> list[dict]:
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=",
@@ -444,8 +418,6 @@ def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
return peers
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
errors = []
for method, args in candidates:
@@ -457,7 +429,6 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d
raise RuntimeError("; ".join(errors))
def _tracker_domain(url: str) -> str:
raw = str(url or '').strip()
if not raw:
@@ -471,7 +442,6 @@ def _tracker_domain(url: str) -> str:
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')]