first commit

This commit is contained in:
root
2026-05-19 13:43:37 +00:00
commit 9dcd0abd7d
107 changed files with 33622 additions and 0 deletions

489
pytorrent/services/auth.py Normal file
View File

@@ -0,0 +1,489 @@
from __future__ import annotations
from functools import wraps
from typing import Any
import secrets
from urllib.parse import urlparse
from flask import abort, g, jsonify, redirect, request, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash
from ..config import AUTH_ENABLE
from ..db import connect, default_user_id, utcnow
PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"}
RTORRENT_WRITE_PREFIXES = (
"/api/torrents/",
"/api/speed/limits",
"/api/labels",
"/api/ratio-groups",
"/api/rss",
"/api/smart-queue",
"/api/automations",
"/api/jobs",
)
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
# Note: API reads that expose rTorrent/profile data must also respect profile permissions.
PROFILE_READ_PREFIXES = (
"/api/torrents",
"/api/torrent-stats",
"/api/system/status",
"/api/app/status",
"/api/port-check",
"/api/path",
"/api/labels",
"/api/ratio-groups",
"/api/rss",
"/api/rtorrent-config",
"/api/smart-queue",
"/api/traffic/history",
"/api/automations",
)
def enabled() -> bool:
return bool(AUTH_ENABLE)
def password_hash(password: str) -> str:
return generate_password_hash(password or "")
def current_user_id() -> int:
if not enabled():
return default_user_id()
api_user_id = getattr(g, "api_user_id", None)
if api_user_id:
return int(api_user_id)
try:
return int(session.get("user_id") or 0)
except Exception:
return 0
def current_user() -> dict[str, Any] | None:
uid = current_user_id()
if not uid:
return None
with connect() as conn:
return conn.execute(
"SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?",
(uid,),
).fetchone()
def is_admin(user: dict[str, Any] | None = None) -> bool:
if not enabled():
return True
user = user or current_user()
return bool(user and user.get("role") == "admin" and int(user.get("is_active") or 0))
def _permissions(user_id: int | None = None) -> list[dict[str, Any]]:
if not enabled():
return [{"profile_id": 0, "access_level": "full"}]
uid = user_id or current_user_id()
if not uid:
return []
with connect() as conn:
return conn.execute(
"SELECT profile_id, access_level FROM user_profile_permissions WHERE user_id=?",
(uid,),
).fetchall()
def can_access_profile(profile_id: int | None, user_id: int | None = None) -> bool:
if not enabled():
return True
uid = user_id or current_user_id()
if not uid:
return False
with connect() as conn:
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
if not user or not int(user.get("is_active") or 0):
return False
if user.get("role") == "admin":
return True
pid = int(profile_id or 0)
row = conn.execute(
"SELECT 1 FROM user_profile_permissions WHERE user_id=? AND (profile_id=0 OR profile_id=?) LIMIT 1",
(uid, pid),
).fetchone()
return bool(row)
def can_write_profile(profile_id: int | None, user_id: int | None = None) -> bool:
if not enabled():
return True
uid = user_id or current_user_id()
if not uid:
return False
with connect() as conn:
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
if not user or not int(user.get("is_active") or 0):
return False
if user.get("role") == "admin":
return True
pid = int(profile_id or 0)
row = conn.execute(
"SELECT access_level FROM user_profile_permissions WHERE user_id=? AND (profile_id=0 OR profile_id=?) ORDER BY profile_id DESC LIMIT 1",
(uid, pid),
).fetchone()
return bool(row and row.get("access_level") == "full")
def visible_profile_ids(user_id: int | None = None) -> set[int] | None:
if not enabled():
return None
uid = user_id or current_user_id()
if not uid:
return set()
with connect() as conn:
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
if not user or not int(user.get("is_active") or 0):
return set()
if user.get("role") == "admin":
return None
rows = conn.execute("SELECT profile_id FROM user_profile_permissions WHERE user_id=?", (uid,)).fetchall()
if any(int(row.get("profile_id") or 0) == 0 for row in rows):
return None
return {int(row.get("profile_id") or 0) for row in rows}
def same_origin_request() -> bool:
"""Return False only when an unsafe request clearly comes from another origin."""
origin = request.headers.get("Origin") or request.headers.get("Referer")
if not origin:
return True
try:
parsed = urlparse(origin)
return parsed.scheme == request.scheme and parsed.netloc == request.host
except Exception:
return False
def writable_profile_ids(user_id: int | None = None) -> set[int] | None:
if not enabled():
return None
uid = user_id or current_user_id()
if not uid:
return set()
with connect() as conn:
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
if not user or not int(user.get("is_active") or 0):
return set()
if user.get("role") == "admin":
return None
rows = conn.execute("SELECT profile_id FROM user_profile_permissions WHERE user_id=? AND access_level='full'", (uid,)).fetchall()
if any(int(row.get("profile_id") or 0) == 0 for row in rows):
return None
return {int(row.get("profile_id") or 0) for row in rows}
def require_admin() -> None:
if enabled() and not is_admin():
abort(403)
def require_profile_read(profile_id: int | None) -> None:
if enabled() and not can_access_profile(profile_id):
abort(403)
def require_profile_write(profile_id: int | None) -> None:
if enabled() and not can_write_profile(profile_id):
abort(403)
def login_user(username: str, password: str) -> dict[str, Any] | None:
if not enabled():
return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1}
with connect() as conn:
user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
if not user or not int(user.get("is_active") or 0):
return None
if not user.get("password_hash") or not check_password_hash(user.get("password_hash"), password or ""):
return None
session.clear()
session["user_id"] = int(user["id"])
session["username"] = user["username"]
session["role"] = user.get("role") or "user"
return current_user()
def logout_user() -> None:
session.clear()
def ensure_admin_user() -> None:
if not enabled():
return
now = utcnow()
with connect() as conn:
row = conn.execute("SELECT id FROM users WHERE username='admin'").fetchone()
if not row:
conn.execute(
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)",
("admin", password_hash("admin"), "admin", 1, now, now),
)
else:
conn.execute("UPDATE users SET role='admin', is_active=1, updated_at=? WHERE username='admin'", (now,))
def list_users() -> list[dict[str, Any]]:
require_admin()
with connect() as conn:
users = conn.execute(
"SELECT id, username, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE"
).fetchall()
perms = conn.execute(
"SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id"
).fetchall()
token_counts = conn.execute(
"SELECT user_id, COUNT(*) AS token_count FROM api_tokens WHERE revoked_at IS NULL GROUP BY user_id"
).fetchall()
by_token_user = {int(row["user_id"]): int(row.get("token_count") or 0) for row in token_counts}
by_user: dict[int, list[dict[str, Any]]] = {}
for perm in perms:
by_user.setdefault(int(perm["user_id"]), []).append({
"profile_id": int(perm.get("profile_id") or 0),
"access_level": perm.get("access_level") or "ro",
})
for user in users:
user["permissions"] = by_user.get(int(user["id"]), [])
user["api_tokens"] = by_token_user.get(int(user["id"]), 0)
return users
def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
require_admin()
now = utcnow()
username = str(data.get("username") or "").strip()
role = "admin" if data.get("role") == "admin" else "user"
is_active = 1 if data.get("is_active", True) else 0
if not username:
raise ValueError("Username is required")
with connect() as conn:
if user_id:
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
if not row:
raise ValueError("User does not exist")
conn.execute(
"UPDATE users SET username=?, role=?, is_active=?, updated_at=? WHERE id=?",
(username, role, is_active, now, user_id),
)
else:
cur = conn.execute(
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)",
(username, password_hash(str(data.get("password") or username)), role, is_active, now, now),
)
user_id = int(cur.lastrowid)
if data.get("password"):
conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id))
if role != "admin":
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
for item in data.get("permissions") or []:
profile_id = int(item.get("profile_id") or 0)
access = "full" if item.get("access_level") == "full" else "ro"
conn.execute(
"INSERT OR REPLACE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
(user_id, profile_id, access, now, now),
)
else:
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
return conn.execute("SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone()
def delete_user(user_id: int) -> None:
require_admin()
uid = int(user_id or 0)
if uid == current_user_id():
raise ValueError("Cannot delete current user")
if uid == default_user_id():
# Note: The built-in fallback account must stay in the database for auth-disabled and recovery flows.
raise ValueError("Cannot delete the default user")
with connect() as conn:
row = conn.execute("SELECT username FROM users WHERE id=?", (uid,)).fetchone()
if not row:
raise ValueError("User does not exist")
if str(row.get("username") or "").lower() in {"default", "admin"}:
# Note: Protect bootstrap accounts by name as well as by id.
raise ValueError("Cannot delete built-in user")
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (uid,))
conn.execute("UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE user_id=?", (utcnow(), utcnow(), uid))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
def _public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
return {
"id": int(row["id"]),
"username": row.get("username"),
"role": row.get("role") or "user",
"is_active": int(row.get("is_active") or 0),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
def _token_response(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": int(row["id"]),
"user_id": int(row["user_id"]),
"name": row.get("name") or "API token",
"token_prefix": row.get("token_prefix") or "",
"last_used_at": row.get("last_used_at"),
"created_at": row.get("created_at"),
"revoked_at": row.get("revoked_at"),
}
def list_api_tokens(user_id: int) -> list[dict[str, Any]]:
if not enabled():
return []
uid = int(user_id or 0)
if not uid:
return []
if not is_admin() and current_user_id() != uid:
abort(403)
with connect() as conn:
rows = conn.execute(
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
(uid,),
).fetchall()
return [_token_response(row) for row in rows]
def create_api_token(user_id: int, name: str = "API token") -> dict[str, Any]:
if not enabled():
raise ValueError("API tokens are available only when authentication is enabled")
uid = int(user_id or 0)
if not uid:
raise ValueError("User is required")
if not is_admin() and current_user_id() != uid:
abort(403)
clean_name = str(name or "API token").strip()[:80] or "API token"
secret = "pt_" + secrets.token_urlsafe(32)
prefix = secret[:14]
now = utcnow()
with connect() as conn:
user = conn.execute("SELECT id,is_active FROM users WHERE id=?", (uid,)).fetchone()
if not user or not int(user.get("is_active") or 0):
raise ValueError("User is inactive or does not exist")
cur = conn.execute(
"INSERT INTO api_tokens(user_id,name,token_hash,token_prefix,created_at,updated_at) VALUES(?,?,?,?,?,?)",
(uid, clean_name, password_hash(secret), prefix, now, now),
)
row = conn.execute(
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE id=?",
(int(cur.lastrowid),),
).fetchone()
data = _token_response(row)
data["token"] = secret
return data
def revoke_api_token(user_id: int, token_id: int) -> None:
if not enabled():
abort(404)
uid = int(user_id or 0)
tid = int(token_id or 0)
if not is_admin() and current_user_id() != uid:
abort(403)
now = utcnow()
with connect() as conn:
conn.execute(
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=?",
(now, now, tid, uid),
)
def authenticate_api_token(token: str) -> dict[str, Any] | None:
if not enabled():
return None
raw = str(token or "").strip()
if not raw:
return None
prefix = raw[:14]
with connect() as conn:
rows = conn.execute(
"""SELECT t.id AS token_id,t.token_hash,t.user_id,u.username,u.role,u.is_active
FROM api_tokens t JOIN users u ON u.id=t.user_id
WHERE t.revoked_at IS NULL AND t.token_prefix=?""",
(prefix,),
).fetchall()
matched = None
for row in rows:
if check_password_hash(row.get("token_hash") or "", raw):
matched = row
break
if not matched or not int(matched.get("is_active") or 0):
return None
conn.execute("UPDATE api_tokens SET last_used_at=?, updated_at=? WHERE id=?", (utcnow(), utcnow(), int(matched["token_id"])))
return {"id": int(matched["user_id"]), "username": matched.get("username"), "role": matched.get("role") or "user", "is_active": 1}
def _request_api_token() -> str:
header = request.headers.get("Authorization") or ""
if header.lower().startswith("bearer "):
return header.split(None, 1)[1].strip()
return (request.headers.get("X-API-Key") or request.args.get("api_key") or "").strip()
def install_guards(app) -> None:
@app.before_request
def _auth_guard():
if not enabled():
return None
g.api_token_authenticated = False
if request.path.startswith("/api/"):
token_user = authenticate_api_token(_request_api_token())
if token_user:
g.api_user_id = int(token_user["id"])
g.api_token_authenticated = True
endpoint = request.endpoint or ""
if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"):
return None
if not current_user_id():
if request.path.startswith("/api/"):
return jsonify({"ok": False, "error": "Authentication required"}), 401
return redirect(url_for("main.login", next=request.full_path if request.query_string else request.path))
user = current_user()
if not user or not int(user.get("is_active") or 0):
logout_user()
return jsonify({"ok": False, "error": "Authentication required"}), 401 if request.path.startswith("/api/") else redirect(url_for("main.login"))
if request.path.startswith("/api/auth/users") and not is_admin(user):
return jsonify({"ok": False, "error": "Admin only"}), 403
if request.path.startswith(PROFILE_READ_PREFIXES):
profile_id = _request_profile_id()
if profile_id and not can_access_profile(profile_id):
return jsonify({"ok": False, "error": "Profile access denied"}), 403
if request.method not in {"GET", "HEAD", "OPTIONS"}:
if request.path.startswith("/api/") and not getattr(g, "api_token_authenticated", False) and not same_origin_request():
return jsonify({"ok": False, "error": "Cross-origin API request blocked"}), 403
if request.path.startswith("/api/profiles") and not request.path.endswith("/activate") and not is_admin(user):
return jsonify({"ok": False, "error": "Admin only"}), 403
profile_id = _request_profile_id()
if request.path.startswith(RTORRENT_CONFIG_PREFIXES) and not can_write_profile(profile_id):
return jsonify({"ok": False, "error": "Read-only profile access"}), 403
if request.path.startswith(RTORRENT_WRITE_PREFIXES) and not can_write_profile(profile_id):
return jsonify({"ok": False, "error": "Read-only profile access"}), 403
return None
def _request_profile_id() -> int | None:
if request.view_args and request.view_args.get("profile_id"):
return int(request.view_args["profile_id"])
try:
payload = request.get_json(silent=True) or {}
if payload.get("profile_id"):
return int(payload.get("profile_id"))
except Exception:
pass
from . import preferences
profile = preferences.active_profile()
return int(profile["id"]) if profile else None

View File

@@ -0,0 +1,382 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import json
from ..db import connect, default_user_id, utcnow
from . import rtorrent
from .preferences import active_profile
from .workers import enqueue
AUTOMATION_JOB_CHUNK_SIZE = 100
AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'}
def _loads(value: str | None, default: Any) -> Any:
try: return json.loads(value or '')
except Exception: return default
def _ts(value: str | None) -> float:
if not value: return 0.0
try: return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp()
except Exception: return 0.0
def _now_ts() -> float:
return datetime.now(timezone.utc).timestamp()
def _label_names(value: str | None) -> list[str]:
seen = []
for part in str(value or '').replace(';', ',').replace('|', ',').split(','):
item = part.strip()
if item and item not in seen: seen.append(item)
return seen
def _label_value(labels: list[str]) -> str:
out = []
for label in labels:
label = str(label or '').strip()
if label and label not in out: out.append(label)
return ', '.join(out)
def _rule_row(row: dict[str, Any]) -> dict[str, Any]:
item = dict(row)
item['conditions'] = _loads(item.pop('conditions_json', '[]'), [])
item['effects'] = _loads(item.pop('effects_json', '[]'), [])
return item
def list_rules(profile_id: int | None = None, user_id: int | None = None) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
if profile_id is None:
profile = active_profile(); profile_id = int(profile['id']) if profile else None
with connect() as conn:
rows = conn.execute('SELECT * FROM automation_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY enabled DESC, name COLLATE NOCASE', (user_id, profile_id)).fetchall()
rules = [_rule_row(r) for r in rows]
if profile_id is not None:
with connect() as conn:
for rule in rules:
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, '__rule__')).fetchone()
last = row.get('last_applied_at') if row else None
cooldown = int(rule.get('cooldown_minutes') or 0)
remaining = max(0, int((_ts(last) + cooldown * 60) - _now_ts())) if last and cooldown > 0 else 0
# Note: Exposes live cooldown timers for the Automations tab without changing rule behavior.
rule['last_applied_at'] = last
rule['cooldown_remaining_seconds'] = remaining
return rules
def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute('SELECT * FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id)).fetchone()
if not row: raise ValueError('Rule not found')
return _rule_row(row)
def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]:
return {
'name': str(rule.get('name') or 'Automation rule'),
'enabled': bool(rule.get('enabled', True)),
'cooldown_minutes': max(0, int(rule.get('cooldown_minutes') or 0)),
'conditions': list(rule.get('conditions') or []),
'effects': list(rule.get('effects') or []),
}
def export_rules(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
# Note: Export contains only portable rule definitions, never DB ids or execution history.
rules = [_portable_rule(rule) for rule in list_rules(profile_id, user_id)]
return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'rules': rules}
def import_rules(profile_id: int, payload: dict[str, Any] | list[Any], user_id: int | None = None, replace: bool = False) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
raw_rules = payload if isinstance(payload, list) else payload.get('rules', []) if isinstance(payload, dict) else []
if not isinstance(raw_rules, list) or not raw_rules:
raise ValueError('Import file does not contain automation rules')
if replace:
with connect() as conn:
# Note: Optional replace is profile-scoped; it does not touch other profiles or history tables.
conn.execute('DELETE FROM automation_rules WHERE user_id=? AND profile_id=?', (user_id, profile_id))
conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,))
imported = []
for raw in raw_rules:
if not isinstance(raw, dict):
continue
rule = _portable_rule(raw)
rule.pop('id', None)
imported.append(save_rule(profile_id, rule, user_id))
if not imported:
raise ValueError('No valid automation rules found')
return imported
def save_rule(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule'
conditions = data.get('conditions') or []
effects = data.get('effects') or []
if not isinstance(conditions, list) or not conditions: raise ValueError('Rule needs at least one condition')
if not isinstance(effects, list) or not effects: raise ValueError('Rule needs at least one effect')
cooldown = max(0, int(data.get('cooldown_minutes') or 0))
enabled = 1 if data.get('enabled', True) else 0
now = utcnow(); rule_id = int(data.get('id') or 0)
with connect() as conn:
if rule_id:
conn.execute('UPDATE automation_rules SET name=?, enabled=?, conditions_json=?, effects_json=?, cooldown_minutes=?, updated_at=? WHERE id=? AND user_id=? AND profile_id=?', (name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, rule_id, user_id, profile_id))
else:
cur = conn.execute('INSERT INTO automation_rules(user_id,profile_id,name,enabled,conditions_json,effects_json,cooldown_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)', (user_id, profile_id, name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, now))
rule_id = int(cur.lastrowid)
return get_rule(rule_id, profile_id, user_id)
def delete_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
with connect() as conn:
conn.execute('DELETE FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id))
conn.execute('DELETE FROM automation_rule_state WHERE rule_id=? AND profile_id=?', (rule_id, profile_id))
def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
with connect() as conn:
return conn.execute('SELECT * FROM automation_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', (user_id, profile_id, max(1, min(int(limit or 30), 100)))).fetchall()
def clear_history(profile_id: int, user_id: int | None = None) -> int:
user_id = user_id or default_user_id()
with connect() as conn:
# Note: Manual automation log cleanup is scoped to the active profile and current user.
cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id))
return int(cur.rowcount or 0)
def _condition_true(t: dict[str, Any], cond: dict[str, Any]) -> bool:
typ = str(cond.get('type') or '')
if typ == 'completed': return bool(int(t.get('complete') or 0))
if typ == 'no_seeds': return int(t.get('seeds') or 0) <= int(cond.get('seeds') or 0)
if typ == 'ratio_gte': return float(t.get('ratio') or 0) >= float(cond.get('ratio') or 0)
if typ == 'progress_gte': return float(t.get('progress') or 0) >= float(cond.get('progress') or 0)
if typ == 'progress_lte': return float(t.get('progress') or 0) <= float(cond.get('progress') or 0)
if typ == 'label_missing': return str(cond.get('label') or '').strip() not in _label_names(t.get('label'))
if typ == 'label_has': return str(cond.get('label') or '').strip() in _label_names(t.get('label'))
if typ == 'status': return str(t.get('status') or '').lower() == str(cond.get('status') or '').lower()
if typ == 'path_contains': return str(cond.get('text') or '').lower() in str(t.get('path') or '').lower()
return False
def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, Any]) -> bool:
h = str(t.get('hash') or '')
if not h: return False
immediate_ok = True; delayed_ok = True; now = utcnow(); now_ts = _now_ts()
for cond in rule.get('conditions') or []:
raw_ok = _condition_true(t, cond)
negated = bool(cond.get('negate'))
# Note: Negation is applied in the backend, so UI and API only store the condition flag.
ok = (not raw_ok) if negated else raw_ok
if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated:
row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone()
if ok:
since = row['condition_since_at'] if row and row.get('condition_since_at') else now
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,last_matched_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=COALESCE(automation_rule_state.condition_since_at, excluded.condition_since_at), last_matched_at=excluded.last_matched_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, since, now, now))
delayed_ok = delayed_ok and (now_ts - _ts(since) >= int(cond.get('minutes') or 0) * 60)
else:
conn.execute('UPDATE automation_rule_state SET condition_since_at=NULL, updated_at=? WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (now, rule['id'], profile_id, h)); delayed_ok = False
else:
immediate_ok = immediate_ok and ok
return immediate_ok and delayed_ok
def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '__rule__') -> bool:
cooldown = int(rule.get('cooldown_minutes') or 0)
if cooldown <= 0: return True
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, torrent_hash)).fetchone()
if not row or not row.get('last_applied_at'): return True
return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60
def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None:
# Note: Cooldown is rule-level, so one batch execution blocks the whole automation until the cooldown expires.
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now))
def _chunk_hashes(hashes: list[str], size: int = AUTOMATION_JOB_CHUNK_SIZE) -> list[list[str]]:
# Note: Automation jobs use the same small-batch idea as manual bulk jobs, so long move/remove/actions remain visible and recoverable.
safe_size = max(1, int(size or AUTOMATION_JOB_CHUNK_SIZE))
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrents_by_hash: dict[str, dict[str, Any]], extra: dict[str, Any] | None = None) -> dict[str, Any]:
# Note: Job context marks jobs created by automations, making the Jobs log explain what rule queued the work.
ctx = {
'source': 'automation',
'rule_id': rule.get('id'),
'rule_name': str(rule.get('name') or ''),
'effect': eff_type,
'bulk': len(hashes) > 1,
'hash_count': len(hashes),
'requested_at': utcnow(),
'items': [
{
'hash': h,
'name': str((torrents_by_hash.get(h) or {}).get('name') or ''),
'path': str((torrents_by_hash.get(h) or {}).get('path') or ''),
}
for h in hashes
],
}
if extra:
ctx.update(extra)
return ctx
def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], action_name: str, hashes: list[str], payload: dict[str, Any], torrents_by_hash: dict[str, dict[str, Any]], user_id: int | None = None, context_extra: dict[str, Any] | None = None) -> list[str]:
# Note: Light automation actions stay in one job; heavy actions are chunked for recoverability.
job_ids: list[str] = []
chunks = [hashes] if action_name in AUTOMATION_LIGHT_ACTIONS else _chunk_hashes(hashes)
for index, chunk in enumerate(chunks, start=1):
part_payload = dict(payload or {})
part_payload['hashes'] = chunk
part_payload['source'] = 'automation'
if action_name not in AUTOMATION_LIGHT_ACTIONS:
part_payload['requires_order'] = True
extra = dict(context_extra or {})
if len(chunks) > 1:
extra.update({'bulk_label': f'automation-{index}', 'bulk_part': index, 'bulk_parts': len(chunks), 'parent_hash_count': len(hashes)})
if action_name == 'move':
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
if action_name == 'remove':
extra.update({'remove_data': bool(part_payload.get('remove_data'))})
part_payload['job_context'] = _job_context(rule, str(context_extra.get('effect_type') if context_extra else action_name), chunk, torrents_by_hash, extra)
job_ids.append(enqueue(action_name, int(profile['id']), part_payload, user_id=user_id))
return job_ids
def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]], rule: dict[str, Any], user_id: int | None = None) -> list[dict[str, Any]]:
hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')]
torrents_by_hash = {str(t.get('hash') or ''): t for t in torrents if str(t.get('hash') or '')}
labels_by_hash = {str(t.get('hash') or ''): _label_names(t.get('label')) for t in torrents}
applied: list[dict[str, Any]] = []
if not hashes: return applied
for eff in effects:
typ = str(eff.get('type') or '')
if typ == 'move':
path = str(eff.get('path') or '').strip() or rtorrent.default_download_path(profile)
payload = {
'path': path,
'move_data': bool(eff.get('move_data')),
'recheck': bool(eff.get('recheck', eff.get('move_data'))),
'keep_seeding': bool(eff.get('keep_seeding')),
}
job_ids = _enqueue_automation_job(profile, rule, 'move', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'move'})
applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'job_ids': job_ids})
elif typ == 'add_label':
label = str(eff.get('label') or '').strip()
if label:
# Note: Add-label automations are idempotent and queue only torrents that need a changed label value.
grouped: dict[str, list[str]] = {}
for h in hashes:
labels = labels_by_hash.get(h, [])
if label in labels:
continue
new_labels = list(labels) + [label]
value = _label_value(new_labels)
labels_by_hash[h] = _label_names(value)
grouped.setdefault(value, []).append(h)
target_hashes = [h for group in grouped.values() for h in group]
job_ids: list[str] = []
for value, group_hashes in grouped.items():
job_ids.extend(_enqueue_automation_job(profile, rule, 'set_label', group_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'add_label', 'label': label}))
if target_hashes:
applied.append({'type': 'add_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
elif typ == 'remove_label':
label = str(eff.get('label') or '').strip()
if label:
# Note: Remove-label automations are queued only for torrents where the requested label exists.
grouped: dict[str, list[str]] = {}
for h in hashes:
labels = labels_by_hash.get(h, [])
if label not in labels:
continue
value = _label_value([x for x in labels if x != label])
labels_by_hash[h] = _label_names(value)
grouped.setdefault(value, []).append(h)
target_hashes = [h for group in grouped.values() for h in group]
job_ids: list[str] = []
for value, group_hashes in grouped.items():
job_ids.extend(_enqueue_automation_job(profile, rule, 'set_label', group_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'remove_label', 'label': label}))
if target_hashes:
applied.append({'type': 'remove_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
elif typ == 'set_labels':
value = _label_value(_label_names(eff.get('labels')))
target_labels = _label_names(value)
# Note: Set-labels queues a job only if the current labels differ from the requested exact list.
target_hashes = [h for h in hashes if labels_by_hash.get(h, []) != target_labels]
for h in target_hashes:
labels_by_hash[h] = list(target_labels)
if target_hashes:
job_ids = _enqueue_automation_job(profile, rule, 'set_label', target_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'set_labels', 'labels': value})
applied.append({'type': 'set_labels', 'labels': value, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
elif typ in {'pause', 'stop', 'start', 'resume', 'recheck', 'reannounce'}:
# Note: Runtime actions are queued as jobs too, so automation activity is visible in the Jobs panel.
job_ids = _enqueue_automation_job(profile, rule, typ, hashes, {}, torrents_by_hash, user_id, {'effect_type': typ})
applied.append({'type': typ, 'count': len(hashes), 'target_hashes': hashes, 'job_ids': job_ids})
elif typ == 'remove':
# Note: Remove is supported for automation payloads and still goes through ordered worker jobs.
payload = {'remove_data': bool(eff.get('remove_data'))}
job_ids = _enqueue_automation_job(profile, rule, 'remove', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'remove'})
applied.append({'type': 'remove', 'count': len(hashes), 'target_hashes': hashes, 'remove_data': payload['remove_data'], 'job_ids': job_ids})
return applied
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False, rule_id: int | None = None) -> dict[str, Any]:
profile = profile or active_profile()
if not profile: return {'ok': False, 'error': 'No active rTorrent profile'}
user_id = user_id or default_user_id(); profile_id = int(profile['id'])
rules = [r for r in list_rules(profile_id, user_id) if (rule_id is None or int(r.get('id') or 0) == int(rule_id)) and (force or int(r.get('enabled') or 0))]
if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0}
torrents = rtorrent.list_torrents(profile); applied = []; batches = []; now = utcnow()
planned: list[dict[str, Any]] = []
with connect() as conn:
for rule in rules:
# Note: This pass only matches rules and updates condition timers; job creation is intentionally delayed until after this DB transaction commits.
if not force and not _cooldown_ok(conn, rule, profile_id):
continue
matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)]
if not matched:
continue
hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')]
if hashes:
planned.append({'rule': rule, 'matched': matched, 'hashes': hashes})
for item in planned:
rule = item['rule']
matched = item['matched']
hashes = item['hashes']
# Note: Automation jobs are enqueued outside the rule-state transaction, preventing SQLite self-locks when enqueue() writes to jobs.
try:
actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, user_id)
except Exception as exc:
actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}]
changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])})
if not actions or not changed_hashes:
# Note: Matching torrents with no real action are not logged and do not restart the cooldown.
continue
history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions]
matched_by_hash = {str(t.get('hash') or ''): t for t in matched}
with connect() as conn:
# Note: State/history writes happen after enqueue succeeds, so failed job creation does not create misleading automation history.
for h in changed_hashes:
t = matched_by_hash.get(h, {})
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_matched_at,last_applied_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_matched_at=excluded.last_matched_at, last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now, now))
applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]})
_mark_rule_cooldown(conn, rule, profile_id, now)
torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents'
torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}'
conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (user_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now))
batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(changed_hashes), 'actions': history_actions})
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches}

