Files
pyTorrent/pytorrent/routes/system.py
Mateusz Gruszczyński 514482f0b5 changes in db
2026-05-26 09:38:12 +02:00

402 lines
16 KiB
Python

from __future__ import annotations
from ._shared import *
from ..services import operation_logs
@bp.get("/system/disk")
def system_disk():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"})
try:
return ok({"disk": _user_disk_status(profile)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@bp.get("/system/status")
def system_status():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"})
try:
status = rtorrent.system_status(profile)
status["disk"] = _user_disk_status(profile)
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
# Note: REST status returns the latest records without waiting for the next Socket.IO message.
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
return ok({"status": status})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@bp.get("/health")
def health_check():
# Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring.
try:
with connect() as conn:
conn.execute("SELECT 1").fetchone()
return ok({"status": "ok"})
except Exception as exc:
return jsonify({"ok": False, "status": "error", "error": str(exc)}), 500
@bp.get("/health/nagios")
def health_check_nagios():
# Note: Plain-text response is compatible with simple Nagios check_http probes.
try:
with connect() as conn:
conn.execute("SELECT 1").fetchone()
return "OK - pyTorrent API healthy\n", 200, {"Content-Type": "text/plain; charset=utf-8"}
except Exception as exc:
return f"CRITICAL - pyTorrent API unhealthy: {exc}\n", 500, {"Content-Type": "text/plain; charset=utf-8"}
@bp.get("/app/status")
def app_status():
started = time.perf_counter()
profile = preferences.active_profile()
proc = psutil.Process(os.getpid())
try:
jobs = list_jobs(10, 0)
jobs_total = jobs.get("total", 0)
except Exception:
jobs_total = 0
status = {
"pytorrent": {
"ok": True,
"pid": os.getpid(),
"uptime_seconds": round(time.time() - proc.create_time(), 1),
"memory_rss": proc.memory_info().rss,
"memory_rss_h": rtorrent.human_size(proc.memory_info().rss),
"threads": proc.num_threads(),
"cpu_percent": proc.cpu_percent(interval=None),
"jobs_total": jobs_total,
"python": platform.python_version(),
"platform": platform.platform(),
"executable": sys.executable,
"worker_threads": WORKERS,
"open_files": _safe_len(proc.open_files) if hasattr(proc, "open_files") else None,
"connections": _safe_len(lambda: proc.net_connections(kind="inet")) if hasattr(proc, "net_connections") else None,
},
"cleanup": cleanup_summary(),
"profile": profile,
"scgi": None,
}
if profile:
try:
status["scgi"] = rtorrent.scgi_diagnostics(profile)
except Exception as exc:
status["scgi"] = {"ok": False, "error": str(exc), "url": profile.get("scgi_url")}
try:
# Note: The diagnostics panel shows the same DL/UL records as the footer.
status["speed_peaks"] = speed_peaks.current(profile["id"])
except Exception as exc:
status["speed_peaks"] = {"error": str(exc)}
try:
prefs = preferences.get_preferences()
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
except Exception as exc:
status["port_check"] = {"status": "error", "error": str(exc)}
status["api_ms"] = round((time.perf_counter() - started) * 1000, 2)
return ok({"status": status})
@bp.get("/port-check")
def port_check_get():
prefs = preferences.get_preferences()
if not bool((prefs or {}).get("port_check_enabled")):
return ok({"port_check": {"status": "disabled", "enabled": False}})
return ok({"port_check": port_check_status(force=False)})
@bp.post("/port-check")
def port_check_post():
return ok({"port_check": port_check_status(force=True)})
@bp.get("/jobs")
def jobs_list():
limit = int(request.args.get("limit", 50))
offset = int(request.args.get("offset", 0))
data = list_jobs(limit, offset)
return ok({"jobs": data["rows"], "total": data["total"], "limit": data["limit"], "offset": data["offset"]})
@bp.post("/jobs/clear")
def jobs_clear():
if str(request.args.get("force") or "").lower() in {"1", "true", "yes"}:
# Note: Emergency cleanup keeps the endpoint behavior unchanged, while force=1 enables rescue mode.
deleted = emergency_clear_jobs()
return ok({"deleted": deleted, "emergency": True})
deleted = clear_jobs()
return ok({"deleted": deleted, "emergency": False})
@bp.get("/cleanup/summary")
def cleanup_status():
return ok({"cleanup": cleanup_summary()})
@bp.post("/cleanup/cache")
def cleanup_profile_cache():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
deleted: dict[str, int | dict] = {}
# Note: Profile cache cleanup removes derived cache only. Torrents, preferences, rules and history stay intact.
deleted["torrent_cache_rows"] = torrent_cache.clear_profile(profile_id)
try:
from ..services.torrent_summary import invalidate_summary
invalidate_summary(profile_id)
deleted["torrent_summary"] = 1
except Exception:
deleted["torrent_summary"] = 0
try:
runtime = rtorrent.clear_profile_runtime_caches(profile_id)
except Exception as exc:
runtime = {"error": str(exc)}
deleted["runtime"] = runtime
with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='torrent_stats_cache'").fetchone()
deleted["torrent_stats_cache"] = int((conn.execute("DELETE FROM torrent_stats_cache WHERE profile_id=?", (profile_id,)).rowcount if exists else 0) or 0)
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tracker_summary_cache'").fetchone()
deleted["tracker_summary_cache"] = int((conn.execute("DELETE FROM tracker_summary_cache WHERE profile_id=?", (profile_id,)).rowcount if exists else 0) or 0)
conn.execute("DELETE FROM app_settings WHERE key LIKE ?", (f"port_check:{profile_id}:%",))
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@bp.post("/cleanup/jobs")
def cleanup_jobs():
deleted = clear_jobs()
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@bp.post("/cleanup/smart-queue")
def cleanup_smart_queue():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
if not exists:
deleted = 0
else:
# Note: Cleanup is limited to the active profile so read/write permissions never affect other profiles.
cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (profile_id,))
deleted = int(cur.rowcount or 0)
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@bp.post("/cleanup/operation-logs")
def cleanup_operation_logs():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
deleted = operation_logs.clear(int(profile["id"]))
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@bp.post("/cleanup/planner")
def cleanup_planner():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
deleted = download_planner.clear_history(int(profile["id"]))
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@bp.post("/cleanup/automations")
def cleanup_automations():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
if not exists:
deleted = 0
else:
# Note: Cleanup panel removes only active profile automation logs, not saved automation rules.
cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (profile_id,))
deleted = int(cur.rowcount or 0)
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@bp.post("/cleanup/all")
def cleanup_all():
deleted_jobs = clear_jobs()
active_profile = preferences.active_profile()
active_profile_id = int(active_profile["id"]) if active_profile else 0
deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
if not exists:
deleted_smart = 0
else:
cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (active_profile_id,))
deleted_smart = int(cur.rowcount or 0)
exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
if not exists_auto:
deleted_auto = 0
else:
cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (active_profile_id,))
deleted_auto = int(cur.rowcount or 0)
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
@bp.post("/jobs/<job_id>/cancel")
def jobs_cancel(job_id: str):
require_profile_write(_job_profile_id(job_id))
if not cancel_job(job_id):
return jsonify({"ok": False, "error": "Only unfinished jobs can be cancelled"}), 400
return ok({"emergency": True})
@bp.post("/jobs/<job_id>/force")
def jobs_force(job_id: str):
require_profile_write(_job_profile_id(job_id))
if not force_job(job_id):
return jsonify({"ok": False, "error": "Only pending jobs can be forced"}), 400
return ok({"job_id": job_id})
@bp.post("/jobs/<job_id>/retry")
def jobs_retry(job_id: str):
require_profile_write(_job_profile_id(job_id))
if not retry_job(job_id):
return jsonify({"ok": False, "error": "Only failed or cancelled jobs can be retried"}), 400
return ok()
@bp.get("/path/default")
def path_default():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
return ok({"path": rtorrent.default_download_path(profile)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/path/browse")
def path_browse():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
base = request.args.get("path") or ""
try:
return ok(rtorrent.browse_path(profile, base))
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get('/rtorrent-config')
def rtorrent_config_get():
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
return ok({'config': rtorrent.get_config(profile)})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
@bp.post('/rtorrent-config')
def rtorrent_config_save():
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
data = request.get_json(silent=True) or {}
result = rtorrent.set_config(profile, data.get('values') or {}, bool(data.get('apply_now', True)), bool(data.get('apply_on_start')), data.get('clear_keys') or [])
if not result.get('ok'):
return jsonify({'ok': False, 'error': 'Some settings were not saved', 'result': result}), 400
return ok({'result': result})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
@bp.post('/rtorrent-config/reset')
def rtorrent_config_reset():
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
# Note: This clears only pyTorrent-saved interface overrides and then reloads live rTorrent values.
return ok({'config': rtorrent.reset_config_overrides(profile)})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 400
@bp.post('/rtorrent-config/generate')
def rtorrent_config_generate():
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
data = request.get_json(silent=True) or {}
return ok({'config_text': rtorrent.generate_config_text(data.get('values') or {})})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
@bp.get('/traffic/history')
def traffic_history_get():
from ..services import traffic_history
profile = preferences.active_profile()
if not profile:
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
range_name = request.args.get('range') or '7d'
if range_name not in {'15m', '1h', '3h', '6h', '24h', '7d', '30d', '90d'}:
range_name = '7d'
try:
try:
from ..services import rtorrent
status = rtorrent.system_status(profile)
traffic_history.record(profile['id'], status.get('down_rate', 0), status.get('up_rate', 0), status.get('total_down', 0), status.get('total_up', 0), force=True)
except Exception:
pass
return ok({'history': traffic_history.history(profile['id'], range_name)})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc), 'history': {'range': range_name, 'rows': []}})