393 lines
15 KiB
Python
393 lines
15 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():
|
|
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:
|
|
cur = conn.execute("DELETE FROM smart_queue_history")
|
|
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():
|
|
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 automation logs, not saved automation rules.
|
|
cur = conn.execute("DELETE FROM automation_history")
|
|
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")
|
|
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")
|
|
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': []}})
|
|
|