View File

@@ -0,0 +1,286 @@
from __future__ import annotations
import json
import threading
import time
from datetime import datetime, timedelta, timezone
from ..db import connect, utcnow, default_user_id
# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped.
BACKUP_TABLES = [
"users", "user_profile_permissions", "user_preferences", "rtorrent_profiles",
"disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules",
"smart_queue_settings", "smart_queue_exclusions", "automation_rules",
"rtorrent_config_overrides", "app_settings", "download_plan_settings",
]
DEFAULT_AUTO_BACKUP_SETTINGS = {
"enabled": False,
"interval_hours": 24,
"retention_days": 30,
"last_run_at": None,
}
BACKUP_PREVIEW_VALUE_LIMIT = 80
BACKUP_PREVIEW_ROW_LIMIT = 3
BACKUP_PREVIEW_SENSITIVE_KEYS = {
"password",
"password_hash",
"token",
"token_hash",
"api_key",
"secret",
}
AUTO_BACKUP_SETTINGS_KEY = "backup:auto"
_scheduler_started = False
_scheduler_lock = threading.Lock()
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
"""Create a settings backup and return a table-count summary.
Note: The automatic flag is metadata only; restore/download behavior remains unchanged.
"""
user_id = user_id or default_user_id()
payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn:
for table in BACKUP_TABLES:
try:
payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall()
except Exception:
payload["tables"][table] = []
cur = conn.execute(
"INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)",
(user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]),
)
backup_id = cur.lastrowid
return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}}
def list_backups(user_id: int | None = None) -> list[dict]:
user_id = user_id or default_user_id()
with connect() as conn:
rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
result = []
for row in rows:
payload = _loads(row.get("payload_json") or "{}")
tables = payload.get("tables") or {}
result.append({
"id": row.get("id"),
"name": row.get("name"),
"created_at": row.get("created_at"),
"automatic": bool(payload.get("automatic")),
"tables": {key: len(value or []) for key, value in tables.items()},
})
return result
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
if not row:
raise ValueError("Backup not found")
return json.loads(row["payload_json"] or "{}")
def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
payload = payload_for_backup(backup_id, user_id)
tables = payload.get("tables") or {}
restored = {}
with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
try:
for table in BACKUP_TABLES:
rows = tables.get(table) or []
if not rows:
continue
columns = list(rows[0].keys())
placeholders = ",".join("?" for _ in columns)
conn.execute(f"DELETE FROM {table}")
for row in rows:
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
restored[table] = len(rows)
finally:
conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored}
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
with connect() as conn:
cur = conn.execute(
"DELETE FROM app_backups WHERE id=? AND user_id=?",
(backup_id, user_id),
)
if not cur.rowcount:
raise ValueError("Backup not found")
return {"deleted": backup_id}
def _loads(value: str) -> dict:
try:
data = json.loads(value or "{}")
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _settings_row_key(user_id: int | None = None) -> str:
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}"
def _latest_backup_created_at(user_id: int) -> str | None:
"""Return the newest persisted backup timestamp for scheduler recovery after restarts.
Note: Automatic scheduling is based on the latest database backup record, so process
restarts cannot create repeated backups before the configured interval elapses.
"""
with connect() as conn:
row = conn.execute(
"SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1",
(user_id,),
).fetchone()
return str(row["created_at"] or "") if row and row.get("created_at") else None
def _preview_value(value: object) -> object:
"""Return a safe, compact value for backup previews without exposing secrets."""
if value is None or isinstance(value, (int, float, bool)):
return value
text = str(value)
return text if len(text) <= BACKUP_PREVIEW_VALUE_LIMIT else f"{text[:BACKUP_PREVIEW_VALUE_LIMIT]}..."
def _preview_row(row: dict) -> dict:
output = {}
for key, value in row.items():
lowered = str(key).lower()
if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS):
output[key] = "[hidden]"
else:
output[key] = _preview_value(value)
return output
def get_auto_backup_settings(user_id: int | None = None) -> dict:
"""Return automatic backup schedule settings for the current user.
Note: The UI uses this as the single source for interval and retention controls.
"""
key = _settings_row_key(user_id)
with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")}
settings["enabled"] = bool(settings.get("enabled"))
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
settings["retention_days"] = max(1, int(settings.get("retention_days") or 30))
return settings
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
"""Persist automatic backup schedule settings after validating UI input.
Note: Minimum interval is one hour to avoid creating excessive database rows.
"""
current = get_auto_backup_settings(user_id)
settings = {
**current,
"enabled": bool(data.get("enabled")),
"interval_hours": max(1, int(data.get("interval_hours") or current["interval_hours"])),
"retention_days": max(1, int(data.get("retention_days") or current["retention_days"])),
"last_run_at": data.get("last_run_at", current.get("last_run_at")),
}
key = _settings_row_key(user_id)
with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings)))
return settings
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
"""Return a compact backup preview without exposing the full JSON payload in the list view.
Note: The preview shows included tables and example keys so users can verify settings coverage.
"""
payload = payload_for_backup(backup_id, user_id)
tables = payload.get("tables") or {}
return {
"version": payload.get("version"),
"created_at": payload.get("created_at"),
"automatic": bool(payload.get("automatic")),
"tables": [
{
"name": table,
"rows": len(rows or []),
"columns": list((rows[0] or {}).keys()) if rows else [],
"sample": [_preview_row(dict(row)) for row in (rows or [])[:BACKUP_PREVIEW_ROW_LIMIT]],
}
for table, rows in tables.items()
],
}
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int:
"""Delete backups older than the configured retention window for the selected user.
Note: Retention is applied only to backup records, not to restored application settings.
"""
user_id = user_id or default_user_id()
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
with connect() as conn:
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at<?", (user_id, cutoff))
return int(cur.rowcount or 0)
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
"""Create an automatic backup when the saved interval has elapsed.
Note: The scheduler calls this periodically, while the UI controls the interval and retention values.
"""
user_id = user_id or default_user_id()
settings = get_auto_backup_settings(user_id)
if not settings.get("enabled"):
return None
now = datetime.now(timezone.utc)
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id)
try:
last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None
except Exception:
last = None
if last and now - last < timedelta(hours=settings["interval_hours"]):
if settings.get("last_run_at") != last_value:
settings["last_run_at"] = last_value
save_auto_backup_settings(settings, user_id)
return None
backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds")
save_auto_backup_settings(settings, user_id)
prune_old_backups(user_id, settings["retention_days"])
return backup
def start_scheduler() -> None:
"""Start a lightweight automatic-backup scheduler.
Note: It scans configured users and never blocks normal request handling.
"""
global _scheduler_started
with _scheduler_lock:
if _scheduler_started:
return
_scheduler_started = True
def loop() -> None:
while True:
try:
with connect() as conn:
rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall()
user_ids = [int(row["id"]) for row in rows] or [default_user_id()]
for uid in user_ids:
maybe_create_automatic_backup(uid)
except Exception:
pass
time.sleep(300)
threading.Thread(target=loop, daemon=True, name="pytorrent-backup-scheduler").start()

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from . import download_planner
def check(profile: dict, force: bool = False) -> dict[str, Any]:
"""Compatibility check for disk protection.
Disk protection is now configured in Download Planner. The planner performs
the pause/resume action; this helper only reports whether the current disk
source is over the planner threshold.
"""
profile_id = int(profile.get("id") or 0)
if not profile_id:
return {"ok": False, "enabled": False, "error": "Missing profile id"}
settings = download_planner.get_settings(profile_id)
enabled = bool(settings.get("enabled") and settings.get("auto_pause_disk_enabled"))
if not enabled:
return {"ok": True, "enabled": False, "profile_id": profile_id}
usage = download_planner.disk_usage(profile, int(settings.get("user_id") or 0) or None) or {}
threshold = max(1, min(100, int(settings.get("auto_pause_disk_percent") or 95)))
percent = float(usage.get("percent") or 0)
triggered = bool(usage.get("ok") and percent >= threshold)
return {
"ok": True,
"enabled": True,
"profile_id": profile_id,
"triggered": triggered,
"rules": [{"threshold": threshold, "percent": percent, "mode": usage.get("mode"), "path": usage.get("path"), "usage": usage}] if triggered else [],
}
def assert_can_start_download(profile: dict) -> None:
result = check(profile, force=True)
if result.get("enabled") and result.get("triggered"):
rule = (result.get("rules") or [{}])[0]
raise RuntimeError(
f"Planner disk protection blocked download start: {rule.get('percent')}% >= {rule.get('threshold')}% ({rule.get('path')})"
)

View File

