first commit
This commit is contained in:
407
pytorrent/routes/_shared.py
Normal file
407
pytorrent/routes/_shared.py
Normal 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
14
pytorrent/routes/api.py
Normal 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"]
|
||||
97
pytorrent/routes/auth_api.py
Normal file
97
pytorrent/routes/auth_api.py
Normal 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
|
||||
|
||||
116
pytorrent/routes/automations.py
Normal file
116
pytorrent/routes/automations.py
Normal 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
|
||||
69
pytorrent/routes/backup.py
Normal file
69
pytorrent/routes/backup.py
Normal 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
79
pytorrent/routes/main.py
Normal 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
109
pytorrent/routes/planner.py
Normal 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
|
||||
182
pytorrent/routes/profiles.py
Normal file
182
pytorrent/routes/profiles.py
Normal 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
82
pytorrent/routes/rss.py
Normal 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))
|
||||
|
||||
|
||||
90
pytorrent/routes/smart_queue.py
Normal file
90
pytorrent/routes/smart_queue.py
Normal 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
378
pytorrent/routes/system.py
Normal 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': []}})
|
||||
|
||||
585
pytorrent/routes/torrents.py
Normal file
585
pytorrent/routes/torrents.py
Normal 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 "",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user