add auth support

This commit is contained in:
Mateusz Gruszczyński
2026-05-06 08:38:07 +02:00
parent aea3c92830
commit dc1cac4e6f
20 changed files with 1185 additions and 220 deletions

View File

@@ -85,7 +85,7 @@ class ScgiRtorrentClient:
def _scgi_retry_attempts() -> int:
# Note: Krotki retry/backoff chroni masowe operacje przed chwilowym Errno 111 przy wysokim loadzie rTorrent.
# 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:
@@ -97,7 +97,7 @@ def _scgi_retry_delay(attempt: int) -> float:
def _is_transient_scgi_error(exc: Exception) -> bool:
# Note: Retry obejmuje typowe chwilowe bledy SCGI/socket, ale nie ukrywa bledow merytorycznych XML-RPC.
# 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)
@@ -115,7 +115,7 @@ _UNSUPPORTED_EXEC_METHODS: set[str] = set()
_EXEC_TARGET_STYLE: dict[str, int] = {}
def _rt_execute_preview(method_name: str, call_args: tuple) -> str:
# Note: Skrocony opis RPC usuwa dlugie skrypty z komunikatu bledu, ale zostawia metode i pierwsze argumenty do diagnostyki.
# 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 += ", ..."
@@ -123,7 +123,7 @@ def _rt_execute_preview(method_name: str, call_args: tuple) -> str:
def _rt_execute_target_variants(method: str, args: tuple) -> list[tuple]:
# Note: rTorrent XML-RPC w zaleznosci od wersji wymaga pustego targetu albo go odrzuca; zapamietujemy dzialajacy wariant per metoda.
# 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):
@@ -137,7 +137,7 @@ def _is_rt_method_missing(exc: Exception) -> bool:
def _rt_execute_methods(method: str) -> list[str]:
# Note: execute2.* jest probowane dopiero gdy podstawowe execute.* nie istnieje, zeby nie generowac falszywych bledow retry.
# 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)
@@ -239,7 +239,7 @@ def _run_remote_move(c: ScgiRtorrentClient, src: str, dst: str, poll_interval: f
try:
output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-move-poll", status_path) or "").strip()
except Exception as exc:
# Note: Podczas masowego move rTorrent potrafi chwilowo nie utworzyc pipe dla execute.capture; polling czeka i probuje dalej.
# 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
@@ -289,7 +289,7 @@ def _safe_rm_rf_path(path: str) -> str:
def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0) -> None:
# Note: rm -rf dziala w tle po stronie rTorrent, wiec dlugie kasowanie nie trzyma jednego polaczenia SCGI.
# 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 = (
@@ -310,7 +310,7 @@ def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0)
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 uzywa tego samego bezpiecznego pollingu co move, wiec chwilowy brak pipe nie wywala calej kolejki.
# 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
@@ -393,6 +393,21 @@ def _row_progress_complete(row: dict) -> bool:
return bool(row.get("complete")) or (size > 0 and completed >= size) or float(row.get("progress") or 0) >= 100.0
def _remove_post_check_label_if_finished(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()
if not (_row_progress_complete(row) or status == "seeding"):
return False
labels = [label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]
value = _label_value(labels)
# Note: Clean the temporary label after reaching 100% or entering seeding, even when the state no longer comes directly from recheck.
c.call("d.custom1.set", str(row.get("hash") or ""), value)
row["label"] = value
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; pause and label incomplete ones."""
previous_rows = previous_rows or {}
@@ -401,6 +416,11 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
for row in rows:
h = str(row.get("hash") or "")
prev = previous_rows.get(h) or {}
try:
if h and _remove_post_check_label_if_finished(c, row):
changes.append({"hash": h, "action": "remove_post_check_label", "complete": True})
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
is_checking = str(row.get("status") or "") == "Checking" or int(row.get("hashing") or 0) > 0
if not h or not was_checking or is_checking:
@@ -408,7 +428,7 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
complete = _row_progress_complete(row)
try:
if complete:
# Note: Po zakonczonym checku pelny torrent jest automatycznie startowany, zeby od razu seedowal.
# Note: After a completed check, a complete torrent is started automatically so it can seed immediately.
c.call("d.start", h)
labels = [label for label in _label_names(str(row.get("label") or "")) if label != POST_CHECK_DOWNLOAD_LABEL]
if _label_value(labels) != str(row.get("label") or ""):
@@ -417,7 +437,7 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding"})
changes.append({"hash": h, "action": "start", "complete": True})
else:
# Note: Niepelny torrent po checku trafia do pauzy i dostaje etykiete informujaca, ze wymaga dalszego pobierania.
# Note: After check, an incomplete torrent is paused and labeled to show that it needs more downloading.
c.call("d.start", h)
c.call("d.pause", h)
labels = _label_names(str(row.get("label") or ""))
@@ -1186,7 +1206,7 @@ def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
state = _int_rpc(c, 'd.state', h)
active = _int_rpc(c, 'd.is_active', h)
opened = _int_rpc(c, 'd.is_open', h)
# Note: W rTorrent pauza nie zmienia d.state. Paused to state=1, open=1, active=0.
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
return {
'state': state,
'open': opened,
@@ -1205,7 +1225,7 @@ def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
before = _download_runtime_state(c, h)
result = {'hash': h, 'before': before, 'commands': []}
try:
# Note: Smart Queue zatrzymuje slot przez d.pause, nie przez d.stop, żeby późniejsze d.resume działało jak w ruTorrent.
# 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)
@@ -1229,8 +1249,8 @@ def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result
try:
# Note: ruTorrent dla od-pauzowania wysyła odpowiednik unpause/d.resume. Nie dokładamy d.start/d.open,
# bo to są komendy dla stanu Stopped/Open, a nie dla czystego Paused.
# 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)
@@ -1253,13 +1273,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
return result
if before.get('paused') or (before.get('state') and not before.get('active')):
# Note: Paused w rTorrent wznawiamy tylko przez d.resume; d.start jest tu celowo pomijane.
# Note: Paused rTorrent items are resumed only with d.resume; d.start is intentionally skipped here.
resumed = resume_paused_hash(c, h)
resumed['mode'] = 'resume_paused'
return resumed
try:
# Note: d.start zostaje wyłącznie dla Stopped/closed, czyli dla stanu innego niż pause->resume.
# Note: d.start remains only for Stopped/closed items, not for the pause-to-resume path.
c.call('d.open', h)
result['commands'].append('d.open')
except Exception as exc:
@@ -1352,15 +1372,15 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
results.append(item)
return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "results": results}
if name == "pause":
# Note: Pauza aplikacji jest teraz czystym d.pause, żeby późniejszy resume działał bez stop/start.
# Note: The app pause action is now a pure d.pause so later resume works without stop/start.
results = [pause_hash(c, h) for h in torrent_hashes]
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
if name in {"resume", "unpause"}:
# Note: Resume/Unpause używa wyłącznie d.resume dla stanu Paused.
# Note: Resume/Unpause uses only d.resume for Paused state.
results = [resume_paused_hash(c, h) for h in torrent_hashes]
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
if name == "start":
# Note: Start rozdziela Stopped od Paused; paused idzie przez d.resume, stopped przez d.start.
# Note: Start separates Stopped from Paused; paused items go through d.resume, stopped items through d.start.
results = [start_or_resume_hash(c, h) for h in torrent_hashes]
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}