@@ -0,0 +1,551 @@
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from typing import Any
import psutil
from ..db import connect, default_user_id, utcnow
from . import rtorrent
DEFAULTS = {
"enabled": False,
"name": "Default download plan",
"profile_name": "night mode",
"dry_run": False,
"manual_override_until": "",
"night_only_enabled": False,
"night_start": "23:00",
"night_end": "07:00",
"quiet_hours_enabled": False,
"quiet_start": "22:00",
"quiet_end": "06:00",
"weekday_down": 0,
"weekday_up": 0,
"weekend_down": 0,
"weekend_up": 0,
"hourly_schedule_enabled": False,
"hourly_schedule": [],
"auto_pause_cpu_enabled": False,
"auto_pause_cpu_percent": 90,
"auto_pause_disk_enabled": False,
"auto_pause_disk_percent": 95,
"network_protection_enabled": False,
"network_max_down": 0,
"network_max_up": 0,
"load_protection_enabled": False,
"load_cpu_percent": 95,
"auto_resume": True,
"auto_resume_grace_seconds": 0,
"check_interval_seconds": 30,
}
_LAST_RUN: dict[int, float] = {}
_LAST_LIMITS: dict[int, tuple[int, int]] = {}
_HIGH_CPU_SINCE: dict[int, float] = {}
def _bool(value: Any) -> bool:
if isinstance(value, str):
return value.lower() in {"1", "true", "yes", "on"}
return bool(value)
def _int(value: Any, default: int = 0, lo: int = 0, hi: int = 10**9) -> int:
try:
return max(lo, min(hi, int(value)))
except Exception:
return default
def _hourly_schedule(value: Any) -> list[dict]:
rows = value if isinstance(value, list) else []
by_hour: dict[int, dict] = {}
for item in rows:
if not isinstance(item, dict):
continue
try:
hour = int(item.get("hour"))
except Exception:
continue
if hour < 0 or hour > 23:
continue
by_hour[hour] = {"hour": hour, "down": _int(item.get("down"), 0), "up": _int(item.get("up"), 0)}
return [by_hour.get(hour, {"hour": hour, "down": 0, "up": 0}) for hour in range(24)]
def _hourly_limit_for(settings: dict, hour: int) -> tuple[int, int] | None:
if not settings.get("hourly_schedule_enabled"):
return None
rows = settings.get("hourly_schedule") or []
for item in rows:
if int(item.get("hour", -1)) == int(hour):
return int(item.get("down") or 0), int(item.get("up") or 0)
return 0, 0
def _time_minutes(value: str, fallback: str) -> int:
text = str(value or fallback).strip()
try:
hh, mm = text.split(":", 1)
return max(0, min(1439, int(hh) * 60 + int(mm)))
except Exception:
hh, mm = fallback.split(":", 1)
return int(hh) * 60 + int(mm)
def _in_window(now_min: int, start: str, end: str) -> bool:
s = _time_minutes(start, "00:00")
e = _time_minutes(end, "00:00")
if s == e:
return True
if s < e:
return s <= now_min < e
return now_min >= s or now_min < e
def normalize(data: dict | None) -> dict:
raw = {**DEFAULTS, **(data or {})}
return {
"enabled": _bool(raw.get("enabled")),
"name": str(raw.get("name") or DEFAULTS["name"]).strip()[:120],
"profile_name": str(raw.get("profile_name") or raw.get("name") or DEFAULTS["profile_name"]).strip()[:80],
"dry_run": _bool(raw.get("dry_run")),
"manual_override_until": str(raw.get("manual_override_until") or "")[:40],
"night_only_enabled": _bool(raw.get("night_only_enabled")),
"night_start": str(raw.get("night_start") or DEFAULTS["night_start"])[:5],
"night_end": str(raw.get("night_end") or DEFAULTS["night_end"])[:5],
"quiet_hours_enabled": _bool(raw.get("quiet_hours_enabled")),
"quiet_start": str(raw.get("quiet_start") or DEFAULTS["quiet_start"])[:5],
"quiet_end": str(raw.get("quiet_end") or DEFAULTS["quiet_end"])[:5],
"weekday_down": _int(raw.get("weekday_down"), 0),
"weekday_up": _int(raw.get("weekday_up"), 0),
"weekend_down": _int(raw.get("weekend_down"), 0),
"weekend_up": _int(raw.get("weekend_up"), 0),
"hourly_schedule_enabled": _bool(raw.get("hourly_schedule_enabled")),
"hourly_schedule": _hourly_schedule(raw.get("hourly_schedule")),
"auto_pause_cpu_enabled": _bool(raw.get("auto_pause_cpu_enabled")),
"auto_pause_cpu_percent": _int(raw.get("auto_pause_cpu_percent"), 90, 1, 100),
"auto_pause_disk_enabled": _bool(raw.get("auto_pause_disk_enabled")),
"auto_pause_disk_percent": _int(raw.get("auto_pause_disk_percent"), 95, 1, 100),
"network_protection_enabled": _bool(raw.get("network_protection_enabled")),
"network_max_down": _int(raw.get("network_max_down"), 0),
"network_max_up": _int(raw.get("network_max_up"), 0),
"load_protection_enabled": _bool(raw.get("load_protection_enabled")),
"load_cpu_percent": _int(raw.get("load_cpu_percent"), 95, 1, 100),
"auto_resume": _bool(raw.get("auto_resume")),
"auto_resume_grace_seconds": _int(raw.get("auto_resume_grace_seconds"), 0, 0, 86400),
"check_interval_seconds": _int(raw.get("check_interval_seconds"), 30, 10, 3600),
}
def _row(user_id: int, profile_id: int) -> dict | None:
with connect() as conn:
return conn.execute(
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
(user_id, profile_id),
).fetchone()
def _preference_row_for_disk_source(profile_id: int, user_id: int | None = None) -> dict | None:
from . import preferences
user_id = user_id or default_user_id()
return preferences.get_disk_monitor_preferences(profile_id, user_id)
def _legacy_disk_guard_defaults(profile_id: int, user_id: int | None = None) -> dict:
pref = _preference_row_for_disk_source(profile_id, user_id)
if not pref or not pref.get("disk_monitor_stop_enabled"):
return {}
return {
"enabled": True,
"auto_pause_disk_enabled": True,
"auto_pause_disk_percent": _int(pref.get("disk_monitor_stop_threshold"), 95, 1, 100),
"auto_resume": True,
}
def _history_key(profile_id: int) -> str:
return f"download_planner.history.{int(profile_id)}"
def _override_key(profile_id: int) -> str:
return f"download_planner.override_until.{int(profile_id)}"
def _parse_iso_ts(value: str | None) -> float:
if not value:
return 0.0
try:
text = str(value).replace("Z", "+00:00")
return datetime.fromisoformat(text).timestamp()
except Exception:
return 0.0
def _override_until(profile_id: int) -> str:
with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_override_key(profile_id),)).fetchone()
return str(row.get("value") or "") if row else ""
def set_manual_override(profile_id: int, seconds: int) -> dict:
until = ""
seconds = _int(seconds, 0, 0, 86400)
if seconds:
until = datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat()
with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_override_key(profile_id), until))
return {"manual_override_until": until, "seconds": seconds}
def _append_history(profile_id: int, event: str, payload: dict | None = None) -> None:
payload = payload or {}
with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_history_key(profile_id),)).fetchone()
try:
items = json.loads(row.get("value") or "[]") if row else []
except Exception:
items = []
items.append({"at": utcnow(), "event": str(event), **payload})
items = items[-80:]
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_history_key(profile_id), json.dumps(items)))
def _history_items(profile_id: int) -> list[dict]:
with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_history_key(profile_id),)).fetchone()
try:
items = json.loads(row.get("value") or "[]") if row else []
except Exception:
items = []
return items if isinstance(items, list) else []
def history(profile_id: int, limit: int = 40) -> list[dict]:
items = _history_items(profile_id)
return list(reversed(items[-max(1, min(200, int(limit))):]))
def history_count(profile_id: int) -> int:
return len(_history_items(profile_id))
def clear_history(profile_id: int) -> int:
deleted = history_count(profile_id)
with connect() as conn:
# Note: Planner history is stored per profile in app_settings; clearing it does not change saved Planner rules.
conn.execute("DELETE FROM app_settings WHERE key=?", (_history_key(profile_id),))
return deleted
def _profile_label(settings: dict) -> str:
return str(settings.get("profile_name") or settings.get("name") or "Planner")
def _next_boundary(now: datetime, settings: dict) -> str:
candidates: list[datetime] = []
for hour in range(24):
if settings.get("hourly_schedule_enabled"):
dt = now.replace(hour=hour, minute=0, second=0, microsecond=0)
if dt <= now:
dt = dt + __import__("datetime").timedelta(days=1)
candidates.append(dt)
for key in ("night_start", "night_end", "quiet_start", "quiet_end"):
value = settings.get(key)
if not value:
continue
minute = _time_minutes(str(value), "00:00")
dt = now.replace(hour=minute // 60, minute=minute % 60, second=0, microsecond=0)
if dt <= now:
dt = dt.replace(day=dt.day) + __import__("datetime").timedelta(days=1)
candidates.append(dt)
return min(candidates).isoformat() if candidates else ""
def get_settings(profile_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
row = _row(user_id, profile_id)
if not row:
migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)})
return {**migrated, "profile_id": int(profile_id), "user_id": int(user_id)}
try:
data = json.loads(row.get("settings_json") or "{}")
except Exception:
data = {}
settings = {**normalize(data), "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": row.get("updated_at")}
runtime_override = _override_until(int(profile_id))
if runtime_override:
settings["manual_override_until"] = runtime_override
return settings
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
settings = normalize(data)
now = utcnow()
with connect() as conn:
conn.execute(
"""
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
VALUES(?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET settings_json=excluded.settings_json, updated_at=excluded.updated_at
""",
(user_id, profile_id, json.dumps(settings), now),
)
return {**settings, "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": now}
def _active_downloading_hashes(profile: dict) -> list[str]:
rows = rtorrent.list_torrents(profile)
hashes: list[str] = []
for row in rows:
if int(row.get("complete") or 0):
continue
if int(row.get("state") or 0) and not row.get("paused"):
h = str(row.get("hash") or "")
if h:
hashes.append(h)
return hashes
def _remember_paused(profile_id: int, hashes: list[str], reason: str) -> None:
if not hashes:
return
now = utcnow()
with connect() as conn:
for h in hashes:
conn.execute(
"INSERT OR REPLACE INTO download_plan_paused(profile_id,torrent_hash,reason,created_at,updated_at) VALUES(?,?,?,?,?)",
(profile_id, h, reason, now, now),
)
def _planned_paused(profile_id: int) -> list[str]:
with connect() as conn:
rows = conn.execute("SELECT torrent_hash FROM download_plan_paused WHERE profile_id=?", (profile_id,)).fetchall()
return [str(row.get("torrent_hash") or "") for row in rows if row.get("torrent_hash")]
def _clear_planned(profile_id: int, hashes: list[str] | None = None) -> None:
with connect() as conn:
if hashes:
conn.executemany("DELETE FROM download_plan_paused WHERE profile_id=? AND torrent_hash=?", [(profile_id, h) for h in hashes])
else:
conn.execute("DELETE FROM download_plan_paused WHERE profile_id=?", (profile_id,))
def disk_usage(profile: dict, user_id: int | None = None) -> dict | None:
profile_id = int(profile.get("id") or 0)
pref = _preference_row_for_disk_source(profile_id, user_id) or {}
try:
paths = json.loads(pref.get("disk_monitor_paths_json") or "[]")
except Exception:
paths = []
if not isinstance(paths, list):
paths = []
try:
return rtorrent.disk_usage_for_paths(
profile,
[str(p) for p in paths if str(p or "").strip()],
str(pref.get("disk_monitor_mode") or "default"),
str(pref.get("disk_monitor_selected_path") or ""),
)
except Exception:
return None
def _disk_percent(profile: dict, user_id: int | None = None) -> float | None:
usage = disk_usage(profile, user_id)
if usage and usage.get("ok"):
return float(usage.get("percent") or 0)
return None
def evaluate(profile: dict, settings: dict | None = None, now: datetime | None = None) -> dict:
settings = normalize(settings or get_settings(int(profile.get("id") or 0)))
now = now or datetime.now().astimezone()
override_until = settings.get("manual_override_until") or _override_until(int(profile.get("id") or 0))
override_active = bool(_parse_iso_ts(override_until) > time.time())
now_min = now.hour * 60 + now.minute
weekend = now.weekday() >= 5
reasons: list[str] = []
pause_downloads = False
quiet = bool(settings["quiet_hours_enabled"] and _in_window(now_min, settings["quiet_start"], settings["quiet_end"]))
in_night = _in_window(now_min, settings["night_start"], settings["night_end"])
if quiet:
pause_downloads = True
reasons.append("quiet_hours")
if settings["night_only_enabled"] and not in_night:
pause_downloads = True
reasons.append("outside_night_window")
hourly_limits = _hourly_limit_for(settings, now.hour)
if hourly_limits is not None:
down, up = hourly_limits
reasons.append("hourly_schedule")
else:
down = int(settings["weekend_down"] if weekend else settings["weekday_down"])
up = int(settings["weekend_up"] if weekend else settings["weekday_up"])
if quiet or pause_downloads:
down = 0
cpu = None
if settings["load_protection_enabled"]:
cpu_load = float(psutil.cpu_percent(interval=None))
if cpu_load >= float(settings["load_cpu_percent"]):
pause_downloads = True
reasons.append("high_load")
if settings["auto_pause_cpu_enabled"]:
cpu = float(psutil.cpu_percent(interval=None))
pid = int(profile.get("id") or 0)
if cpu >= float(settings["auto_pause_cpu_percent"]):
_HIGH_CPU_SINCE.setdefault(pid, time.monotonic())
if time.monotonic() - _HIGH_CPU_SINCE[pid] >= 10:
pause_downloads = True
reasons.append("high_cpu")
else:
_HIGH_CPU_SINCE.pop(pid, None)
disk = None
if settings["auto_pause_disk_enabled"]:
disk = _disk_percent(profile, int(settings.get("user_id") or default_user_id()))
if disk is not None and disk >= float(settings["auto_pause_disk_percent"]):
pause_downloads = True
reasons.append("high_disk")
if settings["network_protection_enabled"]:
nd = int(settings.get("network_max_down") or 0)
nu = int(settings.get("network_max_up") or 0)
if nd and (not down or down > nd):
down = nd
reasons.append("network_limit_down")
if nu and (not up or up > nu):
up = nu
reasons.append("network_limit_up")
if override_active:
pause_downloads = False
reasons = ["manual_override"]
return {
"enabled": bool(settings["enabled"]),
"profile_id": int(profile.get("id") or 0),
"profile_name": _profile_label(settings),
"dry_run": bool(settings.get("dry_run")),
"manual_override_until": override_until if override_active else "",
"matched_rule": reasons[0] if reasons else ("weekend" if weekend else "weekday"),
"next_change_at": _next_boundary(now, settings),
"pause_downloads": pause_downloads,
"reasons": reasons,
"down": down,
"up": up,
"weekend": weekend,
"quiet": quiet,
"in_night_window": in_night,
"cpu": cpu,
"disk": disk,
}
def enforce(profile: dict, force: bool = False) -> dict:
profile_id = int(profile.get("id") or 0)
settings = get_settings(profile_id)
if not settings.get("enabled"):
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile)}
now = time.monotonic()
interval = int(settings.get("check_interval_seconds") or 30)
if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True}
_LAST_RUN[profile_id] = now
decision = evaluate(profile, settings)
result: dict[str, Any] = {"ok": True, "enabled": True, **decision, "limits_changed": False, "paused": 0, "resumed": 0}
wanted_limits = (int(decision["down"]), int(decision["up"]))
dry_run = bool(settings.get("dry_run")) or bool(force and str(profile.get("dry_run") or "").lower() == "true")
result["dry_run"] = dry_run
if force or _LAST_LIMITS.get(profile_id) != wanted_limits:
if not dry_run:
rtorrent.set_limits(profile, wanted_limits[0], wanted_limits[1])
_LAST_LIMITS[profile_id] = wanted_limits
result["limits_changed"] = True
_append_history(profile_id, "speed_limit_change", {"down": wanted_limits[0], "up": wanted_limits[1], "dry_run": dry_run})
if decision["pause_downloads"]:
hashes = _active_downloading_hashes(profile)
if hashes:
action = {"dry_run": True} if dry_run else rtorrent.action(profile, hashes, "pause", {"source": "download_planner", "reasons": decision["reasons"]})
if not dry_run:
_remember_paused(profile_id, hashes, ",".join(decision["reasons"]))
result["paused"] = len(hashes)
result["pause_result"] = action
_append_history(profile_id, "paused_torrents", {"count": len(hashes), "reasons": decision["reasons"], "dry_run": dry_run})
if "high_cpu" in decision["reasons"] or "high_load" in decision["reasons"]:
_append_history(profile_id, "cpu_protection_trigger", {"cpu": decision.get("cpu"), "dry_run": dry_run})
if "high_disk" in decision["reasons"]:
_append_history(profile_id, "disk_protection_trigger", {"disk": decision.get("disk"), "dry_run": dry_run})
elif settings.get("auto_resume"):
grace = int(settings.get("auto_resume_grace_seconds") or 0)
last_trigger = 0.0
for item in history(profile_id, 20):
if item.get("event") in {"paused_torrents", "cpu_protection_trigger", "disk_protection_trigger"}:
last_trigger = _parse_iso_ts(item.get("at"))
break
if grace and last_trigger and time.time() - last_trigger < grace:
result["resume_wait_seconds"] = int(grace - (time.time() - last_trigger))
else:
hashes = _planned_paused(profile_id)
if hashes:
action = {"dry_run": True} if dry_run else rtorrent.action(profile, hashes, "resume", {"source": "download_planner"})
if not dry_run:
_clear_planned(profile_id, hashes)
result["resumed"] = len(hashes)
result["resume_result"] = action
_append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run})
result["history"] = history(profile_id, 20)
result["history_total"] = history_count(profile_id)
result["preview"] = preview(profile)
return result
def preview(profile: dict) -> dict:
profile_id = int(profile.get("id") or 0)
settings = get_settings(profile_id)
decision = evaluate(profile, settings)
return {
"profile_id": profile_id,
"profile_name": decision.get("profile_name"),
"matched_rule": decision.get("matched_rule"),
"next_change_at": decision.get("next_change_at"),
"pause_downloads": decision.get("pause_downloads"),
"down": decision.get("down"),
"up": decision.get("up"),
"reasons": decision.get("reasons", []),
"manual_override_until": decision.get("manual_override_until", ""),
"dry_run": decision.get("dry_run", False),
}
def start_scheduler(socketio=None) -> None:
def loop():
while True:
try:
from .preferences import active_profile
from .websocket import emit_profile_event
from . import auth
profiles: list[dict]
if auth.enabled():
with connect() as conn:
profiles = conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
else:
profile = active_profile()
profiles = [profile] if profile else []
for profile in profiles:
try:
result = enforce(profile, force=False)
if socketio and result.get("enabled") and not result.get("skipped"):
emit_profile_event(socketio, "download_plan_update", result, int(profile["id"]))
except Exception as exc:
if socketio:
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": int(profile.get("id") or 0), "error": str(exc)}, int(profile.get("id") or 0))
except Exception:
pass
if socketio:
socketio.sleep(30)
else:
time.sleep(30)
if socketio:
socketio.start_background_task(loop)

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from pathlib import Path
from ..config import BASE_DIR, USE_OFFLINE_LIBS
LIBS_STATIC_DIR = "libs"
LIBS_DIR = BASE_DIR / "pytorrent" / "static" / LIBS_STATIC_DIR
BOOTSTRAP_VERSION = "5.3.3"
BOOTSWATCH_VERSION = "5.3.3"
FONTAWESOME_VERSION = "6.5.2"
FLAG_ICONS_VERSION = "7.2.3"
SWAGGER_UI_VERSION = "5"
SOCKET_IO_VERSION = "4.7.5"
BOOTSTRAP_THEMES = (
"default",
"flatly",
"litera",
"lumen",
"minty",
"sketchy",
"solar",
"spacelab",
"united",
"zephyr",
)
STATIC_ASSETS = {
"bootstrap_js": {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js",
},
"fontawesome_css": {
"local": f"{LIBS_STATIC_DIR}/fontawesome/{FONTAWESOME_VERSION}/css/all.min.css",
"cdn": f"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/{FONTAWESOME_VERSION}/css/all.min.css",
},
"flag_icons_css": {
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
},
"socket_io_js": {
"local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
"cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
},
"swagger_css": {
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.css",
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css",
},
"swagger_js": {
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
},
}
def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEMES else "default"
if theme == "default":
return {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
}
return {
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
}
def asset_path(key: str) -> str:
return STATIC_ASSETS[key]["local" if USE_OFFLINE_LIBS else "cdn"]
def bootstrap_css_path(theme: str | None = None) -> str:
return bootstrap_css_asset(theme)["local" if USE_OFFLINE_LIBS else "cdn"]
def required_offline_paths() -> list[Path]:
paths = [LIBS_DIR.parent / item["local"] for item in STATIC_ASSETS.values()]
paths.extend(LIBS_DIR.parent / bootstrap_css_asset(theme)["local"] for theme in BOOTSTRAP_THEMES)
return paths
def missing_offline_paths() -> list[Path]:
missing = [path for path in required_offline_paths() if not path.is_file() or path.stat().st_size <= 0]
required_dirs = [
LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts",
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/4x3",
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1",
]
for directory in required_dirs:
if not directory.is_dir() or not any(directory.iterdir()):
missing.append(directory)
return missing
def validate_offline_assets() -> None:
if not USE_OFFLINE_LIBS:
return
missing = missing_offline_paths()
if missing:
preview = "\n".join(f"- {path.relative_to(BASE_DIR)}" for path in missing[:20])
extra = "" if len(missing) <= 20 else f"\n- ... and {len(missing) - 20} more"
raise RuntimeError(
"PYTORRENT_USE_OFFLINE_LIBS=true, but frontend libraries are missing. "
"Run: ./scripts/download_frontend_libs.py or ./install.sh\n"
f"Missing files:\n{preview}{extra}"
)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from ..config import GEOIP_DB
try:
import geoip2.database
except Exception: # pragma: no cover
geoip2 = None
_reader = None
def _get_reader():
global _reader
if _reader is not None:
return _reader
if not GEOIP_DB.exists() or geoip2 is None:
return None
_reader = geoip2.database.Reader(str(GEOIP_DB))
return _reader
@lru_cache(maxsize=50000)
def lookup_ip(ip: str) -> dict:
reader = _get_reader()
if not reader:
return {"country_iso": "", "country": "", "city": ""}
try:
hit = reader.city(ip)
return {
"country_iso": (hit.country.iso_code or "").lower(),
"country": hit.country.name or "",
"city": hit.city.name or "",
}
except Exception:
return {"country_iso": "", "country": "", "city": ""}

View File

