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

407
pytorrent/routes/_shared.py Normal file
View File

@@ -0,0 +1,407 @@
from __future__ import annotations
import base64
import os
import platform
import sys
import time
import re
from datetime import datetime, timezone
import urllib.request
import urllib.parse
import socket
import json
import psutil
import zipfile
import tempfile
import queue
import threading
from pathlib import Path
from urllib.parse import quote
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
from ..db import connect, utcnow
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner
from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
from ..services.geoip import lookup_ip
from ..services.torrent_meta import parse_torrent
bp = Blueprint("api", __name__, url_prefix="/api")
MOVE_BULK_MAX_HASHES = 100
from .auth_api import register_auth_routes
register_auth_routes(bp)
def _job_profile_id(job_id: str) -> int | None:
with connect() as conn:
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
return int(row.get("profile_id") or 0) if row else None
def ok(payload=None):
data = {"ok": True}
if payload:
data.update(payload)
return jsonify(data)
PORT_CHECK_CACHE_SECONDS = 6 * 60 * 60
def _app_setting_get(key: str):
with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
return row.get("value") if row else None
def _app_setting_set(key: str, value: str):
with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, value))
def _iso_from_epoch(value) -> str | None:
try:
return datetime.fromtimestamp(float(value), timezone.utc).isoformat(timespec="seconds")
except Exception:
return None
def _public_ip(profile: dict | None = None, force: bool = False) -> str:
if profile and bool(profile.get("is_remote")):
return rtorrent.remote_public_ip(profile, force=force)
req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": "pyTorrent/port-check"})
with urllib.request.urlopen(req, timeout=8) as res:
return res.read(64).decode("utf-8", "replace").strip()
MAX_PORT_CHECK_CANDIDATES = 256
def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
"""Return valid incoming port candidates from rTorrent network.port_range.
Note: rTorrent may keep a range/list and pick a random port on start.
The old checker used only the first number, which produced false "closed"
results when another configured port was actually active.
"""
ports: list[int] = []
seen: set[int] = set()
truncated = False
def add(port: int) -> None:
nonlocal truncated
if not 1 <= port <= 65535 or port in seen:
return
if len(ports) >= limit:
truncated = True
return
seen.add(port)
ports.append(port)
for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
a, b = int(start), int(end)
if a > b:
a, b = b, a
for port in range(a, b + 1):
add(port)
if truncated:
break
without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
for item in re.findall(r"\d{1,5}", without_ranges):
add(int(item))
return ports, truncated
def _incoming_ports(profile: dict) -> dict:
try:
raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
except Exception:
raw_value = ""
ports, truncated = _parse_port_candidates(raw_value)
return {"ports": ports, "raw": raw_value, "truncated": truncated}
def _yougetsignal_check(public_ip: str, port: int) -> dict:
body = urllib.parse.urlencode({"remoteAddress": public_ip, "portNumber": str(port)}).encode("utf-8")
req = urllib.request.Request(
"https://ports.yougetsignal.com/check-port.php",
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"User-Agent": "pyTorrent/port-check",
"Accept": "text/html,application/json,*/*",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=12) as res:
text = res.read(8192).decode("utf-8", "replace")
low = text.lower()
if "is open" in low:
return {"status": "open", "source": "yougetsignal", "raw": text[:500]}
if "is closed" in low:
return {"status": "closed", "source": "yougetsignal", "raw": text[:500]}
return {"status": "unknown", "source": "yougetsignal", "raw": text[:500]}
def _local_port_fallback(public_ip: str, port: int) -> dict:
try:
with socket.create_connection((public_ip, port), timeout=3):
return {"status": "open", "source": "local-fallback"}
except Exception as exc:
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
checked: list[int] = []
first_closed: dict | None = None
last_result: dict = {"status": "unknown"}
for port in ports:
checked.append(port)
current = checker(public_ip, port)
last_result = current
if current.get("status") == "open":
current.update({"port": port, "open_port": port, "checked_ports": checked})
return current
if current.get("status") == "closed" and first_closed is None:
first_closed = current
result = first_closed or last_result
result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
return result
def port_check_status(force: bool = False) -> dict:
profile = preferences.active_profile()
prefs = preferences.get_preferences()
enabled = bool((prefs or {}).get("port_check_enabled"))
if not profile:
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
port_info = _incoming_ports(profile)
ports = port_info["ports"]
if not ports:
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
ports_key = ",".join(str(port) for port in ports)
cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
if not force:
cached = _app_setting_get(cache_key)
if cached:
try:
data = json.loads(cached)
if time.time() - float(data.get("checked_at_epoch") or 0) < PORT_CHECK_CACHE_SECONDS:
data["cached"] = True
data["enabled"] = enabled
if not data.get("checked_at"):
data["checked_at"] = _iso_from_epoch(data.get("checked_at_epoch"))
return data
except Exception:
pass
checked_at_epoch = time.time()
result = {
"status": "unknown",
"enabled": enabled,
"port": ports[0],
"ports": ports,
"port_range": port_info["raw"],
"ports_truncated": port_info["truncated"],
"checked_at_epoch": checked_at_epoch,
"checked_at": _iso_from_epoch(checked_at_epoch),
"cached": False,
}
try:
public_ip = _public_ip(profile, force=force)
result["public_ip"] = public_ip
result["remote"] = bool(profile.get("is_remote"))
result.update(_check_ports(public_ip, ports, _yougetsignal_check))
except Exception as exc:
result["error"] = f"YouGetSignal failed: {exc}"
try:
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
result["public_ip"] = public_ip
result["remote"] = bool(profile.get("is_remote"))
result.update(_check_ports(public_ip, ports, _local_port_fallback))
except Exception as fallback_exc:
result["fallback_error"] = str(fallback_exc)
result["source"] = "none"
_app_setting_set(cache_key, json.dumps(result))
return result
def _safe_len(callable_obj) -> int | None:
try:
return len(callable_obj())
except Exception:
return None
def _table_count(table: str, where: str = "", params: tuple = ()) -> int:
with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
if not exists:
return 0
row = conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
return int((row or {}).get("n") or 0)
def _db_size() -> dict:
try:
size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size)}
except Exception as exc:
return {"path": str(DB_PATH), "size": 0, "size_h": "0 B", "error": str(exc)}
def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
profile = preferences.active_profile() if profile_id is None else {"id": profile_id}
profile_id = int((profile or {}).get("id") or 0)
if not profile_id:
return {"profile_id": 0, "profile_rows": 0, "runtime_items": 0}
tracker_rows = _table_count("tracker_summary_cache", "WHERE profile_id=?", (profile_id,))
stats_rows = _table_count("torrent_stats_cache", "WHERE profile_id=?", (profile_id,))
runtime_items = 0
try:
runtime_items += len(torrent_cache.snapshot(profile_id))
except Exception:
pass
return {"profile_id": profile_id, "profile_rows": tracker_rows + stats_rows, "tracker_rows": tracker_rows, "torrent_stats_rows": stats_rows, "runtime_items": runtime_items}
def cleanup_summary() -> dict:
return {
"jobs_total": _table_count("jobs"),
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
"smart_queue_history_total": _table_count("smart_queue_history"),
"automation_history_total": _table_count("automation_history"),
"planner_history_total": download_planner.history_count(int((preferences.active_profile() or {}).get("id") or 0)) if preferences.active_profile() else 0,
"cache": _active_profile_cache_summary(),
"retention_days": {
"jobs": JOBS_RETENTION_DAYS,
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
"automation_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
"planner_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
},
"database": _db_size(),
}
def active_default_download_path(profile: dict | None) -> str:
if not profile:
return ""
try:
return rtorrent.default_download_path(profile)
except Exception:
return ""
def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict:
payload = dict(data or {})
hashes = payload.get("hashes") or []
if isinstance(hashes, str):
hashes = [hashes]
hashes = [str(h) for h in hashes if h]
payload["hashes"] = hashes
payload["job_context"] = {
"source": "api",
"action": action_name,
"bulk": len(hashes) > 1,
"hash_count": len(hashes),
"requested_at": utcnow(),
}
if hashes:
try:
by_hash = {str(t.get("hash")): t for t in torrent_cache.snapshot(profile["id"])}
payload["job_context"]["items"] = [
{
"hash": h,
"name": str((by_hash.get(h) or {}).get("name") or ""),
"path": str((by_hash.get(h) or {}).get("path") or ""),
}
for h in hashes
]
except Exception as exc:
payload["job_context"]["items_error"] = str(exc)
if action_name == "move":
payload["job_context"]["target_path"] = str(payload.get("path") or "")
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
if action_name == "remove":
payload["job_context"]["remove_data"] = bool(payload.get("remove_data"))
return payload
def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]:
# Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable.
safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES))
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]:
# Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions.
base_payload = enrich_bulk_payload(profile, action_name, data)
hashes = base_payload.get("hashes") or []
chunks = _chunk_hashes(hashes)
if len(chunks) <= 1:
job_id = enqueue(action_name, profile["id"], base_payload)
return [{"job_id": job_id, "label": "bulk-1", "part": 1, "parts": 1, "hashes": hashes, "hash_count": len(hashes)}]
jobs = []
items_by_hash = {str(item.get("hash")): item for item in (base_payload.get("job_context") or {}).get("items") or []}
for index, chunk in enumerate(chunks, start=1):
payload = dict(base_payload)
payload["hashes"] = chunk
context = dict(base_payload.get("job_context") or {})
context.update({
"bulk": True,
"bulk_label": f"bulk-{index}",
"bulk_part": index,
"bulk_parts": len(chunks),
"hash_count": len(chunk),
"parent_hash_count": len(hashes),
"items": [items_by_hash[h] for h in chunk if h in items_by_hash],
})
payload["job_context"] = context
job_id = enqueue(action_name, profile["id"], payload)
jobs.append({"job_id": job_id, "label": context["bulk_label"], "part": index, "parts": len(chunks), "hashes": chunk, "hash_count": len(chunk)})
return jobs
def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]:
# Note: Keep the old public move helper while using the same partitioning logic.
return enqueue_bulk_parts(profile, "move", data)
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]:
# Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load.
return enqueue_bulk_parts(profile, "remove", data)
def _user_disk_status(profile: dict) -> dict:
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
try:
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
except Exception:
paths = []
return rtorrent.disk_usage_for_paths(
profile,
paths,
(prefs or {}).get("disk_monitor_mode") or "default",
(prefs or {}).get("disk_monitor_selected_path") or "",
)
# Note: Route modules import shared helpers with wildcard imports; include private helper names intentionally.
__all__ = [name for name in globals() if not name.startswith('__')]