@@ -0,0 +1,244 @@
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from typing import Any
from ..db import connect, utcnow
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
DEFAULTS = {
"adaptive_enabled": True,
"safe_fallback_enabled": True,
"active_interval_seconds": 5.0,
"idle_interval_seconds": 15.0,
"error_interval_seconds": 30.0,
"torrent_list_interval_seconds": 5.0,
"system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.0,
"queue_stats_interval_seconds": 15.0,
"slow_stats_interval_seconds": 60.0,
"heartbeat_interval_seconds": 15.0,
"emit_heartbeat_on_change": True,
"slow_response_threshold_ms": 8000.0,
"slowdown_multiplier": 2.0,
"recovery_after_errors": 3,
}
def _key(profile_id: int) -> str:
return f"poller.settings.{int(profile_id)}"
def _state_key(profile_id: int) -> str:
return f"poller.runtime.{int(profile_id)}"
def _coerce_float(value: Any, default: float, lo: float, hi: float) -> float:
try:
number = float(value)
except Exception:
return default
return max(lo, min(hi, number))
def normalize_settings(data: dict | None) -> dict:
raw = {**DEFAULTS, **(data or {})}
settings = {
"adaptive_enabled": bool(raw.get("adaptive_enabled")),
"safe_fallback_enabled": bool(raw.get("safe_fallback_enabled", True)),
"active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0),
"idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
"torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
"disk_stats_interval_seconds": _coerce_float(raw.get("disk_stats_interval_seconds"), DEFAULTS["disk_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
"queue_stats_interval_seconds": _coerce_float(raw.get("queue_stats_interval_seconds"), DEFAULTS["queue_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
"slow_stats_interval_seconds": _coerce_float(raw.get("slow_stats_interval_seconds"), DEFAULTS["slow_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
"heartbeat_interval_seconds": _coerce_float(raw.get("heartbeat_interval_seconds"), DEFAULTS["heartbeat_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
"emit_heartbeat_on_change": bool(raw.get("emit_heartbeat_on_change")),
"slow_response_threshold_ms": _coerce_float(raw.get("slow_response_threshold_ms"), DEFAULTS["slow_response_threshold_ms"], 100.0, 60000.0),
"slowdown_multiplier": _coerce_float(raw.get("slowdown_multiplier"), DEFAULTS["slowdown_multiplier"], 1.0, 10.0),
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
}
if settings["safe_fallback_enabled"]:
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"):
if settings[key] <= 0:
settings[key] = DEFAULTS[key]
return settings
def get_settings(profile_id: int) -> dict:
with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
try:
data = json.loads(row.get("value") or "{}") if row else {}
except Exception:
data = {}
return normalize_settings(data)
def save_settings(profile_id: int, data: dict) -> dict:
settings = normalize_settings(data)
with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings)))
return settings
@dataclass
class ProfilePollState:
profile_id: int
last_fast_at: float = 0.0
last_system_at: float = 0.0
last_slow_at: float = 0.0
last_tracker_at: float = 0.0
last_disk_at: float = 0.0
last_queue_at: float = 0.0
last_heartbeat_at: float = 0.0
last_ok: bool = True
last_active: bool = False
last_error: str = ""
last_tick_ms: float = 0.0
last_tick_started_at: float = 0.0
last_tick_gap_ms: float = 0.0
effective_interval_seconds: float = 0.0
tick_count: int = 0
sleep_hint: float = 1.0
error_count: int = 0
slow_count: int = 0
skipped_emissions: int = 0
emitted_payload_size: int = 0
rtorrent_call_count: int = 0
adaptive_mode: str = "normal"
slow_task_running: bool = False
system_task_running: bool = False
stats: dict[str, Any] = field(default_factory=dict)
_STATES: dict[int, ProfilePollState] = {}
def state_for(profile_id: int) -> ProfilePollState:
profile_id = int(profile_id)
state = _STATES.get(profile_id)
if state is None:
state = ProfilePollState(profile_id=profile_id)
_STATES[profile_id] = state
return state
def interval_for(settings: dict, state: ProfilePollState) -> float:
if not settings.get("adaptive_enabled"):
return float(settings["active_interval_seconds"])
if not state.last_ok:
return float(settings["error_interval_seconds"])
base = float(settings["active_interval_seconds"] if state.last_active else settings["idle_interval_seconds"])
if state.adaptive_mode == "slowdown":
return min(float(settings["error_interval_seconds"]), base * float(settings.get("slowdown_multiplier") or 2.0))
return base
def effective_fast_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_fast_at) >= effective_fast_interval(settings, state)
def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_system_at) >= float(settings["system_stats_interval_seconds"])
def should_slow_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_slow_at) >= float(settings["slow_stats_interval_seconds"])
def should_tracker_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_tracker_at) >= float(settings["tracker_stats_interval_seconds"])
def should_disk_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_disk_at) >= float(settings["disk_stats_interval_seconds"])
def should_queue_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_queue_at) >= float(settings["queue_stats_interval_seconds"])
def should_heartbeat(now: float, settings: dict, state: ProfilePollState, changed: bool) -> bool:
if changed and settings.get("emit_heartbeat_on_change"):
return True
return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"])
def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict:
now = time.monotonic()
effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS
previous_started_at = state.last_tick_started_at
state.tick_count += 1
state.last_tick_ms = round((now - started_at) * 1000.0, 2)
state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0
state.last_tick_started_at = started_at
state.last_active = bool(active)
state.effective_interval_seconds = effective_fast_interval(effective_settings, state)
state.last_ok = bool(ok)
state.last_error = str(error or "")
state.emitted_payload_size = int(emitted_payload_size or 0)
state.rtorrent_call_count = int(rtorrent_call_count or 0)
state.skipped_emissions += int(skipped_emissions or 0)
adaptive_enabled = bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"]))
if not adaptive_enabled:
# Adaptive mode is explicitly disabled for this rTorrent profile. Keep metrics,
# but do not enter slowdown/recovery or preserve a stale adaptive state from
# earlier ticks; otherwise refreshes remain slow even with the toggle off.
state.error_count = 0 if ok else state.error_count + 1
state.slow_count = 0
state.adaptive_mode = "fixed"
else:
if ok:
state.error_count = 0
else:
state.error_count += 1
threshold = float(effective_settings.get("slow_response_threshold_ms") or DEFAULTS["slow_response_threshold_ms"])
recovery_after = int(effective_settings.get("recovery_after_errors") or DEFAULTS["recovery_after_errors"])
if state.last_tick_ms >= threshold:
state.slow_count += 1
state.adaptive_mode = "slowdown"
elif ok and state.error_count == 0 and state.slow_count:
state.slow_count = max(0, state.slow_count - 1)
if not ok and state.error_count >= recovery_after:
state.adaptive_mode = "recovery"
elif ok and state.slow_count == 0:
state.adaptive_mode = "normal" if state.last_active else "idle"
state.sleep_hint = max(MIN_POLL_INTERVAL_SECONDS, min(10.0, state.sleep_hint))
state.stats = {
"profile_id": state.profile_id,
"tick_count": state.tick_count,
"last_tick_ms": state.last_tick_ms,
"last_active": state.last_active,
"last_ok": state.last_ok,
"last_tick_gap_ms": state.last_tick_gap_ms,
"effective_interval_seconds": state.effective_interval_seconds,
"configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
"last_error": state.last_error,
"duration_ms": state.last_tick_ms,
"emitted_payload_size": state.emitted_payload_size,
"rtorrent_call_count": state.rtorrent_call_count,
"skipped_emissions": state.skipped_emissions,
"adaptive_enabled": adaptive_enabled,
"adaptive_mode": state.adaptive_mode,
"error_count": state.error_count,
"slow_count": state.slow_count,
"updated_at": utcnow(),
}
return dict(state.stats)
def snapshot(profile_id: int) -> dict:
state = state_for(profile_id)
return dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})

View File

@@ -0,0 +1,428 @@
from __future__ import annotations
import json
from ..db import connect, utcnow, default_user_id
from . import auth
BOOTSTRAP_THEMES = {
"default": "Default Bootstrap",
"flatly": "Flatly",
"litera": "Litera",
"lumen": "Lumen",
"minty": "Minty",
"sketchy": "Sketchy",
"solar": "Solar",
"spacelab": "Spacelab",
"united": "United",
"zephyr": "Zephyr",
}
FONT_FAMILIES = {
"default": "Theme default",
"adwaita-mono": "Adwaita Mono",
"inter": "Inter",
"system-ui": "System UI",
"source-sans-3": "Source Sans 3",
"jetbrains-mono": "JetBrains Mono",
}
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
RECOMMENDED_TABLE_COLUMNS = {
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
"shown": ["down_total", "to_download", "up_total", "created"],
"mobile": {
"status": True, "size": True, "progress": True, "down_rate": True, "up_rate": True,
"eta": True, "seeds": True, "peers": True, "ratio": True, "path": True, "label": True,
"ratio_group": False, "down_total": True, "to_download": True, "up_total": True,
"created": False, "priority": False, "state": False, "active": False, "complete": False,
"hashing": False, "message": False, "hash": False,
},
"mobileSmartFiltersEnabled": False,
"widths": {
"select": 44, "name": 389, "status": 83, "size": 75, "progress": 177,
"down_rate": 60, "up_rate": 55, "eta": 53, "seeds": 44, "peers": 49,
"ratio": 47, "path": 135, "label": 67, "ratio_group": 87,
"down_total": 82, "to_download": 89, "up_total": 44, "created": 150,
"priority": 80, "state": 70, "active": 70, "complete": 82, "hashing": 82,
"message": 220, "hash": 280,
},
}
def recommended_table_columns_json() -> str:
return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":"))
def apply_recommended_table_columns(user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
get_preferences(user_id)
now = utcnow()
value = recommended_table_columns_json()
with connect() as conn:
conn.execute(
"UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?",
(value, now, user_id),
)
return get_preferences(user_id)
def bootstrap_css_url(theme: str | None) -> str:
from .frontend_assets import bootstrap_css_path
return bootstrap_css_path(theme)
def _int_setting(data: dict, key: str, default: int, minimum: int, maximum: int) -> int:
try:
value = int(data.get(key) if data.get(key) is not None else default)
except (TypeError, ValueError):
value = default
return max(minimum, min(maximum, value))
def list_profiles(user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
visible = auth.visible_profile_ids(user_id)
with connect() as conn:
if visible is None:
return conn.execute(
"SELECT * FROM rtorrent_profiles ORDER BY is_default DESC, name COLLATE NOCASE"
).fetchall()
if not visible:
return []
placeholders = ",".join("?" for _ in visible)
return conn.execute(
f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) ORDER BY is_default DESC, name COLLATE NOCASE",
tuple(visible),
).fetchall()
def get_profile(profile_id: int, user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
if not auth.can_access_profile(profile_id, user_id):
return None
with connect() as conn:
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
def active_profile(user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
pref = conn.execute("SELECT active_rtorrent_id FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
if pref and pref.get("active_rtorrent_id") and auth.can_access_profile(int(pref["active_rtorrent_id"]), user_id):
row = conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (pref["active_rtorrent_id"],)).fetchone()
if row:
return row
profiles = list_profiles(user_id)
return profiles[0] if profiles else None
def save_profile(data: dict, user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
now = utcnow()
name = str(data.get("name") or "rTorrent").strip()
scgi_url = str(data.get("scgi_url") or "").strip()
timeout = _int_setting(data, "timeout_seconds", 5, 1, 300)
max_parallel = _int_setting(data, "max_parallel_jobs", 5, 1, 64)
light_parallel = _int_setting(data, "light_parallel_jobs", 4, 1, 64)
light_timeout = _int_setting(data, "light_job_timeout_seconds", 300, 30, 86400)
heavy_timeout = _int_setting(data, "heavy_job_timeout_seconds", 7200, 300, 172800)
pending_timeout = _int_setting(data, "pending_job_timeout_seconds", 900, 60, 86400)
is_remote = 1 if data.get("is_remote") else 0
is_default = 1 if data.get("is_default") else 0
if not scgi_url.startswith("scgi://"):
raise ValueError("SCGI URL must start with scgi://")
with connect() as conn:
if is_default:
conn.execute("UPDATE rtorrent_profiles SET is_default=0 WHERE user_id=?", (user_id,))
cur = conn.execute(
"INSERT INTO rtorrent_profiles(user_id,name,scgi_url,is_default,timeout_seconds,max_parallel_jobs,light_parallel_jobs,light_job_timeout_seconds,heavy_job_timeout_seconds,pending_job_timeout_seconds,is_remote,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
(user_id, name, scgi_url, is_default, timeout, max_parallel, light_parallel, light_timeout, heavy_timeout, pending_timeout, is_remote, now, now),
)
profile_id = cur.lastrowid
pref = conn.execute("SELECT active_rtorrent_id FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
if not pref or not pref.get("active_rtorrent_id") or is_default:
conn.execute(
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
(profile_id, now, user_id),
)
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
def update_profile(profile_id: int, data: dict, user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
now = utcnow()
name = str(data.get("name") or "rTorrent").strip()
scgi_url = str(data.get("scgi_url") or "").strip()
timeout = _int_setting(data, "timeout_seconds", 5, 1, 300)
max_parallel = _int_setting(data, "max_parallel_jobs", 5, 1, 64)
light_parallel = _int_setting(data, "light_parallel_jobs", 4, 1, 64)
light_timeout = _int_setting(data, "light_job_timeout_seconds", 300, 30, 86400)
heavy_timeout = _int_setting(data, "heavy_job_timeout_seconds", 7200, 300, 172800)
pending_timeout = _int_setting(data, "pending_job_timeout_seconds", 900, 60, 86400)
is_remote = 1 if data.get("is_remote") else 0
is_default = 1 if data.get("is_default") else 0
if not scgi_url.startswith("scgi://"):
raise ValueError("SCGI URL must start with scgi://")
with connect() as conn:
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
if not row or not auth.can_write_profile(profile_id, user_id):
raise ValueError("Profil nie istnieje")
if is_default:
conn.execute("UPDATE rtorrent_profiles SET is_default=0 WHERE user_id=?", (user_id,))
conn.execute(
"UPDATE rtorrent_profiles SET name=?, scgi_url=?, is_default=?, timeout_seconds=?, max_parallel_jobs=?, light_parallel_jobs=?, light_job_timeout_seconds=?, heavy_job_timeout_seconds=?, pending_job_timeout_seconds=?, is_remote=?, updated_at=? WHERE id=?",
(name, scgi_url, is_default, timeout, max_parallel, light_parallel, light_timeout, heavy_timeout, pending_timeout, is_remote, now, profile_id),
)
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
def delete_profile(profile_id: int, user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
auth.require_profile_write(profile_id)
with connect() as conn:
conn.execute("DELETE FROM rtorrent_profiles WHERE id=?", (profile_id,))
active = active_profile(user_id)
conn.execute(
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
(active["id"] if active else None, utcnow(), user_id),
)
def activate_profile(profile_id: int, user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
if not row or not auth.can_access_profile(profile_id, user_id):
raise ValueError("Profil nie istnieje")
conn.execute(
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
(profile_id, utcnow(), user_id),
)
return get_profile(profile_id, user_id)
def export_profiles(user_id: int | None = None) -> dict:
profiles = [dict(row) for row in list_profiles(user_id)]
for p in profiles:
p.pop("id", None)
p.pop("user_id", None)
p.pop("created_at", None)
p.pop("updated_at", None)
return {"version": 1, "profiles": profiles}
def import_profiles(payload: dict, user_id: int | None = None) -> list[dict]:
user_id = user_id or auth.current_user_id() or default_user_id()
rows = payload.get("profiles") if isinstance(payload, dict) else None
if not isinstance(rows, list):
raise ValueError("Invalid profiles export")
imported = []
for item in rows:
if not isinstance(item, dict):
continue
imported.append(dict(save_profile(item, user_id)))
return imported
def _active_profile_id_for_user(user_id: int) -> int | None:
profile = active_profile(user_id)
try:
return int(profile["id"]) if profile else None
except Exception:
return None
def _clean_disk_paths(value) -> list[str]:
try:
parsed = json.loads(value if isinstance(value, str) else json.dumps(value or []))
except Exception:
parsed = []
if not isinstance(parsed, list):
parsed = []
clean: list[str] = []
for item in parsed:
path = str(item or "").strip()
if path and path not in clean:
clean.append(path)
return clean
def _normalize_disk_monitor(data: dict | None) -> dict:
data = data or {}
mode = str(data.get("mode") or data.get("disk_monitor_mode") or "default")
if mode not in {"default", "selected", "aggregate"}:
mode = "default"
try:
threshold = int(data.get("stop_threshold") if data.get("stop_threshold") is not None else data.get("disk_monitor_stop_threshold") or 98)
except (TypeError, ValueError):
threshold = 98
threshold = max(1, min(100, threshold))
return {
"disk_monitor_paths_json": json.dumps(_clean_disk_paths(data.get("paths_json") if data.get("paths_json") is not None else data.get("disk_monitor_paths_json"))),
"disk_monitor_mode": mode,
"disk_monitor_selected_path": str(data.get("selected_path") if data.get("selected_path") is not None else data.get("disk_monitor_selected_path") or "").strip(),
"disk_monitor_stop_enabled": 1 if (data.get("stop_enabled") if data.get("stop_enabled") is not None else data.get("disk_monitor_stop_enabled")) else 0,
"disk_monitor_stop_threshold": threshold,
}
def legacy_disk_monitor_preferences(user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
row = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {}
return _normalize_disk_monitor(row)
def get_disk_monitor_preferences(profile_id: int | None = None, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
if not profile_id:
return legacy_disk_monitor_preferences(user_id)
with connect() as conn:
row = conn.execute("SELECT * FROM disk_monitor_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
if row:
return _normalize_disk_monitor(row)
# Backward-compatible seed: existing global disk monitor values become defaults for first use of a profile.
return legacy_disk_monitor_preferences(user_id)
def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
if not profile_id:
return legacy_disk_monitor_preferences(user_id)
current = get_disk_monitor_preferences(profile_id, user_id)
merged = dict(current)
for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"):
if key in data:
merged[key] = data.get(key)
clean = _normalize_disk_monitor(merged)
now = utcnow()
with connect() as conn:
conn.execute(
"INSERT INTO disk_monitor_preferences(user_id,profile_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(user_id,profile_id) DO UPDATE SET paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at",
(user_id, profile_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now),
)
return clean
def get_preferences(user_id: int | None = None, profile_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
if not pref:
now = utcnow()
conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now))
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
merged = dict(pref or {})
merged.update(get_disk_monitor_preferences(profile_id, user_id))
return merged
def save_preferences(data: dict, user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
table_columns_json = data.get("table_columns_json")
peers_refresh_seconds = data.get("peers_refresh_seconds")
port_check_enabled = data.get("port_check_enabled")
footer_items_json = data.get("footer_items_json")
title_speed_enabled = data.get("title_speed_enabled")
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
automation_toasts_enabled = data.get("automation_toasts_enabled")
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
disk_monitor_paths_json = data.get("disk_monitor_paths_json")
disk_monitor_mode = data.get("disk_monitor_mode")
disk_monitor_selected_path = data.get("disk_monitor_selected_path")
disk_monitor_stop_enabled = data.get("disk_monitor_stop_enabled")
disk_monitor_stop_threshold = data.get("disk_monitor_stop_threshold")
interface_scale = data.get("interface_scale")
detail_panel_height = data.get("detail_panel_height")
torrent_sort_json = data.get("torrent_sort_json")
active_filter = data.get("active_filter")
disk_payload = None
if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)):
disk_payload = {
"disk_monitor_paths_json": disk_monitor_paths_json,
"disk_monitor_mode": disk_monitor_mode,
"disk_monitor_selected_path": disk_monitor_selected_path,
"disk_monitor_stop_enabled": disk_monitor_stop_enabled,
"disk_monitor_stop_threshold": disk_monitor_stop_threshold,
}
with connect() as conn:
now = utcnow()
if allowed_theme:
conn.execute("UPDATE user_preferences SET theme=?, updated_at=? WHERE user_id=?", (allowed_theme, now, user_id))
if bootstrap_theme:
conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id))
if font_family:
conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id))
if table_columns_json is not None:
conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id))
if peers_refresh_seconds is not None:
sec = int(peers_refresh_seconds or 0)
if sec not in {0, 10, 15, 30, 60}: sec = 0
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
if port_check_enabled is not None:
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
if title_speed_enabled is not None:
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
if tracker_favicons_enabled is not None:
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
if automation_toasts_enabled is not None:
# Note: Lets users silence automation-created toast noise without hiding job/history data.
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
if smart_queue_toasts_enabled is not None:
# Note: Smart Queue toast noise can be disabled independently from automation notifications.
conn.execute("UPDATE user_preferences SET smart_queue_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if smart_queue_toasts_enabled else 0, now, user_id))
if interface_scale is not None:
scale = int(interface_scale or 100)
if scale < 80: scale = 80
if scale > 140: scale = 140
conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id))
if footer_items_json is not None:
# Note: Store only JSON objects so footer visibility can be extended without schema churn.
value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json)
parsed = json.loads(value or "{}")
if not isinstance(parsed, dict):
parsed = {}
conn.execute("UPDATE user_preferences SET footer_items_json=?, updated_at=? WHERE user_id=?", (json.dumps(parsed), now, user_id))
if detail_panel_height is not None:
try:
height = int(detail_panel_height or 255)
except (TypeError, ValueError):
height = 255
if height < 160: height = 160
if height > 720: height = 720
conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id))
if torrent_sort_json is not None:
# Note: Persist only a compact sort object; unknown keys are ignored on the client.
value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json)
parsed = json.loads(value or "{}")
if not isinstance(parsed, dict):
parsed = {}
try:
direction = int(parsed.get("dir") or 1)
except (TypeError, ValueError):
direction = 1
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
sort_key = str(parsed.get("key") or "name")
if sort_key not in allowed_sort_keys:
sort_key = "name"
clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1}
conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id))
if active_filter is not None:
value = str(active_filter or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id))
if disk_payload is not None:
save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id)
return get_preferences(user_id)

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from ..db import connect, utcnow, default_user_id
from . import rtorrent
from .workers import enqueue
def _age_minutes_from_epoch(value) -> int:
try:
created = datetime.fromtimestamp(int(value or 0), timezone.utc)
return max(0, int((datetime.now(timezone.utc) - created).total_seconds() // 60))
except Exception:
return 0
def _is_private(profile: dict, torrent_hash: str) -> bool:
try:
value = rtorrent.client_for(profile).call("d.is_private", torrent_hash)
return bool(int(value or 0))
except Exception:
return False
def _group_for_torrent(groups_by_name: dict[str, dict], torrent: dict) -> dict | None:
name = str(torrent.get("ratio_group") or "").strip()
return groups_by_name.get(name) if name else None
def _record(user_id: int, profile_id: int, group: dict, torrent: dict, action: str, status: str, reason: str, details: dict | None = None) -> None:
now = utcnow()
with connect() as conn:
conn.execute(
"INSERT INTO ratio_history(user_id,profile_id,group_id,group_name,torrent_hash,torrent_name,action,status,reason,details_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(user_id, profile_id, group.get("id"), group.get("name"), torrent.get("hash"), torrent.get("name"), action, status, reason, json.dumps(details or {}), now),
)
conn.execute(
"INSERT INTO ratio_assignments(profile_id,torrent_hash,group_id,group_name,applied_at,last_status,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(profile_id,torrent_hash) DO UPDATE SET group_id=excluded.group_id,group_name=excluded.group_name,applied_at=excluded.applied_at,last_status=excluded.last_status,updated_at=excluded.updated_at",
(profile_id, torrent.get("hash"), group.get("id"), group.get("name"), now if status == "applied" else None, status, now),
)
def _should_apply(profile: dict, group: dict, torrent: dict) -> tuple[bool, str]:
if not int(group.get("enabled") or 0):
return False, "group disabled"
if not torrent.get("complete"):
return False, "torrent is not complete"
if int(group.get("ignore_private") or 0) and _is_private(profile, torrent["hash"]):
return False, "private torrent is excluded"
min_ratio = float(group.get("min_ratio") or 0)
max_ratio = float(group.get("max_ratio") or 0)
wanted_ratio = max(min_ratio, max_ratio)
seed_time = max(int(group.get("seed_time_minutes") or 0), int(group.get("min_seed_time_minutes") or 0))
ratio_ok = float(torrent.get("ratio") or 0) >= wanted_ratio if wanted_ratio else True
seed_ok = _age_minutes_from_epoch(torrent.get("created")) >= seed_time if seed_time else True
if not ratio_ok:
return False, "ratio threshold not reached"
if not seed_ok:
return False, "minimum seed time not reached"
min_upload = int(group.get("active_upload_min_bytes") or 1024)
if int(group.get("ignore_active_upload") or 0) and int(torrent.get("up_rate") or 0) >= min_upload:
return False, "active upload is above exception threshold"
return True, "ratio rule applied"
def check(profile: dict, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
profile_id = int(profile["id"])
with connect() as conn:
groups = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
already = {row["torrent_hash"] for row in conn.execute("SELECT torrent_hash FROM ratio_assignments WHERE profile_id=? AND last_status='applied'", (profile_id,)).fetchall()}
groups_by_name = {str(g.get("name") or ""): g for g in groups}
applied = 0
skipped = 0
queued_jobs = []
for torrent in rtorrent.list_torrents(profile):
group = _group_for_torrent(groups_by_name, torrent)
if not group:
continue
if torrent.get("hash") in already:
skipped += 1
continue
ok, reason = _should_apply(profile, group, torrent)
if not ok:
skipped += 1
with connect() as conn:
conn.execute(
"INSERT INTO ratio_assignments(profile_id,torrent_hash,group_id,group_name,last_status,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(profile_id,torrent_hash) DO UPDATE SET group_id=excluded.group_id,group_name=excluded.group_name,last_status=excluded.last_status,updated_at=excluded.updated_at",
(profile_id, torrent.get("hash"), group.get("id"), group.get("name"), reason, utcnow()),
)
continue
action = str(group.get("action") or "stop")
payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}}
if action == "remove_data":
api_action = "remove"
payload["remove_data"] = True
elif action == "move":
api_action = "move"
payload.update({"path": group.get("move_path") or torrent.get("path") or "", "move_data": True, "recheck": False, "keep_seeding": False})
elif action == "set_label":
api_action = "set_label"
payload["label"] = group.get("set_label") or group.get("name") or ""
else:
api_action = action if action in {"stop", "remove", "pause"} else "stop"
job_id = enqueue(api_action, profile_id, payload, user_id=user_id)
queued_jobs.append(job_id)
applied += 1
_record(user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action})
return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs}
_scheduler_started = False
def start_scheduler(socketio=None) -> None:
global _scheduler_started
if _scheduler_started:
return
_scheduler_started = True
def loop() -> None:
# Note: Ratio rules are evaluated periodically and actions are executed through the existing safe job queue.
while True:
try:
from .preferences import get_profile
with connect() as conn:
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM ratio_groups WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
for row in profiles:
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
if not profile:
continue
result = check(profile, int(row["user_id"]))
if socketio and result.get("applied"):
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
except Exception:
pass
time.sleep(300)
if socketio:
socketio.start_background_task(loop)
else:
import threading
threading.Thread(target=loop, daemon=True, name="pytorrent-ratio-scheduler").start()

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
from ..db import connect
_LAST_CLEANUP = 0.0
CLEANUP_EVERY_SECONDS = 3600
def _cutoff(days: int) -> str:
return (datetime.now(timezone.utc) - timedelta(days=max(1, int(days or 1)))).isoformat(timespec="seconds")
def _table_exists(conn, table: str) -> bool:
row = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
return bool(row)
def cleanup(force: bool = False) -> dict[str, int]:
global _LAST_CLEANUP
now_ts = datetime.now(timezone.utc).timestamp()
if not force and now_ts - _LAST_CLEANUP < CLEANUP_EVERY_SECONDS:
return {}
_LAST_CLEANUP = now_ts
deleted: dict[str, int] = {}
with connect() as conn:
targets = {
"traffic_history": ("created_at", TRAFFIC_HISTORY_RETENTION_DAYS),
"smart_queue_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS),
# Note: Automation history follows Smart Queue retention; rules and rule state are never deleted here.
"automation_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS),
"jobs": ("updated_at", JOBS_RETENTION_DAYS),
"logs": ("created_at", LOG_RETENTION_DAYS),
}
for table, (column, days) in targets.items():
if not _table_exists(conn, table):
continue
if table == "jobs":
cur = conn.execute(
f"DELETE FROM {table} WHERE {column} < ? AND status IN ('done','failed','cancelled')",
(_cutoff(days),),
)
else:
cur = conn.execute(f"DELETE FROM {table} WHERE {column} < ?", (_cutoff(days),))
deleted[table] = int(cur.rowcount or 0)
return deleted

218
pytorrent/services/rss.py Normal file
View File

@@ -0,0 +1,218 @@
from __future__ import annotations
import re
import time
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone, timedelta
from email.utils import parsedate_to_datetime
from typing import Iterable
from ..db import connect, utcnow, default_user_id
from . import rtorrent
from .workers import enqueue
RSS_FETCH_LIMIT = 2_000_000
def _parse_dt(value: str | None) -> datetime | None:
if not value:
return None
try:
return parsedate_to_datetime(value).astimezone(timezone.utc)
except Exception:
return None
def _item_size(item: ET.Element) -> int:
enc = item.find("enclosure")
if enc is not None:
try:
return int(enc.get("length") or 0)
except Exception:
return 0
for tag in ("size", "length"):
try:
return int(item.findtext(tag) or 0)
except Exception:
pass
return 0
def _item_category(item: ET.Element) -> str:
values = [x.text or "" for x in item.findall("category")]
return " ".join(values).strip()
def parse_feed(raw: bytes) -> list[dict]:
root = ET.fromstring(raw)
items = root.findall(".//item")
if not items and root.tag.lower().endswith("feed"):
items = root.findall("{http://www.w3.org/2005/Atom}entry")
parsed: list[dict] = []
for item in items[:200]:
title = item.findtext("title") or item.findtext("{http://www.w3.org/2005/Atom}title") or ""
link = item.findtext("link") or ""
atom_link = item.find("{http://www.w3.org/2005/Atom}link")
if atom_link is not None and atom_link.get("href"):
link = atom_link.get("href") or link
enc = item.find("enclosure")
if enc is not None and enc.get("url"):
link = enc.get("url") or link
pub_date = item.findtext("pubDate") or item.findtext("updated") or item.findtext("{http://www.w3.org/2005/Atom}updated")
parsed.append({
"title": title.strip(),
"link": str(link or "").strip(),
"size": _item_size(item),
"category": _item_category(item),
"published_at": _parse_dt(pub_date).isoformat(timespec="seconds") if _parse_dt(pub_date) else None,
})
return parsed
def fetch_feed(url: str) -> list[dict]:
req = urllib.request.Request(url, headers={"User-Agent": "pyTorrent RSS"})
with urllib.request.urlopen(req, timeout=12) as res:
raw = res.read(RSS_FETCH_LIMIT)
return parse_feed(raw)
def _season_episode(title: str) -> tuple[int | None, int | None]:
match = re.search(r"S(\d{1,2})E(\d{1,3})", title or "", re.I)
if match:
return int(match.group(1)), int(match.group(2))
match = re.search(r"\b(\d{1,2})x(\d{1,3})\b", title or "", re.I)
if match:
return int(match.group(1)), int(match.group(2))
return None, None
def matches_rule(rule: dict, item: dict) -> tuple[bool, str]:
title = str(item.get("title") or "")
haystack = " ".join([title, str(item.get("category") or "")])
pattern = str(rule.get("pattern") or ".*")
exclude = str(rule.get("exclude_pattern") or "").strip()
try:
if pattern and not re.search(pattern, haystack, re.I):
return False, "include pattern did not match"
if exclude and re.search(exclude, haystack, re.I):
return False, "exclude pattern matched"
except re.error as exc:
return False, f"invalid regex: {exc}"
size_mb = (int(item.get("size") or 0) / 1024 / 1024) if item.get("size") else 0
min_size = int(rule.get("min_size_mb") or 0)
max_size = int(rule.get("max_size_mb") or 0)
if min_size and size_mb and size_mb < min_size:
return False, "item is below minimum size"
if max_size and size_mb and size_mb > max_size:
return False, "item is above maximum size"
category = str(rule.get("category") or "").strip().lower()
if category and category not in str(item.get("category") or "").lower() and category not in title.lower():
return False, "category did not match"
quality = str(rule.get("quality") or "").strip().lower()
if quality and quality not in title.lower():
return False, "quality did not match"
wanted_season = rule.get("season")
wanted_episode = rule.get("episode")
found_season, found_episode = _season_episode(title)
if wanted_season not in (None, "", 0) and int(wanted_season) != int(found_season or -1):
return False, "season did not match"
if wanted_episode not in (None, "", 0) and int(wanted_episode) != int(found_episode or -1):
return False, "episode did not match"
return True, "matched"
def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None:
with connect() as conn:
try:
conn.execute(
"INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
(user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
)
except Exception:
# Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads.
pass
def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict:
user_id = user_id or default_user_id()
profile_id = int(profile["id"])
now = utcnow()
with connect() as conn:
if only_due:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall()
else:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
queued = 0
tested = 0
errors: list[dict] = []
for feed in feeds:
interval = max(5, int(feed.get("interval_minutes") or 30))
next_check = (datetime.now(timezone.utc) + timedelta(minutes=interval)).isoformat(timespec="seconds")
try:
items = fetch_feed(feed["url"])
for item in items:
for rule in rules:
matched, reason = matches_rule(rule, item)
tested += 1
if not matched:
continue
link = item.get("link") or ""
if not link:
_log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link")
continue
enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id)
queued += 1
_log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason)
with connect() as conn:
conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"]))
except Exception as exc:
errors.append({"feed_id": feed.get("id"), "error": str(exc)})
with connect() as conn:
conn.execute("UPDATE rss_feeds SET last_error=?,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (str(exc), now, next_check, now, feed["id"]))
return {"queued": queued, "tested": tested, "feeds_checked": len(feeds), "errors": errors}
def test_rule(feed_url: str, rule: dict) -> dict:
items = fetch_feed(feed_url)
matches = []
rejected = []
for item in items[:100]:
matched, reason = matches_rule(rule, item)
target = matches if matched else rejected
target.append({**item, "reason": reason})
return {"matches": matches[:50], "rejected": rejected[:50], "total": len(items)}
_scheduler_started = False
def start_scheduler(socketio=None) -> None:
global _scheduler_started
if _scheduler_started:
return
_scheduler_started = True
def loop() -> None:
# Note: The lightweight RSS scheduler uses persisted next_check_at values, so restarts do not reset cadence.
while True:
try:
from .preferences import get_profile
with connect() as conn:
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
for row in profiles:
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
if profile:
result = check(profile, int(row["user_id"]), only_due=True)
if socketio and result.get("queued"):
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
except Exception:
pass
time.sleep(60)
if socketio:
socketio.start_background_task(loop)
else:
import threading
threading.Thread(target=loop, daemon=True, name="pytorrent-rss-scheduler").start()

View 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.

View 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 *

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

View 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('__')]

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

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

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

View File

@@ -0,0 +1,4 @@
from __future__ import annotations
# Note: Backward-compatible internal alias for modules created during refactor.
from .client import *

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
from __future__ import annotations
import threading
from typing import Any
from ..db import connect, utcnow
from .rtorrent import human_rate
_SESSION_STARTED_AT = utcnow()
_CACHE: dict[int, dict[str, Any]] = {}
_LOADED = False
_LOCK = threading.Lock()
def _empty_peak(profile_id: int, all_time: dict[str, Any] | None = None) -> dict[str, Any]:
# Note: One in-memory structure keeps the current session and all-time record for the rTorrent profile.
all_time = all_time or {}
return {
"profile_id": int(profile_id),
"session_started_at": _SESSION_STARTED_AT,
"session_down_peak": 0,
"session_up_peak": 0,
"session_down_peak_at": None,
"session_up_peak_at": None,
"all_time_down_peak": int(all_time.get("all_time_down_peak") or 0),
"all_time_up_peak": int(all_time.get("all_time_up_peak") or 0),
"all_time_down_peak_at": all_time.get("all_time_down_peak_at"),
"all_time_up_peak_at": all_time.get("all_time_up_peak_at"),
}
def load_cache() -> None:
# Note: All-time records are loaded on application start, while the session record starts from zero.
global _LOADED
with _LOCK:
if _LOADED:
return
with connect() as conn:
rows = conn.execute("SELECT * FROM transfer_speed_peaks").fetchall()
for row in rows:
profile_id = int(row.get("profile_id") or 0)
if profile_id:
_CACHE[profile_id] = _empty_peak(profile_id, row)
_LOADED = True
def _ensure_profile(profile_id: int) -> dict[str, Any]:
# Note: Lazy loading protects profiles added after startup from empty records.
profile_id = int(profile_id)
item = _CACHE.get(profile_id)
if item:
return item
with connect() as conn:
row = conn.execute("SELECT * FROM transfer_speed_peaks WHERE profile_id=?", (profile_id,)).fetchone()
item = _empty_peak(profile_id, row)
_CACHE[profile_id] = item
return item
def _persist(item: dict[str, Any]) -> None:
# Note: SQLite is updated only when a new session or all-time record appears.
now = utcnow()
with connect() as conn:
conn.execute(
"""
INSERT INTO transfer_speed_peaks(
profile_id, session_started_at, session_down_peak, session_up_peak,
session_down_peak_at, session_up_peak_at, all_time_down_peak,
all_time_up_peak, all_time_down_peak_at, all_time_up_peak_at,
created_at, updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET
session_started_at=excluded.session_started_at,
session_down_peak=excluded.session_down_peak,
session_up_peak=excluded.session_up_peak,
session_down_peak_at=excluded.session_down_peak_at,
session_up_peak_at=excluded.session_up_peak_at,
all_time_down_peak=excluded.all_time_down_peak,
all_time_up_peak=excluded.all_time_up_peak,
all_time_down_peak_at=excluded.all_time_down_peak_at,
all_time_up_peak_at=excluded.all_time_up_peak_at,
updated_at=excluded.updated_at
""",
(
int(item["profile_id"]),
item["session_started_at"],
int(item["session_down_peak"]),
int(item["session_up_peak"]),
item.get("session_down_peak_at"),
item.get("session_up_peak_at"),
int(item["all_time_down_peak"]),
int(item["all_time_up_peak"]),
item.get("all_time_down_peak_at"),
item.get("all_time_up_peak_at"),
now,
now,
),
)
def _public(item: dict[str, Any]) -> dict[str, Any]:
# Note: The frontend receives bytes/s and ready labels matching the existing speed format.
return {
"session_started_at": item["session_started_at"],
"session": {
"down": int(item["session_down_peak"]),
"up": int(item["session_up_peak"]),
"down_h": human_rate(int(item["session_down_peak"])),
"up_h": human_rate(int(item["session_up_peak"])),
"down_at": item.get("session_down_peak_at"),
"up_at": item.get("session_up_peak_at"),
},
"all_time": {
"down": int(item["all_time_down_peak"]),
"up": int(item["all_time_up_peak"]),
"down_h": human_rate(int(item["all_time_down_peak"])),
"up_h": human_rate(int(item["all_time_up_peak"])),
"down_at": item.get("all_time_down_peak_at"),
"up_at": item.get("all_time_up_peak_at"),
},
}
def record(profile_id: int, down_rate: int = 0, up_rate: int = 0) -> dict[str, Any]:
# Note: The poller calls this in the background; the database updates only after a record is beaten.
load_cache()
down_rate = max(0, int(down_rate or 0))
up_rate = max(0, int(up_rate or 0))
measured_at = utcnow()
changed = False
with _LOCK:
item = _ensure_profile(int(profile_id))
if down_rate > int(item["session_down_peak"]):
item["session_down_peak"] = down_rate
item["session_down_peak_at"] = measured_at
changed = True
if up_rate > int(item["session_up_peak"]):
item["session_up_peak"] = up_rate
item["session_up_peak_at"] = measured_at
changed = True
if down_rate > int(item["all_time_down_peak"]):
item["all_time_down_peak"] = down_rate
item["all_time_down_peak_at"] = measured_at
changed = True
if up_rate > int(item["all_time_up_peak"]):
item["all_time_up_peak"] = up_rate
item["all_time_up_peak_at"] = measured_at
changed = True
result = _public(item)
if changed:
_persist(item)
return result
def current(profile_id: int) -> dict[str, Any]:
# Note: The REST API can show the latest known record without forcing a new measurement.
load_cache()
with _LOCK:
return _public(_ensure_profile(int(profile_id)))

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from time import sleep
from . import preferences, rtorrent
_started = False
def schedule_startup_config_apply(socketio, delay_seconds: int = 60) -> None:
"""Apply saved rTorrent UI overrides after pyTorrent has been running for a moment."""
global _started
if _started:
return
_started = True
def runner():
sleep(max(0, int(delay_seconds)))
try:
for profile in preferences.list_profiles():
result = rtorrent.apply_startup_overrides(profile)
if not result.get("skipped"):
socketio.emit("rtorrent_config_applied", {"profile_id": profile["id"], "result": result})
except Exception as exc:
socketio.emit("rtorrent_config_applied", {"ok": False, "error": str(exc)})
socketio.start_background_task(runner)

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from threading import RLock
from time import time
from . import rtorrent
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
class TorrentCache:
def __init__(self):
self._lock = RLock()
self._data: dict[int, dict[str, dict]] = {}
self._errors: dict[int, str] = {}
self._updated_at: dict[int, float] = {}
def snapshot(self, profile_id: int) -> list[dict]:
with self._lock:
return list(self._data.get(profile_id, {}).values())
def error(self, profile_id: int) -> str:
with self._lock:
return self._errors.get(profile_id, "")
def clear_profile(self, profile_id: int) -> int:
"""Clear cached torrent rows for one profile and return removed row count."""
# Note: Cleanup clears only in-memory rows for the selected profile; rTorrent data is untouched.
profile_id = int(profile_id or 0)
with self._lock:
removed = len(self._data.get(profile_id, {}))
self._data.pop(profile_id, None)
self._errors.pop(profile_id, None)
self._updated_at.pop(profile_id, None)
return removed
def refresh(self, profile: dict) -> dict:
profile_id = int(profile["id"])
try:
rows = rtorrent.list_torrents(profile)
with self._lock:
old = dict(self._data.get(profile_id, {}))
post_check_changes = rtorrent.apply_post_check_policy(profile, rows, old)
fresh = {t["hash"]: t for t in rows}
with self._lock:
added = [v for h, v in fresh.items() if h not in old]
removed = [h for h in old.keys() if h not in fresh]
updated = []
for h, new in fresh.items():
prev = old.get(h)
if not prev:
continue
patch = {"hash": h}
for key, value in new.items():
if prev.get(key) != value:
patch[key] = value
if len(patch) > 1:
updated.append(patch)
self._data[profile_id] = fresh
self._errors[profile_id] = ""
self._updated_at[profile_id] = time()
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
except Exception as exc:
with self._lock:
self._errors[profile_id] = str(exc)
return {"ok": False, "profile_id": profile_id, "error": str(exc), "added": [], "updated": [], "removed": []}
torrent_cache = TorrentCache()

View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import hashlib
import os
import time
from pathlib import Path
from typing import Any
DEFAULT_PIECE_KIB = 256
MIN_PIECE_KIB = 16
MAX_PIECE_KIB = 16384
def _bencode(value: Any) -> bytes:
if isinstance(value, bool):
value = int(value)
if isinstance(value, int):
return b"i" + str(value).encode("ascii") + b"e"
if isinstance(value, bytes):
return str(len(value)).encode("ascii") + b":" + value
if isinstance(value, str):
raw = value.encode("utf-8")
return str(len(raw)).encode("ascii") + b":" + raw
if isinstance(value, (list, tuple)):
return b"l" + b"".join(_bencode(item) for item in value) + b"e"
if isinstance(value, dict):
items = []
for key in sorted(value.keys(), key=lambda k: k.encode("utf-8") if isinstance(k, str) else bytes(k)):
bkey = key.encode("utf-8") if isinstance(key, str) else bytes(key)
items.append(_bencode(bkey) + _bencode(value[key]))
return b"d" + b"".join(items) + b"e"
raise TypeError(f"Unsupported bencode value: {type(value)!r}")
def _clean_tracker_lines(raw: str) -> list[str]:
lines = []
seen = set()
for item in str(raw or "").replace("\r", "\n").split("\n"):
url = item.strip()
if not url or url in seen:
continue
seen.add(url)
lines.append(url)
return lines
def _normalize_piece_size(piece_size_kib: int | str | None) -> int:
try:
kib = int(piece_size_kib or DEFAULT_PIECE_KIB)
except Exception:
kib = DEFAULT_PIECE_KIB
kib = max(MIN_PIECE_KIB, min(MAX_PIECE_KIB, kib))
return kib * 1024
def _safe_path_parts(path: Path) -> list[str]:
parts = [part for part in path.parts if part not in {"", ".", ".."}]
if not parts:
raise ValueError("File path inside torrent is empty")
return parts
def _iter_files(source: Path) -> list[tuple[Path, list[str], int]]:
if source.is_file():
return [(source, [source.name], source.stat().st_size)]
if not source.is_dir():
raise ValueError("Source must be an existing file or directory")
rows: list[tuple[Path, list[str], int]] = []
for root, dirs, files in os.walk(source):
dirs[:] = sorted(d for d in dirs if not (Path(root) / d).is_symlink())
for filename in sorted(files):
full = Path(root) / filename
if full.is_symlink() or not full.is_file():
continue
rel = full.relative_to(source)
rows.append((full, _safe_path_parts(rel), full.stat().st_size))
if not rows:
raise ValueError("Source directory does not contain regular files")
return rows
def _piece_hashes(files: list[tuple[Path, list[str], int]], piece_size: int) -> bytes:
pieces = bytearray()
buffer = bytearray()
for full, _parts, _size in files:
with full.open("rb") as handle:
while True:
chunk = handle.read(max(64 * 1024, min(piece_size, 1024 * 1024)))
if not chunk:
break
buffer.extend(chunk)
while len(buffer) >= piece_size:
piece = bytes(buffer[:piece_size])
del buffer[:piece_size]
pieces.extend(hashlib.sha1(piece).digest())
if buffer:
pieces.extend(hashlib.sha1(bytes(buffer)).digest())
return bytes(pieces)
def build_torrent(
source_path: str,
trackers: str = "",
comment: str = "",
source: str = "",
piece_size_kib: int | str | None = DEFAULT_PIECE_KIB,
private: bool = False,
created_by: str = "pyTorrent",
) -> dict[str, Any]:
source_path = str(source_path or "").strip()
if not source_path:
raise ValueError("Source path is required")
path = Path(source_path).expanduser().resolve()
files = _iter_files(path)
piece_size = _normalize_piece_size(piece_size_kib)
info: dict[str, Any] = {
"name": path.name,
"piece length": piece_size,
"pieces": _piece_hashes(files, piece_size),
}
if private:
info["private"] = 1
if source:
info["source"] = str(source).strip()
if path.is_file():
info["length"] = files[0][2]
else:
info["files"] = [{"length": size, "path": parts} for _full, parts, size in files]
tracker_lines = _clean_tracker_lines(trackers)
meta: dict[str, Any] = {
"created by": created_by,
"creation date": int(time.time()),
"info": info,
}
if tracker_lines:
meta["announce"] = tracker_lines[0]
meta["announce-list"] = [[url] for url in tracker_lines]
if comment:
meta["comment"] = str(comment).strip()
data = _bencode(meta)
info_hash = hashlib.sha1(_bencode(info)).hexdigest().upper()
return {
"data": data,
"filename": f"{path.name}.torrent",
"info_hash": info_hash,
"source_parent": str(path.parent),
"file_count": len(files),
"total_size": sum(size for _full, _parts, size in files),
"piece_size": piece_size,
"private": bool(private),
"trackers": tracker_lines,
}