14
pytorrent/routes/api.py Normal file
View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from ._shared import bp
# Note: Route modules are imported for their decorators; this keeps the public API unchanged.
from . import torrents as _torrents_routes
from . import profiles as _profiles_routes
from . import rss as _rss_routes
from . import automations as _automations_routes
from . import smart_queue as _smart_queue_routes
from . import system as _system_routes
from . import backup as _backup_routes
__all__ = ["bp"]

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
from flask import abort, jsonify, request
from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, list_api_tokens, create_api_token, revoke_api_token
def _ok(payload=None):
data = {"ok": True}
if payload:
data.update(payload)
return jsonify(data)
def register_auth_routes(bp):
@bp.post("/auth/login")
def auth_login():
if not auth_enabled():
abort(404)
data = request.get_json(silent=True) or {}
user = login_user(str(data.get("username") or ""), str(data.get("password") or ""))
if not user:
return jsonify({"ok": False, "error": "Invalid username or password"}), 401
return _ok({"user": user, "auth_enabled": auth_enabled()})
@bp.get("/auth/me")
def auth_me():
if not auth_enabled():
abort(404)
return _ok({"user": current_user(), "auth_enabled": auth_enabled()})
@bp.post("/auth/logout")
def auth_logout():
if not auth_enabled():
abort(404)
logout_user()
return _ok()
@bp.get("/auth/users")
def auth_users_list():
if not auth_enabled():
abort(404)
return _ok({"users": list_users()})
@bp.post("/auth/users")
def auth_users_create():
if not auth_enabled():
abort(404)
try:
return _ok({"user": save_user(request.get_json(silent=True) or {})})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.put("/auth/users/<int:user_id>")
def auth_users_update(user_id: int):
if not auth_enabled():
abort(404)
try:
return _ok({"user": save_user(request.get_json(silent=True) or {}, user_id)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.delete("/auth/users/<int:user_id>")
def auth_users_delete(user_id: int):
if not auth_enabled():
abort(404)
try:
delete_user(user_id)
return _ok()
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/auth/users/<int:user_id>/tokens")
def auth_user_tokens_list(user_id: int):
if not auth_enabled():
abort(404)
return _ok({"tokens": list_api_tokens(user_id)})
@bp.post("/auth/users/<int:user_id>/tokens")
def auth_user_tokens_create(user_id: int):
if not auth_enabled():
abort(404)
try:
data = request.get_json(silent=True) or {}
return _ok({"token": create_api_token(user_id, str(data.get("name") or "API token"))})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.delete("/auth/users/<int:user_id>/tokens/<int:token_id>")
def auth_user_tokens_delete(user_id: int, token_id: int):
if not auth_enabled():
abort(404)
try:
revoke_api_token(user_id, token_id)
return _ok({"tokens": list_api_tokens(user_id)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400

View File

@@ -0,0 +1,116 @@
from __future__ import annotations
from ._shared import *
@bp.get('/automations')
def automations_get():
from ..services import automation_rules
profile = preferences.active_profile()
if not profile:
return ok({'rules': [], 'history': [], 'error': 'No profile'})
try:
return ok({'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500
@bp.get('/automations/export')
def automations_export():
from ..services import automation_rules
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
# Note: JSON export is profile-scoped and excludes execution history/cooldown state.
data = automation_rules.export_rules(profile['id'])
return ok({'export': data, 'count': len(data.get('rules') or [])})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 400
@bp.post('/automations/import')
def automations_import():
from ..services import automation_rules
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
payload = request.get_json(silent=True) or {}
replace = str(request.args.get('replace') or '').lower() in {'1', 'true', 'yes'} or bool(payload.get('replace')) if isinstance(payload, dict) else False
# Note: Import appends rules by default, so existing automations remain untouched.
imported = automation_rules.import_rules(profile['id'], payload, replace=replace)
return ok({'imported': len(imported), 'rules': automation_rules.list_rules(profile['id'])})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 400
@bp.post('/automations')
def automations_save():
from ..services import automation_rules
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
rule = automation_rules.save_rule(profile['id'], request.get_json(silent=True) or {})
return ok({'rule': rule, 'rules': automation_rules.list_rules(profile['id'])})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 400
@bp.delete('/automations/<int:rule_id>')
def automations_delete(rule_id: int):
from ..services import automation_rules
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
automation_rules.delete_rule(rule_id, profile['id'])
return ok({'rules': automation_rules.list_rules(profile['id'])})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 400
@bp.post('/automations/<int:rule_id>/run')
def automations_run_rule(rule_id: int):
from ..services import automation_rules
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
# Note: Single-rule run ignores disabled state and cooldown for manual troubleshooting.
return ok({'result': automation_rules.check(profile, force=True, rule_id=rule_id), 'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
@bp.post('/automations/check')
def automations_check():
from ..services import automation_rules
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
# Note: Force check ignores disabled state and cooldown, allowing a one-off manual automation pass.
return ok({'result': automation_rules.check(profile, force=True), 'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
@bp.delete('/automations/history')
def automations_history_clear():
from ..services import automation_rules
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
# Note: Clear only automation execution logs; rules and cooldown state stay unchanged.
deleted = automation_rules.clear_history(profile['id'])
return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id']), 'cleanup': cleanup_summary()})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from ._shared import *
@bp.get("/backup")
def backup_list():
return ok({"backups": backup_service.list_backups(default_user_id()), "auto": backup_service.get_auto_backup_settings(default_user_id())})
@bp.post("/backup")
def backup_create():
data = request.get_json(silent=True) or {}
return ok({"backup": backup_service.create_backup(str(data.get("name") or "Manual backup"), default_user_id()), "backups": backup_service.list_backups(default_user_id())})
@bp.get("/backup/settings")
def backup_settings_get():
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())})
@bp.post("/backup/settings")
def backup_settings_save():
data = request.get_json(silent=True) or {}
try:
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/backup/<int:backup_id>/preview")
def backup_preview(backup_id: int):
try:
return ok({"preview": backup_service.preview_backup(backup_id, default_user_id())})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/backup/<int:backup_id>/restore")
def backup_restore(backup_id: int):
try:
return ok({"result": backup_service.restore_backup(backup_id, default_user_id())})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.delete("/backup/<int:backup_id>")
def backup_delete(backup_id: int):
try:
return ok({"result": backup_service.delete_backup(backup_id, default_user_id())})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/backup/<int:backup_id>/download")
def backup_download(backup_id: int):
try:
payload = backup_service.payload_for_backup(backup_id, default_user_id())
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8")
json.dump(payload, tmp, ensure_ascii=False, indent=2)
tmp.close()
return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-backup-{backup_id}.json")
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400

79
pytorrent/routes/main.py Normal file
View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from pathlib import Path
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file
from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
from ..services import auth
from ..services.frontend_assets import asset_path
# for favicon
from flask import current_app, send_from_directory
bp = Blueprint("main", __name__)
def _asset_url(key: str) -> str:
path = asset_path(key)
return path if path.startswith("http") else url_for("static", filename=path)
@bp.get("/favicon.ico")
def favicon_ico():
response = send_from_directory(
current_app.static_folder,
"favicon.svg",
mimetype="image/svg+xml",
)
return response
@bp.route("/login", methods=["GET", "POST"])
def login():
# Note: When optional authentication is disabled, /login is intentionally unavailable.
if not auth.enabled():
abort(404)
error = ""
if request.method == "POST":
user = auth.login_user(request.form.get("username", ""), request.form.get("password", ""))
if user:
return redirect(request.args.get("next") or url_for("main.index"))
error = "Invalid username or password"
return render_template("login.html", error=error)
@bp.get("/logout")
def logout():
auth.logout_user()
if not auth.enabled():
return redirect(url_for("main.index"))
return redirect(url_for("main.login"))
@bp.get("/")
def index():
prefs = get_preferences()
return render_template(
"index.html",
prefs=prefs,
profiles=list_profiles(),
active_profile=active_profile(),
bootstrap_themes=BOOTSTRAP_THEMES,
font_families=FONT_FAMILIES,
auth_enabled=auth.enabled(),
current_user=auth.current_user(),
)
@bp.get("/docs")
def docs():
html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pyTorrent API Docs</title><link rel="stylesheet" href="{_asset_url('swagger_css')}"></head><body><div id="swagger-ui"></div><script src="{_asset_url('swagger_js')}"></script><script>window.onload=()=>SwaggerUIBundle({{url:'/api/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true}});</script></body></html>"""
return Response(html, mimetype="text/html")
@bp.get("/api/openapi.json")
def openapi():
spec_path = Path(current_app.root_path) / "openapi" / "openapi.json"
response = send_file(spec_path, mimetype="application/json", conditional=False, max_age=0)
response.headers["Cache-Control"] = "no-store, no-cache, private"
return response

109
pytorrent/routes/planner.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..services import preferences, download_planner, poller_control
from ..services.auth import current_user_id
bp = Blueprint("planner_api", __name__, url_prefix="/api")
def ok(payload=None):
data = {"ok": True}
if payload:
data.update(payload)
return jsonify(data)
def _profile_or_error():
profile = preferences.active_profile()
if not profile:
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
return profile, None
@bp.get("/download-planner")
def download_planner_get():
profile, error = _profile_or_error()
if error:
return error
return ok({"settings": download_planner.get_settings(int(profile["id"]), current_user_id())})
@bp.post("/download-planner")
def download_planner_save():
profile, error = _profile_or_error()
if error:
return error
try:
settings = download_planner.save_settings(int(profile["id"]), request.get_json(silent=True) or {}, current_user_id())
return ok({"settings": settings})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/download-planner/check")
def download_planner_check():
profile, error = _profile_or_error()
if error:
return error
try:
data = request.get_json(silent=True) or {}
run_profile = dict(profile)
if data.get("dry_run"):
run_profile["dry_run"] = "true"
return ok({"result": download_planner.enforce(run_profile, force=True)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/download-planner/preview")
def download_planner_preview():
profile, error = _profile_or_error()
if error:
return error
return ok({"preview": download_planner.preview(profile), "history": download_planner.history(int(profile["id"]), int(request.args.get("history_limit") or 40)), "history_total": download_planner.history_count(int(profile["id"]))})
@bp.delete("/download-planner/history")
def download_planner_history_clear():
profile, error = _profile_or_error()
if error:
return error
try:
deleted = download_planner.clear_history(int(profile["id"]))
return ok({"deleted": deleted, "history": [], "history_total": 0})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/download-planner/override")
def download_planner_override():
profile, error = _profile_or_error()
if error:
return error
try:
seconds = int((request.get_json(silent=True) or {}).get("seconds") or 0)
return ok(download_planner.set_manual_override(int(profile["id"]), seconds))
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/poller/settings")
def poller_settings_get():
profile, error = _profile_or_error()
if error:
return error
pid = int(profile["id"])
return ok({"settings": poller_control.get_settings(pid), "runtime": poller_control.snapshot(pid)})
@bp.post("/poller/settings")
def poller_settings_save():
profile, error = _profile_or_error()
if error:
return error
try:
return ok({"settings": poller_control.save_settings(int(profile["id"]), request.get_json(silent=True) or {})})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from ._shared import *
from ..services.rtorrent.diagnostics import profile_diagnostics
@bp.get("/profiles")
def profiles_list():
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
@bp.post("/profiles")
def profiles_create():
try:
return ok({"profile": preferences.save_profile(request.json or {})})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.put("/profiles/<int:profile_id>")
def profiles_update(profile_id: int):
try:
return ok({"profile": preferences.update_profile(profile_id, request.json or {})})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.delete("/profiles/<int:profile_id>")
def profiles_delete(profile_id: int):
preferences.delete_profile(profile_id)
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
@bp.post("/profiles/<int:profile_id>/activate")
def profiles_activate(profile_id: int):
try:
return ok({"profile": preferences.activate_profile(profile_id)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 404
@bp.post("/profiles/test")
def profiles_test_unsaved():
data = request.get_json(silent=True) or {}
profile = {
"id": data.get("id"),
"name": data.get("name") or "test",
"scgi_url": data.get("scgi_url") or "",
"timeout_seconds": data.get("timeout_seconds") or 5,
}
return ok({"diagnostics": profile_diagnostics(profile)})
@bp.get("/profiles/<int:profile_id>/diagnostics")
def profiles_diagnostics(profile_id: int):
profile = preferences.get_profile(profile_id)
if not profile:
return jsonify({"ok": False, "error": "Profile not found"}), 404
return ok({"diagnostics": profile_diagnostics(profile)})
@bp.get("/profiles/diagnostics")
def profiles_diagnostics_all():
rows = preferences.list_profiles()
diagnostics = []
for profile in rows:
diagnostics.append(profile_diagnostics(profile))
return ok({"diagnostics": diagnostics})
@bp.get("/profiles/export")
def profiles_export():
return ok(preferences.export_profiles())
@bp.post("/profiles/import")
def profiles_import():
try:
rows = preferences.import_profiles(request.get_json(silent=True) or {})
return ok({"profiles": rows})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/preferences")
def prefs_get():
return ok({"preferences": preferences.get_preferences()})
@bp.post("/preferences")
def prefs_save():
return ok({"preferences": preferences.save_preferences(request.json or {})})
@bp.post("/preferences/table-columns/recommended")
def prefs_table_columns_recommended():
# Note: Applies the backend-owned recommended desktop and mobile column layout.
return ok({"preferences": preferences.apply_recommended_table_columns()})
@bp.get("/labels")
def labels_list():
profile = preferences.active_profile()
pid = profile["id"] if profile else None
with connect() as conn:
rows = conn.execute("SELECT * FROM labels WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name COLLATE NOCASE", (default_user_id(), pid)).fetchall()
return ok({"labels": rows})
@bp.post("/labels")
def labels_save():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
name = str(data.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "error": "Missing label name"}), 400
now = utcnow()
with connect() as conn:
conn.execute("INSERT OR IGNORE INTO labels(user_id,profile_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)", (default_user_id(), profile["id"], name, data.get("color") or "#64748b", now, now))
return labels_list()
@bp.delete("/labels/<int:label_id>")
def labels_delete(label_id: int):
profile = preferences.active_profile()
pid = profile["id"] if profile else None
with connect() as conn:
conn.execute("DELETE FROM labels WHERE id=? AND user_id=? AND (profile_id=? OR profile_id IS NULL)", (label_id, default_user_id(), pid))
return labels_list()
@bp.get("/ratio-groups")
def ratio_groups_list():
profile = preferences.active_profile()
pid = profile["id"] if profile else None
with connect() as conn:
rows = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name COLLATE NOCASE", (default_user_id(), pid)).fetchall()
history = conn.execute("SELECT * FROM ratio_history WHERE user_id=? AND profile_id=? ORDER BY id DESC LIMIT 50", (default_user_id(), pid or 0)).fetchall() if pid else []
return ok({"groups": rows, "history": history})
@bp.post("/ratio-groups")
def ratio_groups_save():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
name = str(data.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "error": "Missing group name"}), 400
now = utcnow()
with connect() as conn:
conn.execute(
"""INSERT INTO ratio_groups(user_id,profile_id,name,min_ratio,max_ratio,seed_time_minutes,min_seed_time_minutes,ignore_private,ignore_active_upload,active_upload_min_bytes,move_path,set_label,action,enabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id,profile_id,name) DO UPDATE SET min_ratio=excluded.min_ratio,max_ratio=excluded.max_ratio,seed_time_minutes=excluded.seed_time_minutes,min_seed_time_minutes=excluded.min_seed_time_minutes,ignore_private=excluded.ignore_private,ignore_active_upload=excluded.ignore_active_upload,active_upload_min_bytes=excluded.active_upload_min_bytes,move_path=excluded.move_path,set_label=excluded.set_label,action=excluded.action,enabled=excluded.enabled,updated_at=excluded.updated_at""",
(default_user_id(), profile["id"], name, float(data.get("min_ratio") or 1), float(data.get("max_ratio") or 2), int(data.get("seed_time_minutes") or 0), int(data.get("min_seed_time_minutes") or 0), 1 if data.get("ignore_private", True) else 0, 1 if data.get("ignore_active_upload", True) else 0, int(data.get("active_upload_min_bytes") or 1024), data.get("move_path") or "", data.get("set_label") or "", data.get("action") or "stop", 1 if data.get("enabled", True) else 0, now, now),
)
return ratio_groups_list()
@bp.post("/ratio-groups/check")
def ratio_groups_check():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"result": ratio_rules.check(profile, default_user_id())})

82
pytorrent/routes/rss.py Normal file
View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from ._shared import *
@bp.get("/rss")
def rss_list():
profile = preferences.active_profile()
pid = profile["id"] if profile else None
with connect() as conn:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall()
history = conn.execute("SELECT * FROM rss_history WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY id DESC LIMIT 80", (default_user_id(), pid)).fetchall()
return ok({"feeds": feeds, "rules": rules, "history": history})
@bp.post("/rss/feeds")
def rss_feed_save():
profile = preferences.active_profile()
data = request.get_json(silent=True) or {}
now = utcnow()
feed_id = data.get("id")
with connect() as conn:
if feed_id:
conn.execute("UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND user_id=?", (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, default_user_id()))
else:
conn.execute("INSERT INTO rss_feeds(user_id,profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now))
return rss_list()
@bp.delete("/rss/feeds/<int:feed_id>")
def rss_feed_delete(feed_id: int):
with connect() as conn:
conn.execute("DELETE FROM rss_feeds WHERE id=? AND user_id=?", (feed_id, default_user_id()))
return rss_list()
@bp.post("/rss/rules")
def rss_rule_save():
profile = preferences.active_profile()
data = request.get_json(silent=True) or {}
now = utcnow()
rule_id = data.get("id")
values = (data.get("name") or "Rule", data.get("pattern") or ".*", data.get("exclude_pattern") or "", int(data.get("min_size_mb") or 0), int(data.get("max_size_mb") or 0), data.get("category") or "", data.get("quality") or "", data.get("season") or None, data.get("episode") or None, data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1 if data.get("enabled", True) else 0, now)
with connect() as conn:
if rule_id:
conn.execute("UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND user_id=?", (*values, rule_id, default_user_id()))
else:
conn.execute("INSERT INTO rss_rules(user_id,profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, *values, now))
return rss_list()
@bp.delete("/rss/rules/<int:rule_id>")
def rss_rule_delete(rule_id: int):
with connect() as conn:
conn.execute("DELETE FROM rss_rules WHERE id=? AND user_id=?", (rule_id, default_user_id()))
return rss_list()
@bp.post("/rss/rules/test")
def rss_rule_test():
data = request.get_json(silent=True) or {}
try:
result = rss_service.test_rule(str(data.get("feed_url") or ""), data.get("rule") or data)
return ok({"result": result})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/rss/check")
def rss_check():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok(rss_service.check(profile, default_user_id(), only_due=False))

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
from ._shared import *
@bp.get('/smart-queue')
def smart_queue_get():
from ..services import smart_queue
profile = preferences.active_profile()
if not profile:
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
try:
history_limit = max(1, min(int(request.args.get('history_limit', 10) or 10), 100))
settings = smart_queue.get_settings(profile['id'])
exclusions = smart_queue.list_exclusions(profile['id'])
history = smart_queue.list_history(profile['id'], limit=history_limit)
history_total = smart_queue.count_history(profile['id'])
return ok({'settings': settings, 'exclusions': exclusions, 'history': history, 'history_total': history_total, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []})
@bp.post('/smart-queue')
def smart_queue_save():
from ..services import smart_queue
profile = preferences.active_profile()
if not profile:
return ok({'settings': {}, 'error': 'No profile'})
try:
payload = request.get_json(silent=True) or {}
settings = smart_queue.save_settings(profile['id'], payload)
return ok({'settings': settings, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)})
@bp.post('/smart-queue/check')
def smart_queue_check():
profile = preferences.active_profile()
if not profile:
return ok({'result': {'ok': False, 'error': 'No profile'}})
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
from ..services import smart_queue
try:
result = smart_queue.check(profile, force=True)
diff = torrent_cache.refresh(profile)
rows = torrent_cache.snapshot(profile['id'])
return ok({'result': result, 'torrent_patch': {**diff, 'summary': cached_summary(profile['id'], rows, force=True)}})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
try:
job_id = enqueue(
'smart_queue_check',
int(profile['id']),
{'job_context': {'source': 'user', 'bulk_label': 'Smart Queue manual check'}},
force=True,
max_attempts=1,
)
return ok({'queued': True, 'job_id': job_id, 'result': {'ok': True, 'queued': True, 'job_id': job_id}})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
@bp.post('/smart-queue/exclusion')
def smart_queue_exclusion():
from ..services import smart_queue
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
data = request.get_json(silent=True) or {}
torrent_hash = str(data.get('hash') or '').strip()
if not torrent_hash:
return jsonify({'ok': False, 'error': 'Missing torrent hash'}), 400
smart_queue.set_exclusion(profile['id'], torrent_hash, bool(data.get('excluded', True)), str(data.get('reason') or 'manual'))
return ok({'exclusions': smart_queue.list_exclusions(profile['id'])})
@bp.delete('/smart-queue/history')
def smart_queue_history_clear():
from ..services import smart_queue
profile = preferences.active_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
removed = smart_queue.clear_history(profile['id'])
return ok({'removed': removed, 'history': [], 'history_total': 0})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500

378
pytorrent/routes/system.py Normal file
View File

@@ -0,0 +1,378 @@
from __future__ import annotations
from ._shared import *
@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/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()
deleted_planner = download_planner.clear_history(int(active_profile["id"])) if active_profile 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, "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': []}})

View File

@@ -0,0 +1,585 @@
from __future__ import annotations
from ._shared import *
from ..services import torrent_creator
@bp.get("/torrents")
def torrents():
profile = preferences.active_profile()
if not profile:
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
rows = torrent_cache.snapshot(profile["id"])
return ok({
"profile_id": profile["id"],
"torrents": rows,
"summary": cached_summary(profile["id"], rows),
"error": torrent_cache.error(profile["id"]),
})
@bp.get("/trackers/summary")
def trackers_summary():
profile = preferences.active_profile()
if not profile:
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
try:
# Note: Tracker summary returns cached data immediately; optional warmup scans rTorrent in the background for very large libraries.
scan_limit = min(250, max(0, int(request.args.get("scan_limit") or 0)))
bg_limit = min(250, max(1, int(request.args.get("bg_limit") or 80)))
warm = str(request.args.get("warm") or "").lower() in {"1", "true", "yes"}
hashes = [t.get("hash") for t in torrent_cache.snapshot(profile["id"]) if t.get("hash")]
prefs = preferences.get_preferences()
include_favicons = bool(prefs and prefs.get("tracker_favicons_enabled"))
loader = lambda h: rtorrent.torrent_trackers(profile, h)
summary = tracker_cache.summary(profile, hashes, loader, scan_limit=scan_limit, include_favicons=include_favicons)
if warm and int(summary.get("pending") or 0) > 0:
summary["warming"] = tracker_cache.warm_summary_cache(profile, hashes, loader, batch_size=bg_limit)
return ok({"summary": summary})
except Exception as exc:
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [{"error": str(exc)}], "scanned": 0, "pending": 0}, "error": str(exc)})
@bp.get("/trackers/favicon/<path:domain>")
@bp.get("/tracker-favicon/<path:domain>")
def tracker_favicon(domain: str):
prefs = preferences.get_preferences()
force = str(request.args.get("refresh") or "").lower() in {"1", "true", "yes", "force"}
# Note: Manual refresh must work from CLI even when tracker favicons are disabled in Preferences.
enabled = force or bool(prefs and prefs.get("tracker_favicons_enabled"))
static_url = tracker_cache.favicon_public_url(domain, enabled=enabled, create=True, force=force)
if static_url:
# Note: The API only discovers/cache-warms the icon; the browser receives the file from /static/tracker_favicons/.
return redirect(static_url, code=302)
cached = tracker_cache.favicon_cache_row(domain)
return jsonify({
"ok": False,
"error": "favicon not found",
"domain": tracker_cache.tracker_domain(domain),
"enabled": bool(enabled),
"cached_error": (cached or {}).get("error") if cached else None,
}), 404
@bp.get("/trackers/favicon")
def tracker_favicon_query():
# Note: Query-string alias makes cache warming easier from shell scripts where path routing/proxies may differ.
domain = str(request.args.get("domain") or "").strip()
if not domain:
return jsonify({"ok": False, "error": "domain is required"}), 400
return tracker_favicon(domain)
@bp.get("/torrent-stats")
def torrent_stats_get():
profile = preferences.active_profile()
if not profile:
return ok({"stats": {}, "error": "No profile"})
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
try:
# Note: Heavy file metadata is served from a 15-minute DB cache unless the user explicitly refreshes it.
return ok({"stats": torrent_stats.get(profile, force=force)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
@bp.get("/torrents/<torrent_hash>/files")
def torrent_files(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
@bp.post("/torrents/<torrent_hash>/files/priority")
def torrent_file_priority(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
files = data.get("files") or []
if not isinstance(files, list) or not files:
return jsonify({"ok": False, "error": "No files selected"}), 400
result = rtorrent.set_file_priorities(profile, torrent_hash, files)
status = 207 if result.get("errors") else 200
return ok(result), status
@bp.get("/torrents/<torrent_hash>/files/tree")
def torrent_file_tree(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
def torrent_folder_priority(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
result = rtorrent.set_folder_priority(profile, torrent_hash, str(data.get("path") or ""), int(data.get("priority") or 0))
status = 207 if result.get("errors") else 200
return ok(result), status
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream") -> dict:
safe = Path(download_name or "download.bin").name or "download.bin"
return {
"Content-Type": content_type,
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(safe)}",
"X-Content-Type-Options": "nosniff",
}
def _cleanup_staged_file(profile: dict, path: str, local: bool = False) -> None:
if local:
try:
Path(path).unlink()
except Exception:
pass
return
rtorrent._remote_remove_staged(profile, path)
try:
tmp_prefix = str(PYTORRENT_TMP_DIR).rstrip("/") + "/pytorrent-download-"
if str(path).startswith(tmp_prefix) and Path(path).exists():
Path(path).unlink()
except Exception:
pass
def _read_staged_file(profile: dict, path: str, local: bool = False) -> bytes:
if local:
return Path(path).read_bytes()
chunks = []
for chunk in rtorrent.iter_remote_file_chunks(profile, path):
if chunk:
chunks.append(bytes(chunk))
return b"".join(chunks)
def _send_staged_file(profile: dict, path: str, download_name: str, local: bool = False):
headers = _attachment_headers(download_name, "application/x-bittorrent")
if local:
data = Path(path).read_bytes()
_cleanup_staged_file(profile, path, local=True)
headers["Content-Length"] = str(len(data))
return Response(data, headers=headers)
def generate():
try:
yield from rtorrent.iter_remote_file_chunks(profile, path)
finally:
_cleanup_staged_file(profile, path, local=False)
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
def torrent_file_download(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
size = int(item.get("size") or 0)
headers = _attachment_headers(item.get("download_name") or "file.bin")
if size > 0:
headers["Content-Length"] = str(size)
def generate():
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
class _ZipStream:
def __init__(self):
self.queue: queue.Queue[bytes | None] = queue.Queue(maxsize=16)
self.closed = False
def write(self, data):
if not data:
return 0
payload = bytes(data)
self.queue.put(payload)
return len(payload)
def flush(self):
return None
def close(self):
if not self.closed:
self.closed = True
self.queue.put(None)
def writable(self):
return True
def _safe_zip_name(name: str, fallback: str) -> str:
value = str(name or fallback).replace("\\", "/").lstrip("/")
parts = [part for part in value.split("/") if part not in ("", ".", "..")]
return "/".join(parts) or fallback
def _stream_torrent_files_zip(profile: dict, items: list[dict]):
writer = _ZipStream()
errors: list[BaseException] = []
def produce():
try:
with zipfile.ZipFile(writer, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
used = set()
for item in items:
arcname = _safe_zip_name(str(item.get("path") or ""), f"file-{item.get('index', 0)}")
base = arcname
counter = 2
while arcname in used:
stem = Path(base).stem or "file"
suffix = Path(base).suffix
parent = str(Path(base).parent).replace(".", "", 1).strip("/")
candidate = f"{stem}-{counter}{suffix}"
arcname = f"{parent}/{candidate}" if parent else candidate
counter += 1
used.add(arcname)
info = zipfile.ZipInfo(arcname)
info.compress_type = zipfile.ZIP_STORED
info.file_size = int(item.get("size") or 0)
with archive.open(info, "w", force_zip64=True) as dest:
for chunk in rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=int(item.get("size") or 0) or None):
dest.write(chunk)
except BaseException as exc:
errors.append(exc)
finally:
writer.close()
threading.Thread(target=produce, name="pytorrent-zip-stream", daemon=True).start()
while True:
chunk = writer.queue.get()
if chunk is None:
break
yield chunk
if errors:
raise errors[0]
@bp.post("/torrents/<torrent_hash>/files/download.zip")
def torrent_files_download_zip(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
try:
items = rtorrent.torrent_download_zip_items(profile, torrent_hash, data.get("indexes") or None)
headers = _attachment_headers(f"{torrent_hash[:12]}-files.zip", "application/zip")
headers["X-PyTorrent-Download-Mode"] = "rtorrent-stream"
return Response(stream_with_context(_stream_torrent_files_zip(profile, items)), headers=headers, direct_passthrough=True)
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/torrent-file")
def torrent_file_export(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
item = rtorrent.export_torrent_file(profile, torrent_hash)
return _send_staged_file(profile, item["path"], item["download_name"], bool(item.get("local")))
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/torrent-files.zip")
def torrent_files_export_zip():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
hashes = [str(h) for h in (data.get("hashes") or []) if str(h).strip()]
if not hashes:
return jsonify({"ok": False, "error": "No torrents selected"}), 400
staged_paths = []
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-torrents-", suffix=".zip", delete=False, dir=str(PYTORRENT_TMP_DIR))
tmp.close()
try:
with zipfile.ZipFile(tmp.name, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
used_names = set()
for h in hashes:
item = rtorrent.export_torrent_file(profile, h)
staged_paths.append((item["path"], bool(item.get("local"))))
name = Path(item["download_name"]).name or f"{h}.torrent"
base_name = name
counter = 2
while name in used_names:
stem = Path(base_name).stem
name = f"{stem}-{counter}.torrent"
counter += 1
used_names.add(name)
archive.writestr(name, _read_staged_file(profile, item["path"], bool(item.get("local"))))
response = send_file(tmp.name, as_attachment=True, download_name="pytorrent-torrents.zip")
def cleanup():
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
try:
Path(tmp.name).unlink()
except Exception:
pass
response.call_on_close(cleanup)
return response
except Exception as exc:
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
try:
Path(tmp.name).unlink()
except Exception:
pass
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/chunks")
def torrent_chunks(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
max_cells = min(10000, max(64, int(request.args.get("max_cells") or 2048)))
return ok({"chunks": rtorrent.torrent_chunks(profile, torrent_hash, max_cells=max_cells)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
def torrent_chunk_action(torrent_hash: str, action_name: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: Chunk actions are intentionally limited to rTorrent-safe operations; XML-RPC has no supported single-piece redownload call.
result = rtorrent.torrent_chunk_action(profile, torrent_hash, action_name, request.get_json(silent=True) or {})
return ok({"result": result, "message": result.get("message") or f"Chunk action {action_name} done"})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/peers")
def torrent_peers(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
peers = rtorrent.torrent_peers(profile, torrent_hash)
for peer in peers:
peer.update(lookup_ip(peer.get("ip", "")))
return ok({"peers": peers})
@bp.get("/torrents/<torrent_hash>/trackers")
def torrent_trackers(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
def torrent_tracker_action(torrent_hash: str, action_name: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
result = rtorrent.tracker_action(profile, torrent_hash, action_name, request.get_json(silent=True) or {})
return ok({"result": result, "message": f"Tracker {action_name} via {result.get('method', 'XMLRPC')}"})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/<action_name>")
def torrent_action(action_name: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"}
if action_name not in allowed:
return jsonify({"ok": False, "error": "Unknown action"}), 400
if action_name in {"move", "remove"}:
# Note: Large move/remove requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
jobs = enqueue_bulk_parts(profile, action_name, data)
first_job_id = jobs[0]["job_id"] if jobs else None
total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs)
return ok({
"job_id": first_job_id,
"job_ids": [job["job_id"] for job in jobs],
"jobs": jobs,
"hash_count": total_hashes,
"bulk": total_hashes > 1,
"bulk_parts": len(jobs),
"chunk_size": MOVE_BULK_MAX_HASHES,
})
payload = enrich_bulk_payload(profile, action_name, data)
job_id = enqueue(action_name, profile["id"], payload)
return ok({"job_id": job_id, "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1})
@bp.post("/torrents/create")
def torrent_create():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
try:
created = torrent_creator.build_torrent(
source_path=form.get("source_path", ""),
trackers=form.get("trackers", ""),
comment=form.get("comment", ""),
source=form.get("source", ""),
piece_size_kib=form.get("piece_size_kib", 256),
private=str(form.get("private", "0")).lower() in {"1", "true", "on", "yes"},
)
share = str(form.get("share", "0")).lower() in {"1", "true", "on", "yes"}
if share:
size_check = rtorrent.validate_torrent_upload_size(profile, created["data"], True, created["source_parent"], form.get("label", ""))
if not size_check.get("ok"):
return jsonify({"ok": False, "error": f"Created torrent is too large for the current rTorrent XML-RPC limit: request {size_check['request_h']} > limit {size_check['limit_h']}. Change {size_check['setting']}.set to e.g. {size_check['suggested_value']} in rTorrent settings.", "xmlrpc_limit": size_check}), 413
rtorrent.add_torrent_raw(profile, created["data"], True, created["source_parent"], form.get("label", ""))
headers = _attachment_headers(created["filename"], "application/x-bittorrent")
headers["Content-Length"] = str(len(created["data"]))
headers["X-PyTorrent-Info-Hash"] = created["info_hash"]
headers["X-PyTorrent-Create-Message"] = f"Created {created['filename']} ({created['file_count']} file(s))"
return Response(created["data"], headers=headers)
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/add")
def torrent_add():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
job_ids = []
if request.content_type and request.content_type.startswith("multipart/form-data"):
start = request.form.get("start", "1") in {"1", "true", "on", "yes"}
directory = request.form.get("directory", "") or active_default_download_path(profile)
label = request.form.get("label", "")
uris = [x.strip() for x in request.form.get("uris", "").splitlines() if x.strip()]
for uri in uris:
job_ids.append(enqueue("add_magnet", profile["id"], {"uri": uri, "start": start, "directory": directory, "label": label}))
existing_hashes = {str(t.get("hash") or "").upper() for t in torrent_cache.snapshot(profile["id"])}
try:
priority_payload = json.loads(request.form.get("file_priorities") or "{}")
except Exception:
priority_payload = {}
allow_duplicates = request.form.get("allow_duplicates", "0") in {"1", "true", "on", "yes"}
skipped_duplicates = []
for uploaded in request.files.getlist("files"):
raw = uploaded.read()
meta = parse_torrent(raw)
info_hash = str(meta.get("info_hash") or "").upper()
filename = uploaded.filename or meta.get("name") or info_hash
if info_hash and info_hash in existing_hashes and not allow_duplicates:
skipped_duplicates.append({"filename": filename, "info_hash": info_hash})
continue
file_priorities = []
if isinstance(priority_payload, dict):
file_priorities = priority_payload.get(filename) or priority_payload.get(info_hash) or []
elif isinstance(priority_payload, list):
file_priorities = priority_payload
size_check = rtorrent.validate_torrent_upload_size(profile, raw, start, directory, label, file_priorities or None)
if not size_check.get("ok"):
return jsonify({
"ok": False,
"error": (
f"Torrent file is too large for the current rTorrent XML-RPC limit: "
f"request {size_check['request_h']} > limit {size_check['limit_h']}. "
f"Change {size_check['setting']}.set to e.g. {size_check['suggested_value']} in rTorrent settings."
),
"xmlrpc_limit": size_check,
}), 413
data_b64 = base64.b64encode(raw).decode("ascii")
job_ids.append(enqueue("add_torrent_raw", profile["id"], {"filename": filename, "data_b64": data_b64, "start": start, "directory": directory, "label": label, "file_priorities": file_priorities or None}))
return ok({"job_ids": job_ids, "skipped_duplicates": skipped_duplicates})
data = request.get_json(silent=True) or {}
uris = data.get("uris") or []
if isinstance(uris, str):
uris = [x.strip() for x in uris.splitlines() if x.strip()]
for uri in uris:
job_ids.append(enqueue("add_magnet", profile["id"], {"uri": uri, "start": data.get("start", True), "directory": data.get("directory", "") or active_default_download_path(profile), "label": data.get("label", "")}))
return ok({"job_ids": job_ids})
@bp.post("/torrents/preview")
def torrent_preview():
profile = preferences.active_profile()
existing_hashes = set()
if profile:
try:
existing_hashes = {str(t.get("hash") or "").upper() for t in torrent_cache.snapshot(profile["id"])}
except Exception:
existing_hashes = set()
previews = []
xmlrpc_limit = rtorrent.xmlrpc_size_limit(profile) if profile else None
try:
uploads = request.files.getlist("files") if request.content_type and request.content_type.startswith("multipart/form-data") else []
for uploaded in uploads:
raw = uploaded.read()
meta = parse_torrent(raw)
meta["filename"] = uploaded.filename
meta["duplicate"] = bool(meta.get("info_hash") and meta["info_hash"].upper() in existing_hashes)
if profile:
size_check = rtorrent.validate_torrent_upload_size(profile, raw)
meta["xmlrpc_request_bytes"] = size_check["request_bytes"]
meta["xmlrpc_request_h"] = size_check["request_h"]
meta["xmlrpc_too_large"] = not size_check.get("ok")
previews.append(meta)
return ok({"previews": previews, "xmlrpc_limit": xmlrpc_limit})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/speed/limits")
def speed_limits():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
job_id = enqueue("set_limits", profile["id"], {"down": data.get("down"), "up": data.get("up")})
return ok({"job_id": job_id})
def _user_disk_status(profile: dict) -> dict:
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
try:
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
except Exception:
paths = []
return rtorrent.disk_usage_for_paths(
profile,
paths,
(prefs or {}).get("disk_monitor_mode") or "default",
(prefs or {}).get("disk_monitor_selected_path") or "",
)