View File

@@ -0,0 +1,150 @@
from __future__ import annotations
import hashlib
from pathlib import PurePosixPath
from typing import Any
class BencodeError(ValueError):
pass
class BencodeReader:
def __init__(self, data: bytes):
self.data = data
self.pos = 0
def parse(self) -> Any:
value = self._read_value()
if self.pos != len(self.data):
raise BencodeError("Trailing data in torrent file")
return value
def _read_value(self) -> Any:
if self.pos >= len(self.data):
raise BencodeError("Unexpected end of bencoded data")
token = self.data[self.pos:self.pos + 1]
if token == b"i":
return self._read_int()
if token == b"l":
return self._read_list()
if token == b"d":
return self._read_dict()
if b"0" <= token <= b"9":
return self._read_bytes()
raise BencodeError(f"Invalid bencode token at offset {self.pos}")
def _read_int(self) -> int:
self.pos += 1
end = self.data.find(b"e", self.pos)
if end < 0:
raise BencodeError("Unterminated integer")
raw = self.data[self.pos:end]
self.pos = end + 1
return int(raw)
def _read_bytes(self) -> bytes:
colon = self.data.find(b":", self.pos)
if colon < 0:
raise BencodeError("Invalid byte string length")
length = int(self.data[self.pos:colon])
self.pos = colon + 1
end = self.pos + length
if end > len(self.data):
raise BencodeError("Byte string exceeds input size")
value = self.data[self.pos:end]
self.pos = end
return value
def _read_list(self) -> list[Any]:
self.pos += 1
out: list[Any] = []
while self.pos < len(self.data) and self.data[self.pos:self.pos + 1] != b"e":
out.append(self._read_value())
if self.pos >= len(self.data):
raise BencodeError("Unterminated list")
self.pos += 1
return out
def _read_dict(self) -> dict[bytes, Any]:
self.pos += 1
out: dict[bytes, Any] = {}
while self.pos < len(self.data) and self.data[self.pos:self.pos + 1] != b"e":
key = self._read_bytes()
out[key] = self._read_value()
if self.pos >= len(self.data):
raise BencodeError("Unterminated dictionary")
self.pos += 1
return out
def bencode(value: Any) -> bytes:
if isinstance(value, int):
return b"i" + str(value).encode("ascii") + b"e"
if isinstance(value, bytes):
return str(len(value)).encode("ascii") + b":" + value
if isinstance(value, str):
raw = value.encode("utf-8")
return str(len(raw)).encode("ascii") + b":" + raw
if isinstance(value, list):
return b"l" + b"".join(bencode(item) for item in value) + b"e"
if isinstance(value, dict):
items = sorted(value.items(), key=lambda item: item[0] if isinstance(item[0], bytes) else str(item[0]).encode("utf-8"))
raw = []
for key, item in items:
raw.append(bencode(key if isinstance(key, bytes) else str(key)))
raw.append(bencode(item))
return b"d" + b"".join(raw) + b"e"
raise TypeError(f"Unsupported bencode type: {type(value)!r}")
def _text(value: Any) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", "replace")
return str(value or "")
def parse_torrent(data: bytes) -> dict:
# Note: The parser is dependency-free so .torrent preview works in offline installations.
root = BencodeReader(data).parse()
if not isinstance(root, dict) or b"info" not in root:
raise BencodeError("Missing torrent info dictionary")
info = root[b"info"]
if not isinstance(info, dict):
raise BencodeError("Invalid torrent info dictionary")
info_hash = hashlib.sha1(bencode(info)).hexdigest().upper()
name = _text(info.get(b"name") or "")
piece_length = int(info.get(b"piece length") or 0)
private = int(info.get(b"private") or 0)
files: list[dict] = []
total = 0
if b"files" in info:
for entry in info.get(b"files") or []:
if not isinstance(entry, dict):
continue
length = int(entry.get(b"length") or 0)
path_parts = [_text(part) for part in entry.get(b"path") or []]
rel_path = str(PurePosixPath(name, *path_parts)) if path_parts else name
total += length
files.append({"path": rel_path, "size": length})
else:
length = int(info.get(b"length") or 0)
total = length
files.append({"path": name, "size": length})
announce = _text(root.get(b"announce") or "")
trackers = [announce] if announce else []
for tier in root.get(b"announce-list") or []:
for tracker in tier if isinstance(tier, list) else [tier]:
value = _text(tracker)
if value and value not in trackers:
trackers.append(value)
return {
"name": name,
"info_hash": info_hash,
"size": total,
"file_count": len(files),
"files": files,
"trackers": trackers,
"piece_length": piece_length,
"private": private,
}

View File

@@ -0,0 +1,209 @@
from __future__ import annotations
import json
import threading
import time
from typing import Any
from ..db import connect, utcnow
from . import rtorrent
from .torrent_cache import torrent_cache
CACHE_SECONDS = 15 * 60
_STARTUP_DELAY_SECONDS = 3 * 60
_STARTED_AT = time.monotonic()
_LOCK = threading.Lock()
_BACKGROUND_LOCK = threading.Lock()
_BACKGROUND_PROFILE_IDS: set[int] = set()
def _human_size(value: int | float) -> str:
size = float(value or 0)
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
if abs(size) < 1024 or unit == "PiB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
size /= 1024
return f"{size:.1f} PiB"
def _empty(profile_id: int, error: str = "") -> dict[str, Any]:
now = utcnow()
return {
"profile_id": profile_id,
"torrent_count": 0,
"complete_count": 0,
"incomplete_count": 0,
"total_torrent_size": 0,
"total_torrent_size_h": _human_size(0),
"total_file_size": 0,
"total_file_size_h": _human_size(0),
"file_count": 0,
"seeds_total": 0,
"peers_total": 0,
"down_rate_total": 0,
"up_rate_total": 0,
"down_rate_total_h": "0 B/s",
"up_rate_total_h": "0 B/s",
"sampled_torrents": 0,
"errors": [],
"error": error,
"created_at": now,
"updated_at": now,
"age_seconds": 0,
"stale": True,
}
def _load_cached(profile_id: int) -> dict[str, Any] | None:
with connect() as conn:
row = conn.execute("SELECT * FROM torrent_stats_cache WHERE profile_id=?", (profile_id,)).fetchone()
if not row:
return None
payload = json.loads(row.get("payload_json") or "{}")
payload["created_at"] = row.get("created_at")
payload["updated_at"] = row.get("updated_at")
try:
payload["age_seconds"] = max(0, int(time.time() - float(row.get("updated_epoch") or 0)))
except Exception:
payload["age_seconds"] = 0
payload["stale"] = payload["age_seconds"] >= CACHE_SECONDS
return payload
def _save(profile_id: int, payload: dict[str, Any]) -> dict[str, Any]:
now = utcnow()
payload = dict(payload)
payload["updated_at"] = now
payload["age_seconds"] = 0
payload["stale"] = False
with connect() as conn:
conn.execute(
"""
INSERT INTO torrent_stats_cache(profile_id,payload_json,created_at,updated_at,updated_epoch)
VALUES(?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET
payload_json=excluded.payload_json,
updated_at=excluded.updated_at,
updated_epoch=excluded.updated_epoch
""",
(profile_id, json.dumps(payload), now, now, time.time()),
)
return payload
def collect(profile: dict) -> dict[str, Any]:
"""Collect heavier torrent/file statistics on demand or every cache window."""
profile_id = int(profile.get("id") or 0)
torrents = rtorrent.list_torrents(profile)
total_torrent_size = sum(int(t.get("size") or 0) for t in torrents)
seeds_total = sum(int(t.get("seeds") or 0) for t in torrents)
peers_total = sum(int(t.get("peers") or 0) for t in torrents)
down_rate_total = sum(int(t.get("down_rate") or 0) for t in torrents)
up_rate_total = sum(int(t.get("up_rate") or 0) for t in torrents)
total_file_size = 0
file_count = 0
errors: list[dict[str, str]] = []
# Note: File metadata is queried per torrent only during cached statistics refresh, not during every UI poll.
for torrent in torrents:
h = str(torrent.get("hash") or "")
if not h:
continue
try:
files = rtorrent.torrent_files(profile, h)
file_count += len(files)
total_file_size += sum(int(f.get("size") or 0) for f in files)
except Exception as exc:
errors.append({"hash": h, "name": str(torrent.get("name") or ""), "error": str(exc)})
torrent_cache.refresh(profile)
payload = {
"profile_id": profile_id,
"torrent_count": len(torrents),
"complete_count": sum(1 for t in torrents if int(t.get("complete") or 0)),
"incomplete_count": sum(1 for t in torrents if not int(t.get("complete") or 0)),
"total_torrent_size": total_torrent_size,
"total_torrent_size_h": _human_size(total_torrent_size),
"total_file_size": total_file_size,
"total_file_size_h": _human_size(total_file_size),
"file_count": file_count,
"seeds_total": seeds_total,
"peers_total": peers_total,
"down_rate_total": down_rate_total,
"up_rate_total": up_rate_total,
"down_rate_total_h": rtorrent.human_rate(down_rate_total),
"up_rate_total_h": rtorrent.human_rate(up_rate_total),
"sampled_torrents": len(torrents),
"errors": errors[:25],
"error": "" if not errors else f"File metadata failed for {len(errors)} torrent(s)",
"created_at": utcnow(),
}
return _save(profile_id, payload)
def get(profile: dict | None, force: bool = False) -> dict[str, Any]:
if not profile:
return _empty(0, "No active rTorrent profile")
profile_id = int(profile.get("id") or 0)
cached = _load_cached(profile_id)
if cached and not force and not cached.get("stale"):
return cached
if cached and not force:
return cached
with _LOCK:
cached = _load_cached(profile_id)
if cached and not force and not cached.get("stale"):
return cached
return collect(profile)
def maybe_refresh(profile: dict | None, force: bool = False) -> dict[str, Any] | None:
if not profile:
return None
if not force and time.monotonic() - _STARTED_AT < _STARTUP_DELAY_SECONDS:
return None
cached = _load_cached(int(profile.get("id") or 0))
if cached and not cached.get("stale") and not force:
return cached
try:
return get(profile, force=True)
except Exception:
return cached
def queue_refresh(socketio, profile: dict | None, force: bool = False, emit_update: bool = True, room: str | None = None) -> dict[str, Any] | None:
"""Schedule heavier statistics refresh outside the main WebSocket/system poller."""
if not profile:
return None
if not force and time.monotonic() - _STARTED_AT < _STARTUP_DELAY_SECONDS:
return _load_cached(int(profile.get("id") or 0))
profile_id = int(profile.get("id") or 0)
cached = _load_cached(profile_id)
if cached and not cached.get("stale") and not force:
return cached
with _BACKGROUND_LOCK:
if profile_id in _BACKGROUND_PROFILE_IDS:
return cached
_BACKGROUND_PROFILE_IDS.add(profile_id)
profile_snapshot = dict(profile)
def runner():
try:
# Note: This can query file metadata per torrent, so it never runs inside the fast CPU/RAM/disk poller.
stats = get(profile_snapshot, force=True)
if emit_update and stats:
payload = {"profile_id": profile_id, "stats": stats}
socketio.emit("torrent_stats_update", payload, to=room) if room else socketio.emit("torrent_stats_update", payload)
except Exception as exc:
if emit_update:
payload = {"profile_id": profile_id, "ok": False, "error": str(exc)}
socketio.emit("torrent_stats_update", payload, to=room) if room else socketio.emit("torrent_stats_update", payload)
finally:
with _BACKGROUND_LOCK:
_BACKGROUND_PROFILE_IDS.discard(profile_id)
socketio.start_background_task(runner)
return cached

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from copy import deepcopy
from threading import RLock
from time import time
SUMMARY_CACHE_TTL_SECONDS = 60
_ERROR_PATTERNS = (
"error",
"failed",
"failure",
"timeout",
"timed out",
"tracker",
"could not",
"cannot",
"refused",
"unreachable",
"denied",
)
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "stopped")
_summary_cache: dict[int, dict] = {}
_summary_lock = RLock()
def _number(row: dict, key: str) -> int:
try:
return int(float(row.get(key) or 0))
except (TypeError, ValueError):
return 0
def _has_error(row: dict) -> bool:
message = str(row.get("message") or "").strip().lower()
return bool(message and any(pattern in message for pattern in _ERROR_PATTERNS))
def _is_checking(row: dict) -> bool:
return str(row.get("status") or "") == "Checking" or _number(row, "hashing") > 0
def _matches(row: dict, summary_type: str) -> bool:
status = str(row.get("status") or "")
checking = _is_checking(row)
if summary_type == "all":
return True
if summary_type == "downloading":
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
if summary_type == "seeding":
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
if summary_type == "paused":
return not checking and (bool(row.get("paused")) or status == "Paused")
if summary_type == "checking":
return checking
if summary_type == "error":
return _has_error(row)
if summary_type == "stopped":
# Note: Stopped count follows the UI filter exactly, so torrents being hash-checked do not inflate an empty Stopped list.
return not checking and not bool(row.get("state"))
return False
def _empty_bucket() -> dict:
return {
"count": 0,
"size": 0,
"disk_bytes": 0,
"completed_bytes": 0,
"remaining_bytes": 0,
"progress_percent": 0.0,
"remaining_percent": 100.0,
# Kept for backward compatibility with older clients; not used by the filters UI.
"down_total": 0,
"up_total": 0,
}
def build_summary(rows: list[dict]) -> dict:
filters = {summary_type: _empty_bucket() for summary_type in _SUMMARY_TYPES}
for row in rows:
for summary_type in _SUMMARY_TYPES:
if not _matches(row, summary_type):
continue
bucket = filters[summary_type]
bucket["count"] += 1
size = _number(row, "size")
completed = min(size, _number(row, "completed_bytes")) if size else _number(row, "completed_bytes")
bucket["size"] += size
bucket["completed_bytes"] += completed
bucket["disk_bytes"] += completed
bucket["down_total"] += _number(row, "down_total")
bucket["up_total"] += _number(row, "up_total")
for bucket in filters.values():
bucket["remaining_bytes"] = max(0, bucket["size"] - bucket["completed_bytes"])
if bucket["size"] > 0:
bucket["progress_percent"] = round((bucket["completed_bytes"] / bucket["size"]) * 100, 1)
bucket["remaining_percent"] = round(100 - bucket["progress_percent"], 1)
else:
bucket["progress_percent"] = 0.0
bucket["remaining_percent"] = 0.0
now = time()
return {
"filters": filters,
"cache_ttl_seconds": SUMMARY_CACHE_TTL_SECONDS,
"generated_at_epoch": now,
"cached": False,
}
def cached_summary(profile_id: int, rows: list[dict], force: bool = False) -> dict:
now = time()
with _summary_lock:
cached = _summary_cache.get(int(profile_id))
rows_count = len(rows or [])
cached_count = int(((cached or {}).get("filters") or {}).get("all", {}).get("count") or 0)
cache_is_fresh = cached and now - float(cached.get("generated_at_epoch") or 0) < SUMMARY_CACHE_TTL_SECONDS
cache_is_usable = cache_is_fresh and not (cached_count == 0 and rows_count > 0)
if not force and cache_is_usable:
result = deepcopy(cached)
result["cached"] = True
return result
result = build_summary(rows or [])
# Do not cache an empty cold-start snapshot. On first connection the cache may be populated
# before rTorrent refresh finishes, which would otherwise show zeros for the full TTL.
if rows_count > 0 or force:
_summary_cache[int(profile_id)] = deepcopy(result)
return result
def invalidate_summary(profile_id: int | None = None) -> None:
with _summary_lock:
if profile_id is None:
_summary_cache.clear()
else:
_summary_cache.pop(int(profile_id), None)

View File

@@ -0,0 +1,440 @@
from __future__ import annotations
import json
import mimetypes
import re
import time
import threading
import ssl
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
from pathlib import Path
from ..config import BASE_DIR
from ..db import connect, utcnow
TRACKER_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
FAVICON_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
TRACKER_SCAN_LIMIT = 80
FAVICON_DIR = BASE_DIR / "data" / "tracker_favicons"
PUBLIC_FAVICON_BASE = "/static/tracker_favicons"
_TRACKER_SCAN_LOCKS: dict[int, threading.Lock] = {}
_TRACKER_SCAN_LOCKS_GUARD = threading.Lock()
class _IconParser(HTMLParser):
def __init__(self):
super().__init__()
self.icons: list[str] = []
def handle_starttag(self, tag: str, attrs):
if tag.lower() != "link":
return
data = {str(k).lower(): str(v or "") for k, v in attrs}
rel = re.sub(r"\s+", " ", data.get("rel", "").lower()).strip()
href = data.get("href", "").strip()
if href and "icon" in rel:
self.icons.append(href)
def _now_epoch() -> float:
return time.time()
def tracker_domain(url: str) -> str:
raw = str(url or "").strip()
if not raw:
return ""
parsed = urllib.parse.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 _root_domain(domain: str) -> str:
parts = [p for p in str(domain or "").lower().strip(".").split(".") if p]
if len(parts) <= 2:
return ".".join(parts)
# Note: Tracker favicon discovery needs the real main site first; for t.pte.nu that is pte.nu, not t.pte.nu.
known_second_level_suffixes = {"co", "com", "net", "org", "gov", "edu", "ac"}
if len(parts[-1]) == 2 and parts[-2] in known_second_level_suffixes and len(parts) >= 3:
return ".".join(parts[-3:])
return ".".join(parts[-2:])
def _safe_filename(domain: str) -> str:
return re.sub(r"[^a-z0-9_.-]+", "_", domain.lower()).strip("._") or "tracker"
def _read_cached(profile_id: int, hashes: list[str], ttl: int) -> tuple[dict[str, list[dict]], set[str]]:
if not hashes:
return {}, set()
now = _now_epoch()
cached: dict[str, list[dict]] = {}
fresh: set[str] = set()
with connect() as conn:
for start in range(0, len(hashes), 900):
chunk = hashes[start:start + 900]
placeholders = ",".join("?" for _ in chunk)
rows = conn.execute(
f"SELECT torrent_hash, trackers_json, updated_epoch FROM tracker_summary_cache WHERE profile_id=? AND torrent_hash IN ({placeholders})",
(profile_id, *chunk),
).fetchall()
for row in rows:
h = str(row.get("torrent_hash") or "")
try:
items = json.loads(row.get("trackers_json") or "[]")
except Exception:
items = []
cached[h] = items if isinstance(items, list) else []
if now - float(row.get("updated_epoch") or 0) < ttl:
fresh.add(h)
return cached, fresh
def _store(profile_id: int, torrent_hash: str, trackers: list[dict]) -> None:
now = utcnow()
epoch = _now_epoch()
compact = []
seen = set()
for item in trackers:
domain = tracker_domain(str(item.get("url") or item.get("domain") or "")) or str(item.get("domain") or "")
if not domain or domain in seen:
continue
seen.add(domain)
compact.append({"domain": domain, "url": str(item.get("url") or "")})
with connect() as conn:
conn.execute(
"""
INSERT INTO tracker_summary_cache(profile_id, torrent_hash, trackers_json, updated_at, updated_epoch)
VALUES(?, ?, ?, ?, ?)
ON CONFLICT(profile_id, torrent_hash) DO UPDATE SET
trackers_json=excluded.trackers_json,
updated_at=excluded.updated_at,
updated_epoch=excluded.updated_epoch
""",
(profile_id, torrent_hash, json.dumps(compact), now, epoch),
)
def summary(profile: dict, hashes: list[str], loader, scan_limit: int = TRACKER_SCAN_LIMIT, include_favicons: bool = False) -> dict:
"""Build tracker sidebar data from disk cache and refresh a small batch per request."""
# Note: Tracker data is cached per torrent hash, so huge rTorrent libraries are never scanned in one UI request.
profile_id = int(profile.get("id") or 0)
clean_hashes = [str(h or "").strip() for h in hashes if str(h or "").strip()]
cached, fresh = _read_cached(profile_id, clean_hashes, TRACKER_CACHE_TTL_SECONDS)
missing = [h for h in clean_hashes if h not in fresh]
errors: list[dict] = []
scanned_now = 0
for h in missing[:max(0, int(scan_limit or 0))]:
try:
trackers = loader(h)
_store(profile_id, h, trackers)
cached[h] = [{"domain": tracker_domain(t.get("url") or t.get("domain") or ""), "url": str(t.get("url") or "")} for t in trackers]
fresh.add(h)
scanned_now += 1
except Exception as exc:
errors.append({"hash": h, "error": str(exc)})
by_hash: dict[str, list[dict]] = {}
counts: dict[str, dict] = {}
for h in clean_hashes:
items = []
seen = set()
for item in cached.get(h, []):
domain = tracker_domain(str(item.get("url") or item.get("domain") or "")) or str(item.get("domain") or "")
if not domain or domain in seen:
continue
seen.add(domain)
row = {"domain": domain, "url": str(item.get("url") or "")}
items.append(row)
bucket = counts.setdefault(domain, {"domain": domain, "url": row["url"], "count": 0})
bucket["count"] += 1
if not bucket.get("url") and row["url"]:
bucket["url"] = row["url"]
by_hash[h] = items
trackers = sorted(counts.values(), key=lambda x: (-int(x.get("count") or 0), str(x.get("domain") or "")))
if include_favicons:
# Note: Summary returns only already cached static favicon URLs; network favicon discovery stays outside the hot tracker count path.
for item in trackers:
item["favicon_url"] = favicon_public_url(str(item.get("domain") or ""), enabled=True, create=False)
pending = max(0, len([h for h in clean_hashes if h not in fresh]))
return {"hashes": by_hash, "trackers": trackers, "errors": errors[:25], "scanned": len(clean_hashes), "scanned_now": scanned_now, "pending": pending, "cached": len(clean_hashes) - pending}
def _scan_lock(profile_id: int) -> threading.Lock:
with _TRACKER_SCAN_LOCKS_GUARD:
if profile_id not in _TRACKER_SCAN_LOCKS:
_TRACKER_SCAN_LOCKS[profile_id] = threading.Lock()
return _TRACKER_SCAN_LOCKS[profile_id]
def warm_summary_cache(profile: dict, hashes: list[str], loader, batch_size: int = TRACKER_SCAN_LIMIT) -> bool:
"""Start a non-blocking tracker cache warmup for large libraries."""
# Note: Tracker cache warming runs in one background thread per profile, so F5 returns cached data immediately instead of waiting for rTorrent scans.
profile_id = int(profile.get("id") or 0)
clean_hashes = [str(h or "").strip() for h in hashes if str(h or "").strip()]
if not profile_id or not clean_hashes:
return False
lock = _scan_lock(profile_id)
if lock.locked():
return False
def _worker():
if not lock.acquire(blocking=False):
return
try:
while True:
result = summary(profile, clean_hashes, loader, scan_limit=max(1, int(batch_size or TRACKER_SCAN_LIMIT)), include_favicons=False)
if int(result.get("pending") or 0) <= 0 or int(result.get("scanned_now") or 0) <= 0:
break
time.sleep(0.05)
finally:
lock.release()
threading.Thread(target=_worker, name=f"tracker-cache-warm-{profile_id}", daemon=True).start()
return True
def favicon_public_url(domain: str, enabled: bool = True, create: bool = False, force: bool = False) -> str:
"""Return the static URL for a cached tracker favicon, optionally creating or refreshing it first."""
# Note: Favicon files stay in data/tracker_favicons, but the browser loads them via the static/tracker_favicons symlink.
clean = tracker_domain(domain)
if not enabled or not clean:
return ""
if create:
favicon_path(clean, enabled=True, force=force)
cached = _cached_favicon(clean)
now = _now_epoch()
if not cached or now - float(cached.get("updated_epoch") or 0) >= FAVICON_CACHE_TTL_SECONDS:
return ""
path = Path(str(cached.get("file_path") or ""))
if not path.exists() or not path.is_file():
return ""
try:
rel = path.resolve().relative_to(FAVICON_DIR.resolve())
except Exception:
rel = Path(path.name)
return f"{PUBLIC_FAVICON_BASE}/{urllib.parse.quote(str(rel).replace(chr(92), '/'))}"
def _fetch(url: str, limit: int = 262144) -> tuple[bytes, str, str]:
# Note: Favicon discovery uses browser-like headers and a certificate fallback, because tracker login pages/CDNs often reject minimal Python requests.
req = urllib.request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0 (compatible; pyTorrent favicon fetcher)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8",
"Connection": "close",
},
)
def _read(context=None):
with urllib.request.urlopen(req, timeout=8, context=context) as resp:
data = resp.read(limit + 1)
if len(data) > limit:
data = data[:limit]
content_type = str(resp.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
final_url = str(resp.geturl() or url)
return data, content_type, final_url
try:
return _read()
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", None)
if isinstance(reason, ssl.SSLError) or "CERTIFICATE_VERIFY_FAILED" in str(exc):
return _read(ssl._create_unverified_context())
raise
def _is_icon(data: bytes, content_type: str, url: str) -> bool:
"""Validate that downloaded bytes are a browser-readable image, not only an image-like HTTP header."""
# Note: Some trackers serve a broken /favicon.ico with image/vnd.microsoft.icon; pyTorrent now validates bytes before caching it.
if not data or len(data) < 16:
return False
head = data[:32]
lower = data[:512].lstrip().lower()
if head.startswith(b"\x00\x00\x01\x00") or head.startswith(b"\x00\x00\x02\x00"):
try:
count = int.from_bytes(data[4:6], "little")
except Exception:
count = 0
return 0 < count <= 256 and len(data) >= 6 + (16 * count)
if head.startswith(b"\x89PNG\r\n\x1a\n"):
return True
if head.startswith(b"\xff\xd8\xff"):
return True
if head.startswith((b"GIF87a", b"GIF89a")):
return True
if head.startswith(b"RIFF") and data[8:12] == b"WEBP":
return True
if lower.startswith(b"<svg") or b"<svg" in lower[:256]:
return True
ctype = content_type.lower()
if ctype in {"image/svg+xml"}:
return b"<svg" in lower[:512]
return False
def _attr_value(tag: str, name: str) -> str:
# Note: Accept quoted and unquoted HTML attributes so favicon discovery works with compact/minified tracker pages.
match = re.search(rf"\b{name}\s*=\s*(['\"])(.*?)\1", tag, re.I | re.S)
if match:
return match.group(2).strip()
match = re.search(rf"\b{name}\s*=\s*([^\s>]+)", tag, re.I | re.S)
return match.group(1).strip().strip("'\"") if match else ""
def _extract_icon_hrefs(html: str) -> list[str]:
# Note: Read any <link rel=...icon... href=...> order, including shortcut icon and relative CDN paths.
hrefs: list[str] = []
parser = _IconParser()
try:
parser.feed(html)
hrefs.extend(parser.icons)
except Exception:
pass
for match in re.finditer(r"<link\b[^>]*>", html, re.I | re.S):
tag = match.group(0)
rel = _attr_value(tag, "rel").lower()
href = _attr_value(tag, "href")
if href and "icon" in rel:
hrefs.append(href)
clean = []
seen = set()
for href in hrefs:
href = str(href or "").strip()
if href and href not in seen:
seen.add(href)
clean.append(href)
return clean
def _tracker_icon_hosts(domain: str) -> list[str]:
host = tracker_domain(domain)
root = _root_domain(host)
# Note: Direct favicon fallback checks the tracker host first, then the main domain.
return [h for h in dict.fromkeys([host, root]) if h]
def _tracker_html_hosts(domain: str) -> list[str]:
host = tracker_domain(domain)
root = _root_domain(host)
# Note: HTML discovery checks the main site first, because tracker announce hosts often return text/plain.
return [h for h in dict.fromkeys([root, host]) if h]
def _favicon_candidates(domain: str) -> list[str]:
candidates = []
for h in _tracker_icon_hosts(domain):
candidates.extend([f"https://{h}/favicon.ico", f"http://{h}/favicon.ico"])
return list(dict.fromkeys(candidates))
def _html_icon_candidates(domain: str, errors: list[str] | None = None) -> list[str]:
urls = []
for h in _tracker_html_hosts(domain):
for scheme in ("https", "http"):
base = f"{scheme}://{h}/"
try:
data, ctype, final_url = _fetch(base, limit=524288)
except Exception as exc:
if errors is not None:
errors.append(f"{base}: {exc}")
continue
lower = data[:4096].lower()
if "html" not in ctype and b"<html" not in lower and b"<link" not in data.lower():
if errors is not None:
errors.append(f"{base}: response is not html ({ctype or 'unknown content-type'})")
continue
html = data.decode("utf-8", errors="ignore")
for href in _extract_icon_hrefs(html):
urls.append(urllib.parse.urljoin(final_url, href))
return list(dict.fromkeys(urls))
def _cached_favicon(domain: str):
clean = tracker_domain(domain)
if not clean:
return None
with connect() as conn:
return conn.execute("SELECT * FROM tracker_favicon_cache WHERE domain=?", (clean,)).fetchone()
def favicon_cache_row(domain: str):
"""Note: Expose the favicon cache row for diagnostics without duplicating SQL in routes or CLI."""
return _cached_favicon(domain)
def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tuple[Path | None, str | None]:
clean = tracker_domain(domain)
if not enabled or not clean:
return None, None
cached = _cached_favicon(clean)
now = _now_epoch()
if cached and not force and now - float(cached.get("updated_epoch") or 0) < FAVICON_CACHE_TTL_SECONDS:
path = Path(str(cached.get("file_path") or ""))
mime = str(cached.get("mime_type") or mimetypes.guess_type(path.name)[0] or "image/x-icon")
if path.exists() and path.is_file():
try:
if _is_icon(path.read_bytes()[:524288], mime, str(cached.get("source_url") or path.name)):
return path, mime
except Exception:
pass
if cached.get("error"):
return None, None
# Note: Favicon lookup checks the main-domain HTML first, then tracker HTML, then direct /favicon.ico fallbacks.
FAVICON_DIR.mkdir(parents=True, exist_ok=True)
errors = []
candidates = _html_icon_candidates(clean, errors) + _favicon_candidates(clean)
candidates = list(dict.fromkeys(candidates))
idx = 0
while idx < len(candidates):
url = candidates[idx]
idx += 1
try:
data, ctype, final_url = _fetch(url, limit=524288)
if not _is_icon(data, ctype, final_url):
errors.append(f"{url}: invalid icon ({ctype or 'unknown content-type'}, {len(data)} bytes)")
continue
ext = Path(urllib.parse.urlparse(final_url).path).suffix.lower() or mimetypes.guess_extension(ctype) or ".ico"
if ext not in {".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp"}:
ext = ".ico"
path = FAVICON_DIR / f"{_safe_filename(clean)}{ext}"
path.write_bytes(data)
mime = ctype if ctype.startswith("image/") else (mimetypes.guess_type(path.name)[0] or "image/x-icon")
with connect() as conn:
conn.execute(
"""
INSERT INTO tracker_favicon_cache(domain, source_url, file_path, mime_type, updated_at, updated_epoch, error)
VALUES(?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(domain) DO UPDATE SET
source_url=excluded.source_url,
file_path=excluded.file_path,
mime_type=excluded.mime_type,
updated_at=excluded.updated_at,
updated_epoch=excluded.updated_epoch,
error=NULL
""",
(clean, final_url, str(path), mime, utcnow(), now),
)
return path, mime
except Exception as exc:
errors.append(f"{url}: {exc}")
# HTML is checked once before direct /favicon.ico probes; do not guess cdn/static/www hosts unless HTML points there.
with connect() as conn:
conn.execute(
"""
INSERT INTO tracker_favicon_cache(domain, source_url, file_path, mime_type, updated_at, updated_epoch, error)
VALUES(?, '', '', '', ?, ?, ?)
ON CONFLICT(domain) DO UPDATE SET
updated_at=excluded.updated_at,
updated_epoch=excluded.updated_epoch,
error=excluded.error
""",
(clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"),
)
return None, None

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
from ..db import connect, utcnow
from . import retention
_LAST_WRITE: dict[int, float] = {}
WRITE_EVERY_SECONDS = 60
def _now_ts() -> float:
return datetime.now(timezone.utc).timestamp()
def record(profile_id: int, down_rate: int = 0, up_rate: int = 0, total_down: int = 0, total_up: int = 0, force: bool = False) -> None:
"""Store compact transfer samples. One sample per minute per profile keeps SQLite small."""
profile_id = int(profile_id)
now_ts = _now_ts()
if not force and now_ts - _LAST_WRITE.get(profile_id, 0.0) < WRITE_EVERY_SECONDS:
return
_LAST_WRITE[profile_id] = now_ts
with connect() as conn:
conn.execute(
"INSERT INTO traffic_history(profile_id,down_rate,up_rate,total_down,total_up,created_at) VALUES(?,?,?,?,?,?)",
(profile_id, int(down_rate or 0), int(up_rate or 0), int(total_down or 0), int(total_up or 0), utcnow()),
)
retention.cleanup()
def _range_to_cutoff(range_name: str) -> datetime:
now = datetime.now(timezone.utc)
if range_name == "15m":
return now - timedelta(minutes=15)
if range_name == "1h":
return now - timedelta(hours=1)
if range_name == "3h":
return now - timedelta(hours=3)
if range_name == "6h":
return now - timedelta(hours=6)
if range_name == "24h":
return now - timedelta(hours=24)
if range_name == "30d":
return now - timedelta(days=30)
if range_name == "90d":
return now - timedelta(days=90)
return now - timedelta(days=7)
def _bucket_for(range_name: str) -> str:
if range_name in {"15m", "1h", "3h"}:
return "%Y-%m-%d %H:%M"
if range_name in {"6h", "24h"}:
return "%Y-%m-%d %H:00"
return "%Y-%m-%d"
def _row_value(row: Any, key: str, index: int, default: Any = 0) -> Any:
# connect() uses dict_factory, so SQLite rows are dicts. The fallback keeps
# this function compatible with tuple/list rows in tests or future refactors.
if isinstance(row, dict):
return row.get(key, default)
try:
return row[index]
except (IndexError, KeyError, TypeError):
return default
def history(profile_id: int, range_name: str = "7d") -> dict[str, Any]:
cutoff = _range_to_cutoff(range_name)
bucket = _bucket_for(range_name)
cutoff_s = cutoff.isoformat(timespec="seconds")
bucket_name = "minute" if range_name in {"15m", "1h", "3h"} else ("hour" if range_name in {"6h", "24h"} else "day")
with connect() as conn:
raw = conn.execute(
"""
SELECT down_rate, up_rate, total_down, total_up, created_at
FROM traffic_history
WHERE profile_id=? AND created_at >= ?
ORDER BY created_at ASC
""",
(int(profile_id), cutoff_s),
).fetchall()
rows_by_bucket: dict[str, dict[str, Any]] = {}
prev_down = prev_up = None
for r in raw:
created = str(_row_value(r, "created_at", 4, ""))
try:
dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
except Exception:
continue
b = dt.strftime(bucket)
item = rows_by_bucket.setdefault(b, {"bucket": b, "avg_down_rate": 0, "avg_up_rate": 0, "downloaded": 0, "uploaded": 0, "samples": 0})
down_rate = int(_row_value(r, "down_rate", 0, 0) or 0)
up_rate = int(_row_value(r, "up_rate", 1, 0) or 0)
total_down = int(_row_value(r, "total_down", 2, 0) or 0)
total_up = int(_row_value(r, "total_up", 3, 0) or 0)
item["avg_down_rate"] += down_rate
item["avg_up_rate"] += up_rate
item["samples"] += 1
if prev_down is not None and total_down >= prev_down:
item["downloaded"] += total_down - prev_down
if prev_up is not None and total_up >= prev_up:
item["uploaded"] += total_up - prev_up
prev_down, prev_up = total_down, total_up
rows = []
for item in rows_by_bucket.values():
samples = max(1, int(item["samples"] or 1))
item["avg_down_rate"] = round(item["avg_down_rate"] / samples)
item["avg_up_rate"] = round(item["avg_up_rate"] / samples)
rows.append(item)
rows.sort(key=lambda x: x["bucket"])
return {"range": range_name, "bucket": bucket_name, "retention_days": TRAFFIC_HISTORY_RETENTION_DAYS, "rows": rows}

View File

@@ -0,0 +1,256 @@
from __future__ import annotations
import threading
import time
import json
import psutil
from flask_socketio import emit, join_room, leave_room, disconnect
from .preferences import active_profile, get_profile
from .torrent_cache import torrent_cache
from .torrent_summary import cached_summary
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner
def _profile_room(profile_id: int) -> str:
return f"profile:{int(profile_id)}"
def _poller_profiles() -> list[dict]:
# Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms.
if not auth.enabled():
profile = active_profile()
return [profile] if profile else []
from ..db import connect
with connect() as conn:
return conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
target = _profile_room(profile_id) if auth.enabled() else None
socketio.emit(event, payload, to=target) if target else socketio.emit(event, payload)
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
emit_profile_event(socketio, event, payload, profile_id)
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
state = poller_control.state_for(profile_id)
try:
try:
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
except Exception as exc:
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try:
result = smart_queue.check(profile, force=False)
if result.get("enabled"):
_emit_profile(socketio, "smart_queue_update", result, profile_id)
if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"):
queue_diff = torrent_cache.refresh(profile)
if queue_diff.get("ok"):
payload = {**queue_diff, "summary": cached_summary(profile_id, torrent_cache.snapshot(profile_id), force=True)}
_emit_profile(socketio, "torrent_patch", payload, profile_id)
except Exception as exc:
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try:
auto_result = automation_rules.check(profile, force=False)
if auto_result.get("applied"):
_emit_profile(socketio, "automation_update", auto_result, profile_id)
except Exception as exc:
_emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try:
plan_result = download_planner.enforce(profile, force=False)
if plan_result.get("enabled") and not plan_result.get("skipped"):
_emit_profile(socketio, "download_plan_update", plan_result, profile_id)
except Exception as exc:
_emit_profile(socketio, "download_plan_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
finally:
state.slow_task_running = False
def _is_active_rows(rows: list[dict]) -> bool:
for row in rows or []:
try:
if int(row.get("state") or 0) and (int(row.get("down_rate") or 0) > 0 or int(row.get("up_rate") or 0) > 0):
return True
except Exception:
continue
return False
def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict:
# Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats.
down_rate = sum(int(row.get("down_rate") or 0) for row in rows or [])
up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
return {
"profile_id": int(profile_id),
"down_rate": down_rate,
"up_rate": up_rate,
"down_rate_h": rtorrent.human_rate(down_rate),
"up_rate_h": rtorrent.human_rate(up_rate),
"speed_peaks": speed_peaks.record(profile_id, down_rate, up_rate),
}
_started = False
_start_lock = threading.Lock()
def register_socketio_handlers(socketio):
def poller():
while True:
loop_started = time.monotonic()
next_sleep = poller_control.MIN_POLL_INTERVAL_SECONDS
for profile in _poller_profiles():
if not profile:
continue
pid = int(profile["id"])
settings = poller_control.get_settings(pid)
state = poller_control.state_for(pid)
now = time.monotonic()
next_sleep = min(next_sleep, poller_control.effective_fast_interval(settings, state))
if not poller_control.should_fast_poll(now, settings, state):
continue
tick_started = time.monotonic()
changed = False
ok = True
error = ""
active = False
emitted_payload_size = 0
rtorrent_call_count = 0
skipped_emissions = 0
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
try:
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_fast_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else None
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
changed = True
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_patch", payload, pid)
elif not diff.get("ok"):
_emit_profile(socketio, "rtorrent_error", diff, pid)
else:
# Note: Speeds and peak records may change even when no torrent rows need repainting.
if speed_status:
payload = {"ok": True, "profile_id": pid, "added": [], "updated": [], "removed": [], "speed_status": speed_status}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_patch", payload, pid)
else:
skipped_emissions += 1
if poller_control.should_system_poll(now, settings, state):
state.last_system_at = now
status = rtorrent.system_status(profile, rows)
rtorrent_call_count += 1
if bool(profile.get("is_remote")):
try:
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
usage = rtorrent.remote_system_usage(profile)
status.update(usage)
status["usage_available"] = True
except Exception as exc:
status["usage_source"] = "rtorrent-remote"
status["usage_available"] = False
status["usage_error"] = str(exc)
else:
status["cpu"] = psutil.cpu_percent(interval=None)
status["ram"] = psutil.virtual_memory().percent
status["usage_source"] = "local"
status["usage_available"] = True
status["profile_id"] = pid
traffic_history.record(pid, status.get("down_rate", 0), status.get("up_rate", 0), status.get("total_down", 0), status.get("total_up", 0))
status["speed_peaks"] = (speed_status or _speed_status_from_rows(pid, rows))["speed_peaks"]
status["poller"] = poller_control.snapshot(pid)
emitted_payload_size += len(json.dumps(status, default=str))
_emit_profile(socketio, "system_stats", status, pid)
if poller_control.should_disk_poll(now, settings, state):
state.last_disk_at = now
if poller_control.should_tracker_poll(now, settings, state):
state.last_tracker_at = now
if poller_control.should_slow_poll(now, settings, state) or poller_control.should_queue_poll(now, settings, state):
state.last_slow_at = now
state.last_queue_at = now
if state.slow_task_running:
skipped_emissions += 1
else:
state.slow_task_running = True
socketio.start_background_task(_run_slow_profile_tasks, socketio, dict(profile), pid)
except Exception as exc:
ok = False
error = str(exc)
_emit_profile(socketio, "rtorrent_error", {"profile_id": pid, "error": error}, pid)
runtime = poller_control.mark_tick(state, tick_started, active=active, ok=ok, error=error, emitted_payload_size=emitted_payload_size, rtorrent_call_count=rtorrent_call_count, skipped_emissions=skipped_emissions, settings=settings)
heartbeat.update({"ok": ok, "error": error, "active": active, "poller": runtime})
if poller_control.should_heartbeat(time.monotonic(), settings, state, changed):
state.last_heartbeat_at = time.monotonic()
_emit_profile(socketio, "heartbeat", heartbeat, pid)
elapsed = time.monotonic() - loop_started
socketio.sleep(max(poller_control.MIN_POLL_INTERVAL_SECONDS, min(10.0, next_sleep - elapsed)))
def ensure_poller_started():
global _started
with _start_lock:
if not _started:
# The poller starts with the app, so Smart Queue, planner and automations work without an open UI.
socketio.start_background_task(poller)
_started = True
ensure_poller_started()
@socketio.on("connect")
def handle_connect():
ensure_poller_started()
if auth.enabled() and not auth.current_user_id():
disconnect()
return False
profile = active_profile()
if profile:
join_room(_profile_room(profile["id"]))
emit("connected", {"ok": True, "profile": profile})
if not profile:
emit("profile_required", {"ok": True, "profiles": []})
return
rows = torrent_cache.snapshot(profile["id"])
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)})
emit("poller_settings", {"settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
emit("download_plan_update", {"settings": download_planner.get_settings(int(profile["id"]))})
@socketio.on("select_profile")
def handle_select_profile(data):
if auth.enabled() and not auth.current_user_id():
disconnect()
return
old_profile = active_profile()
if old_profile:
leave_room(_profile_room(old_profile["id"]))
profile_id = int((data or {}).get("profile_id") or 0)
if not profile_id:
emit("profile_required", {"ok": True, "profiles": []})
return
profile = get_profile(profile_id)
if not profile:
emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"})
return
join_room(_profile_room(profile_id))
diff = torrent_cache.refresh(profile)
rows = torrent_cache.snapshot(profile_id)
emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")})
emit("poller_settings", {"settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
emit("download_plan_update", {"settings": download_planner.get_settings(profile_id)})

View File

@@ -0,0 +1,569 @@
from __future__ import annotations
import json
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from . import rtorrent, auth, disk_guard
from .preferences import get_profile
from ..config import WORKERS
from ..db import connect, utcnow, default_user_id
LIGHT_ACTIONS = {"start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "reannounce", "set_limits"}
WATCHDOG_INTERVAL_SECONDS = 30
_heavy_executor = ThreadPoolExecutor(max_workers=WORKERS, thread_name_prefix="pytorrent-heavy-job")
_light_executor = ThreadPoolExecutor(max_workers=max(4, min(WORKERS, 16)), thread_name_prefix="pytorrent-light-job")
_socketio = None
_heavy_semaphores: dict[int, tuple[int, threading.Semaphore]] = {}
_light_semaphores: dict[int, tuple[int, threading.Semaphore]] = {}
_exclusive_locks: dict[int, threading.Lock] = {}
_active_runners: set[str] = set()
_sem_lock = threading.Lock()
_runner_lock = threading.Lock()
_watchdog_started = False
_watchdog_lock = threading.Lock()
def set_socketio(socketio):
global _socketio
_socketio = socketio
def _emit(name: str, payload: dict):
if not _socketio:
return
profile_id = payload.get("profile_id")
if auth.enabled() and profile_id:
# Note: Job/socket events are sent only to clients joined to the affected profile room.
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
else:
_socketio.emit(name, payload)
def _bounded_int(value, default: int, minimum: int = 1) -> int:
try:
parsed = int(value if value is not None else default)
except (TypeError, ValueError):
parsed = default
return max(minimum, parsed)
def _is_light_action(action_name: str) -> bool:
return str(action_name or "") in LIGHT_ACTIONS
def _profile_heavy_limit(profile: dict) -> int:
return _bounded_int(profile.get("max_parallel_jobs"), 5)
def _profile_light_limit(profile: dict) -> int:
return _bounded_int(profile.get("light_parallel_jobs"), 4)
def _get_sem(profile: dict, light: bool = False) -> threading.Semaphore:
profile_id = int(profile["id"])
limit = _profile_light_limit(profile) if light else _profile_heavy_limit(profile)
registry = _light_semaphores if light else _heavy_semaphores
with _sem_lock:
current = registry.get(profile_id)
if not current or current[0] != limit:
registry[profile_id] = (limit, threading.Semaphore(limit))
return registry[profile_id][1]
def _get_exclusive_lock(profile_id: int) -> threading.Lock:
with _sem_lock:
if profile_id not in _exclusive_locks:
_exclusive_locks[profile_id] = threading.Lock()
return _exclusive_locks[profile_id]
def _job_row(job_id: str):
with connect() as conn:
return conn.execute("SELECT rowid AS _rowid, * FROM jobs WHERE id=?", (job_id,)).fetchone()
def _job_payload(row) -> dict:
try:
return json.loads((row or {}).get("payload_json") or "{}")
except Exception:
return {}
def _is_ordered_job(row) -> bool:
payload = _job_payload(row)
action = str((row or {}).get("action") or "")
# Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work.
return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
def _is_priority_job(row) -> bool:
payload = _job_payload(row)
return bool(payload.get('priority_job') or payload.get('force_job')) or str((row or {}).get('action') or '') == 'set_limits'
def _is_light_job(row) -> bool:
return _is_light_action(str((row or {}).get("action") or ""))
def _has_prior_ordered_jobs(profile_id: int, rowid: int) -> bool:
with connect() as conn:
rows = conn.execute(
"""
SELECT rowid AS _rowid, action, payload_json
FROM jobs
WHERE profile_id=?
AND rowid<?
AND status IN ('pending', 'running')
ORDER BY rowid
""",
(profile_id, rowid),
).fetchall()
return any(_is_ordered_job(row) and not _is_priority_job(row) for row in rows)
def _wait_for_prior_ordered_jobs(job_id: str, profile_id: int, rowid: int) -> bool:
while _has_prior_ordered_jobs(profile_id, rowid):
fresh = _job_row(job_id)
if not fresh or fresh["status"] == "cancelled":
return False
if _is_priority_job(fresh):
return True
time.sleep(0.5)
return True
def _set_job(job_id: str, status: str, error: str = "", result: dict | None = None, started: bool = False, finished: bool = False):
now = utcnow()
fields = ["status=?", "error=?", "updated_at=?"]
values: list = [status, error, now]
if result is not None:
fields.append("result_json=?")
values.append(json.dumps(result))
if started:
fields.append("started_at=?")
values.append(now)
if finished:
fields.append("finished_at=?")
values.append(now)
values.append(job_id)
with connect() as conn:
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=?", values)
def _job_state(row) -> dict:
try:
return json.loads((row or {}).get("state_json") or "{}")
except Exception:
return {}
def _checkpoint_job(job_id: str, state: dict, progress_current: int | None = None, progress_total: int | None = None) -> None:
now = utcnow()
fields = ["state_json=?", "heartbeat_at=?", "updated_at=?"]
values: list = [json.dumps(state), now, now]
if progress_current is not None:
fields.append("progress_current=?")
values.append(int(progress_current))
if progress_total is not None:
fields.append("progress_total=?")
values.append(int(progress_total))
values.append(job_id)
with connect() as conn:
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=? AND status='running'", values)
def _submit_job(job_id: str, action_name: str | None = None):
if action_name is None:
row = _job_row(job_id)
action_name = str((row or {}).get("action") or "")
executor = _light_executor if _is_light_action(str(action_name or "")) else _heavy_executor
executor.submit(_run, job_id)
def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | None = None, max_attempts: int = 2, force: bool = False) -> str:
user_id = user_id or auth.current_user_id() or default_user_id()
job_id = uuid.uuid4().hex
if force:
payload = dict(payload or {})
# Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation.
payload['force_job'] = True
payload['priority_job'] = True
now = utcnow()
progress_total = len((payload or {}).get("hashes") or [])
with connect() as conn:
conn.execute(
"INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now),
)
_emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
_submit_job(job_id, action_name)
return job_id
def _job_event_meta(payload: dict) -> dict:
ctx = payload.get("job_context") or {}
source = str(ctx.get("source") or payload.get("source") or "user")
meta = {"source": source}
if source == "automation":
# Note: Socket operation toasts use this flag so automation notifications respect user preferences.
meta["automation"] = True
meta["source_label"] = str(ctx.get("rule_name") or "automation")
if ctx.get("rule_id") is not None:
meta["rule_id"] = ctx.get("rule_id")
return meta
def _execute(profile: dict, action_name: str, payload: dict):
if action_name == "smart_queue_check":
from . import smart_queue
return smart_queue.check(profile, user_id=auth.current_user_id() or default_user_id(), force=True)
if action_name == "add_magnet":
if bool(payload.get("start", True)):
disk_guard.assert_can_start_download(profile)
return rtorrent.add_magnet(profile, payload["uri"], bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""))
if action_name == "add_torrent_raw":
import base64
raw = base64.b64decode(payload["data_b64"])
if bool(payload.get("start", True)):
disk_guard.assert_can_start_download(profile)
return rtorrent.add_torrent_raw(profile, raw, bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""), payload.get("file_priorities") or None)
if action_name == "set_limits":
return rtorrent.set_limits(profile, payload.get("down"), payload.get("up"))
hashes = payload.get("hashes") or []
if action_name in {"start", "resume", "unpause"}:
disk_guard.assert_can_start_download(profile)
state = payload.get("__resume_state") or {}
def checkpoint(next_state: dict, current: int, total: int):
job_id = payload.get("__job_id")
if job_id:
_checkpoint_job(str(job_id), next_state, current, total)
return rtorrent.action(profile, hashes, action_name, payload, checkpoint=checkpoint, resume_state=state)
def _claim_runner(job_id: str) -> bool:
with _runner_lock:
if job_id in _active_runners:
return False
_active_runners.add(job_id)
return True
def _release_runner(job_id: str) -> None:
with _runner_lock:
_active_runners.discard(job_id)
def _mark_running(job_id: str, attempts: int) -> bool:
now = utcnow()
with connect() as conn:
cur = conn.execute(
"UPDATE jobs SET status='running', attempts=?, started_at=COALESCE(started_at, ?), updated_at=? WHERE id=? AND status='pending'",
(attempts, now, now, job_id),
)
return int(cur.rowcount or 0) == 1
def _run(job_id: str):
if not _claim_runner(job_id):
return
sem = None
ordered_lock = None
try:
job = _job_row(job_id)
if not job or job["status"] == "cancelled":
return
profile = get_profile(int(job["profile_id"]), int(job["user_id"]))
if not profile:
_set_job(job_id, "failed", "rTorrent profile does not exist", finished=True)
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"})
return
profile_id = int(profile["id"])
if _is_ordered_job(job) and not _is_priority_job(job):
if not _wait_for_prior_ordered_jobs(job_id, profile_id, int(job["_rowid"])):
return
ordered_lock = _get_exclusive_lock(profile_id)
ordered_lock.acquire()
sem = _get_sem(profile, light=_is_light_job(job))
sem.acquire()
job = _job_row(job_id)
if not job or job["status"] == "cancelled":
return
payload = json.loads(job.get("payload_json") or "{}")
payload["__job_id"] = job_id
payload["__resume_state"] = _job_state(job)
attempts = int(job.get("attempts") or 0) + 1
if not _mark_running(job_id, attempts):
return
event_meta = _job_event_meta(payload)
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
result = _execute(profile, job["action"], payload)
fresh = _job_row(job_id)
# Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state.
if fresh and fresh["status"] != "running":
return
_set_job(job_id, "done", result=result, finished=True)
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
except Exception as exc:
fresh = _job_row(job_id) or {}
attempts = int(fresh.get("attempts") or 1)
max_attempts = int(fresh.get("max_attempts") or 2)
# Note: Emergency cancel keeps an exception from a cancelled job from moving it back to retry or failed.
if fresh and fresh.get("status") != "running":
return
status = "pending" if attempts < max_attempts else "failed"
_set_job(job_id, status, str(exc), finished=(status == "failed"))
_emit("operation_failed", {"job_id": job_id, "action": job.get("action"), "profile_id": job.get("profile_id"), "hashes": payload.get("hashes") or [], "error": str(exc), **_job_event_meta(payload)})
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts})
if status == "pending":
_submit_job(job_id, job.get("action"))
finally:
if sem:
sem.release()
if ordered_lock:
ordered_lock.release()
_release_runner(job_id)
def _parse_ts(value: str | None) -> float | None:
if not value:
return None
try:
from datetime import datetime
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
except Exception:
return None
def _job_timeout_seconds(profile: dict, row) -> int:
key = "light_job_timeout_seconds" if _is_light_job(row) else "heavy_job_timeout_seconds"
default = 300 if _is_light_job(row) else 7200
return _bounded_int(profile.get(key), default, 30)
def _pending_timeout_seconds(profile: dict) -> int:
return _bounded_int(profile.get("pending_job_timeout_seconds"), 900, 60)
def _timeout_running_jobs() -> None:
now_ts = time.time()
with connect() as conn:
rows = conn.execute("SELECT id,user_id,profile_id,action,started_at FROM jobs WHERE status='running'").fetchall()
for row in rows:
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
if not profile:
continue
started_ts = _parse_ts(row.get("started_at"))
if started_ts is None or now_ts - started_ts < _job_timeout_seconds(profile, row):
continue
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
_set_job(row["id"], "failed", message, finished=True)
_emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"})
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message})
def _resubmit_interrupted_running_jobs() -> None:
now_ts = time.time()
with connect() as conn:
rows = conn.execute("SELECT id,user_id,profile_id,action,heartbeat_at,updated_at FROM jobs WHERE status='running'").fetchall()
for row in rows:
with _runner_lock:
active = row["id"] in _active_runners
if active:
continue
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
if not profile:
continue
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at"))
# Note: After process restart there is no in-memory runner for this job.
# A short grace avoids stealing work from another still-alive Gunicorn worker.
if last_seen_ts is not None and now_ts - last_seen_ts < 90:
continue
with connect() as conn:
cur = conn.execute(
"UPDATE jobs SET status='pending', error=?, updated_at=? WHERE id=? AND status='running'",
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
)
if int(cur.rowcount or 0):
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True})
_submit_job(row["id"], row.get("action"))
def _resubmit_stale_pending_jobs() -> None:
now_ts = time.time()
with connect() as conn:
rows = conn.execute("SELECT id,user_id,profile_id,action,updated_at FROM jobs WHERE status='pending'").fetchall()
for row in rows:
with _runner_lock:
active = row["id"] in _active_runners
if active:
continue
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
if not profile:
continue
updated_ts = _parse_ts(row.get("updated_at"))
if updated_ts is None or now_ts - updated_ts < _pending_timeout_seconds(profile):
continue
with connect() as conn:
conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"]))
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True})
_submit_job(row["id"], row.get("action"))
def _watchdog_loop() -> None:
while True:
try:
_resubmit_interrupted_running_jobs()
_timeout_running_jobs()
_resubmit_stale_pending_jobs()
except Exception:
pass
time.sleep(WATCHDOG_INTERVAL_SECONDS)
def start_watchdog() -> None:
global _watchdog_started
with _watchdog_lock:
if _watchdog_started:
return
_watchdog_started = True
thread = threading.Thread(target=_watchdog_loop, name="pytorrent-job-watchdog", daemon=True)
thread.start()
def _safe_json(value, fallback):
try:
return json.loads(value or "")
except Exception:
return fallback
def _job_summary(row: dict, payload: dict, result: dict) -> str:
ctx = payload.get("job_context") or {}
count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
parts = []
if ctx.get("bulk_label"):
# Note: Shows which generated bulk part is being displayed in the job queue.
parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
if count:
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)")
if ctx.get("target_path"):
parts.append(f"target: {ctx.get('target_path')}")
if ctx.get("remove_data"):
parts.append("remove data")
if ctx.get("move_data"):
parts.append("move data")
if result.get("count") is not None:
parts.append(f"done: {result.get('count')}")
if result.get("errors"):
parts.append(f"errors: {len(result.get('errors') or [])}")
return "; ".join(parts)
def _public_job(row) -> dict:
d = dict(row)
payload = _safe_json(d.get("payload_json"), {})
result = _safe_json(d.get("result_json"), {})
ctx = payload.get("job_context") or {}
d["payload"] = payload
state = _safe_json(d.get("state_json"), {})
d["result"] = result
d["state"] = state
d["progress_current"] = int(d.get("progress_current") or len(state.get("completed_hashes") or []))
d["progress_total"] = int(d.get("progress_total") or len(payload.get("hashes") or []) or result.get("count") or 0)
d["hash_count"] = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
d["is_bulk"] = bool(ctx.get("bulk") or d["hash_count"] > 1)
d["summary"] = _job_summary(d, payload, result)
d["source"] = str(ctx.get("source") or "user")
d["source_label"] = str(ctx.get("rule_name") or ctx.get("source") or "user")
d["is_forced"] = bool(payload.get("force_job") or payload.get("priority_job"))
items = ctx.get("items") or []
if d["is_bulk"]:
d["items_preview"] = ""
else:
d["items_preview"] = ", ".join([str((x or {}).get("name") or (x or {}).get("hash") or "") for x in items[:1] if x])
return d
def _job_scope_sql(writable: bool = False) -> tuple[str, tuple]:
visible = auth.writable_profile_ids() if writable else auth.visible_profile_ids()
if visible is None:
return "", ()
if not visible:
return " WHERE 1=0", ()
placeholders = ",".join("?" for _ in visible)
return f" WHERE profile_id IN ({placeholders})", tuple(visible)
def list_jobs(limit: int = 200, offset: int = 0):
limit = max(1, min(int(limit or 50), 500))
offset = max(0, int(offset or 0))
where, params = _job_scope_sql()
with connect() as conn:
rows = conn.execute(f"SELECT * FROM jobs{where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
total = conn.execute(f"SELECT COUNT(*) AS n FROM jobs{where}", params).fetchone()["n"]
return {"rows": [_public_job(r) for r in rows], "total": total, "limit": limit, "offset": offset}
def cancel_job(job_id: str) -> bool:
row = _job_row(job_id)
if not row or row["status"] not in {"pending", "running"}:
return False
# Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup.
_set_job(job_id, "cancelled", finished=True)
_emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "cancelled"})
return True
def clear_jobs() -> int:
where, params = _job_scope_sql(writable=True)
status_clause = "status NOT IN ('pending', 'running')"
sql = f"DELETE FROM jobs{where} AND {status_clause}" if where else f"DELETE FROM jobs WHERE {status_clause}"
with connect() as conn:
cur = conn.execute(sql, params)
return int(cur.rowcount or 0)
def emergency_clear_jobs() -> int:
# Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list.
now = utcnow()
where, params = _job_scope_sql(writable=True)
status_clause = "status IN ('pending', 'running')"
update_sql = f"UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=?{where} AND {status_clause}" if where else "UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=? WHERE status IN ('pending', 'running')"
with connect() as conn:
conn.execute(update_sql, (now, now, *params) if where else (now, now))
cur = conn.execute(f"DELETE FROM jobs{where}", params) if where else conn.execute("DELETE FROM jobs")
deleted = int(cur.rowcount or 0)
_emit("job_update", {"status": "cleared", "emergency": True})
return deleted
def force_job(job_id: str) -> bool:
row = _job_row(job_id)
if not row or row['status'] != 'pending':
return False
payload = _job_payload(row)
payload['force_job'] = True
payload['priority_job'] = True
with connect() as conn:
conn.execute("UPDATE jobs SET payload_json=?, updated_at=? WHERE id=?", (json.dumps(payload), utcnow(), job_id))
_emit('job_update', {'id': job_id, 'profile_id': row.get('profile_id'), 'status': 'pending', 'forced': True})
_submit_job(job_id, row.get('action'))
return True
def retry_job(job_id: str) -> bool:
row = _job_row(job_id)
if not row or row["status"] not in {"failed", "cancelled"}:
return False
with connect() as conn:
conn.execute("UPDATE jobs SET status='pending', error='', finished_at=NULL, state_json=NULL, progress_current=0, heartbeat_at=NULL, updated_at=? WHERE id=?", (utcnow(), job_id))
_emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "pending"})
_submit_job(job_id, row.get("action"))
return True