first commit
This commit is contained in:
489
pytorrent/services/auth.py
Normal file
489
pytorrent/services/auth.py
Normal file
@@ -0,0 +1,489 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
import secrets
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import abort, g, jsonify, redirect, request, session, url_for
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from ..config import AUTH_ENABLE
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
|
||||
PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"}
|
||||
RTORRENT_WRITE_PREFIXES = (
|
||||
"/api/torrents/",
|
||||
"/api/speed/limits",
|
||||
"/api/labels",
|
||||
"/api/ratio-groups",
|
||||
"/api/rss",
|
||||
"/api/smart-queue",
|
||||
"/api/automations",
|
||||
"/api/jobs",
|
||||
)
|
||||
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
|
||||
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
|
||||
# Note: API reads that expose rTorrent/profile data must also respect profile permissions.
|
||||
PROFILE_READ_PREFIXES = (
|
||||
"/api/torrents",
|
||||
"/api/torrent-stats",
|
||||
"/api/system/status",
|
||||
"/api/app/status",
|
||||
"/api/port-check",
|
||||
"/api/path",
|
||||
"/api/labels",
|
||||
"/api/ratio-groups",
|
||||
"/api/rss",
|
||||
"/api/rtorrent-config",
|
||||
"/api/smart-queue",
|
||||
"/api/traffic/history",
|
||||
"/api/automations",
|
||||
)
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(AUTH_ENABLE)
|
||||
|
||||
|
||||
def password_hash(password: str) -> str:
|
||||
return generate_password_hash(password or "")
|
||||
|
||||
|
||||
def current_user_id() -> int:
|
||||
if not enabled():
|
||||
return default_user_id()
|
||||
api_user_id = getattr(g, "api_user_id", None)
|
||||
if api_user_id:
|
||||
return int(api_user_id)
|
||||
try:
|
||||
return int(session.get("user_id") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def current_user() -> dict[str, Any] | None:
|
||||
uid = current_user_id()
|
||||
if not uid:
|
||||
return None
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?",
|
||||
(uid,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def is_admin(user: dict[str, Any] | None = None) -> bool:
|
||||
if not enabled():
|
||||
return True
|
||||
user = user or current_user()
|
||||
return bool(user and user.get("role") == "admin" and int(user.get("is_active") or 0))
|
||||
|
||||
|
||||
def _permissions(user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
if not enabled():
|
||||
return [{"profile_id": 0, "access_level": "full"}]
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return []
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"SELECT profile_id, access_level FROM user_profile_permissions WHERE user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def can_access_profile(profile_id: int | None, user_id: int | None = None) -> bool:
|
||||
if not enabled():
|
||||
return True
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return False
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
pid = int(profile_id or 0)
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM user_profile_permissions WHERE user_id=? AND (profile_id=0 OR profile_id=?) LIMIT 1",
|
||||
(uid, pid),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def can_write_profile(profile_id: int | None, user_id: int | None = None) -> bool:
|
||||
if not enabled():
|
||||
return True
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return False
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
pid = int(profile_id or 0)
|
||||
row = conn.execute(
|
||||
"SELECT access_level FROM user_profile_permissions WHERE user_id=? AND (profile_id=0 OR profile_id=?) ORDER BY profile_id DESC LIMIT 1",
|
||||
(uid, pid),
|
||||
).fetchone()
|
||||
return bool(row and row.get("access_level") == "full")
|
||||
|
||||
|
||||
def visible_profile_ids(user_id: int | None = None) -> set[int] | None:
|
||||
if not enabled():
|
||||
return None
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return set()
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return set()
|
||||
if user.get("role") == "admin":
|
||||
return None
|
||||
rows = conn.execute("SELECT profile_id FROM user_profile_permissions WHERE user_id=?", (uid,)).fetchall()
|
||||
if any(int(row.get("profile_id") or 0) == 0 for row in rows):
|
||||
return None
|
||||
return {int(row.get("profile_id") or 0) for row in rows}
|
||||
|
||||
|
||||
|
||||
def same_origin_request() -> bool:
|
||||
"""Return False only when an unsafe request clearly comes from another origin."""
|
||||
origin = request.headers.get("Origin") or request.headers.get("Referer")
|
||||
if not origin:
|
||||
return True
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
return parsed.scheme == request.scheme and parsed.netloc == request.host
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def writable_profile_ids(user_id: int | None = None) -> set[int] | None:
|
||||
if not enabled():
|
||||
return None
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return set()
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return set()
|
||||
if user.get("role") == "admin":
|
||||
return None
|
||||
rows = conn.execute("SELECT profile_id FROM user_profile_permissions WHERE user_id=? AND access_level='full'", (uid,)).fetchall()
|
||||
if any(int(row.get("profile_id") or 0) == 0 for row in rows):
|
||||
return None
|
||||
return {int(row.get("profile_id") or 0) for row in rows}
|
||||
|
||||
def require_admin() -> None:
|
||||
if enabled() and not is_admin():
|
||||
abort(403)
|
||||
|
||||
|
||||
def require_profile_read(profile_id: int | None) -> None:
|
||||
if enabled() and not can_access_profile(profile_id):
|
||||
abort(403)
|
||||
|
||||
|
||||
def require_profile_write(profile_id: int | None) -> None:
|
||||
if enabled() and not can_write_profile(profile_id):
|
||||
abort(403)
|
||||
|
||||
|
||||
def login_user(username: str, password: str) -> dict[str, Any] | None:
|
||||
if not enabled():
|
||||
return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1}
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return None
|
||||
if not user.get("password_hash") or not check_password_hash(user.get("password_hash"), password or ""):
|
||||
return None
|
||||
session.clear()
|
||||
session["user_id"] = int(user["id"])
|
||||
session["username"] = user["username"]
|
||||
session["role"] = user.get("role") or "user"
|
||||
return current_user()
|
||||
|
||||
|
||||
def logout_user() -> None:
|
||||
session.clear()
|
||||
|
||||
|
||||
def ensure_admin_user() -> None:
|
||||
if not enabled():
|
||||
return
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE username='admin'").fetchone()
|
||||
if not row:
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
||||
("admin", password_hash("admin"), "admin", 1, now, now),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE users SET role='admin', is_active=1, updated_at=? WHERE username='admin'", (now,))
|
||||
|
||||
|
||||
def list_users() -> list[dict[str, Any]]:
|
||||
require_admin()
|
||||
with connect() as conn:
|
||||
users = conn.execute(
|
||||
"SELECT id, username, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE"
|
||||
).fetchall()
|
||||
perms = conn.execute(
|
||||
"SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id"
|
||||
).fetchall()
|
||||
token_counts = conn.execute(
|
||||
"SELECT user_id, COUNT(*) AS token_count FROM api_tokens WHERE revoked_at IS NULL GROUP BY user_id"
|
||||
).fetchall()
|
||||
by_token_user = {int(row["user_id"]): int(row.get("token_count") or 0) for row in token_counts}
|
||||
by_user: dict[int, list[dict[str, Any]]] = {}
|
||||
for perm in perms:
|
||||
by_user.setdefault(int(perm["user_id"]), []).append({
|
||||
"profile_id": int(perm.get("profile_id") or 0),
|
||||
"access_level": perm.get("access_level") or "ro",
|
||||
})
|
||||
for user in users:
|
||||
user["permissions"] = by_user.get(int(user["id"]), [])
|
||||
user["api_tokens"] = by_token_user.get(int(user["id"]), 0)
|
||||
return users
|
||||
|
||||
|
||||
def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
|
||||
require_admin()
|
||||
now = utcnow()
|
||||
username = str(data.get("username") or "").strip()
|
||||
role = "admin" if data.get("role") == "admin" else "user"
|
||||
is_active = 1 if data.get("is_active", True) else 0
|
||||
if not username:
|
||||
raise ValueError("Username is required")
|
||||
with connect() as conn:
|
||||
if user_id:
|
||||
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("User does not exist")
|
||||
conn.execute(
|
||||
"UPDATE users SET username=?, role=?, is_active=?, updated_at=? WHERE id=?",
|
||||
(username, role, is_active, now, user_id),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
||||
(username, password_hash(str(data.get("password") or username)), role, is_active, now, now),
|
||||
)
|
||||
user_id = int(cur.lastrowid)
|
||||
if data.get("password"):
|
||||
conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id))
|
||||
if role != "admin":
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
||||
for item in data.get("permissions") or []:
|
||||
profile_id = int(item.get("profile_id") or 0)
|
||||
access = "full" if item.get("access_level") == "full" else "ro"
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
(user_id, profile_id, access, now, now),
|
||||
)
|
||||
else:
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
||||
return conn.execute("SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
|
||||
|
||||
def delete_user(user_id: int) -> None:
|
||||
require_admin()
|
||||
uid = int(user_id or 0)
|
||||
if uid == current_user_id():
|
||||
raise ValueError("Cannot delete current user")
|
||||
if uid == default_user_id():
|
||||
# Note: The built-in fallback account must stay in the database for auth-disabled and recovery flows.
|
||||
raise ValueError("Cannot delete the default user")
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT username FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("User does not exist")
|
||||
if str(row.get("username") or "").lower() in {"default", "admin"}:
|
||||
# Note: Protect bootstrap accounts by name as well as by id.
|
||||
raise ValueError("Cannot delete built-in user")
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (uid,))
|
||||
conn.execute("UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE user_id=?", (utcnow(), utcnow(), uid))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
|
||||
|
||||
|
||||
def _public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"username": row.get("username"),
|
||||
"role": row.get("role") or "user",
|
||||
"is_active": int(row.get("is_active") or 0),
|
||||
"created_at": row.get("created_at"),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def _token_response(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"user_id": int(row["user_id"]),
|
||||
"name": row.get("name") or "API token",
|
||||
"token_prefix": row.get("token_prefix") or "",
|
||||
"last_used_at": row.get("last_used_at"),
|
||||
"created_at": row.get("created_at"),
|
||||
"revoked_at": row.get("revoked_at"),
|
||||
}
|
||||
|
||||
|
||||
def list_api_tokens(user_id: int) -> list[dict[str, Any]]:
|
||||
if not enabled():
|
||||
return []
|
||||
uid = int(user_id or 0)
|
||||
if not uid:
|
||||
return []
|
||||
if not is_admin() and current_user_id() != uid:
|
||||
abort(403)
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return [_token_response(row) for row in rows]
|
||||
|
||||
|
||||
def create_api_token(user_id: int, name: str = "API token") -> dict[str, Any]:
|
||||
if not enabled():
|
||||
raise ValueError("API tokens are available only when authentication is enabled")
|
||||
uid = int(user_id or 0)
|
||||
if not uid:
|
||||
raise ValueError("User is required")
|
||||
if not is_admin() and current_user_id() != uid:
|
||||
abort(403)
|
||||
clean_name = str(name or "API token").strip()[:80] or "API token"
|
||||
secret = "pt_" + secrets.token_urlsafe(32)
|
||||
prefix = secret[:14]
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT id,is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
raise ValueError("User is inactive or does not exist")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens(user_id,name,token_hash,token_prefix,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
||||
(uid, clean_name, password_hash(secret), prefix, now, now),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE id=?",
|
||||
(int(cur.lastrowid),),
|
||||
).fetchone()
|
||||
data = _token_response(row)
|
||||
data["token"] = secret
|
||||
return data
|
||||
|
||||
|
||||
def revoke_api_token(user_id: int, token_id: int) -> None:
|
||||
if not enabled():
|
||||
abort(404)
|
||||
uid = int(user_id or 0)
|
||||
tid = int(token_id or 0)
|
||||
if not is_admin() and current_user_id() != uid:
|
||||
abort(403)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=?",
|
||||
(now, now, tid, uid),
|
||||
)
|
||||
|
||||
|
||||
def authenticate_api_token(token: str) -> dict[str, Any] | None:
|
||||
if not enabled():
|
||||
return None
|
||||
raw = str(token or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
prefix = raw[:14]
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT t.id AS token_id,t.token_hash,t.user_id,u.username,u.role,u.is_active
|
||||
FROM api_tokens t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.revoked_at IS NULL AND t.token_prefix=?""",
|
||||
(prefix,),
|
||||
).fetchall()
|
||||
matched = None
|
||||
for row in rows:
|
||||
if check_password_hash(row.get("token_hash") or "", raw):
|
||||
matched = row
|
||||
break
|
||||
if not matched or not int(matched.get("is_active") or 0):
|
||||
return None
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=?, updated_at=? WHERE id=?", (utcnow(), utcnow(), int(matched["token_id"])))
|
||||
return {"id": int(matched["user_id"]), "username": matched.get("username"), "role": matched.get("role") or "user", "is_active": 1}
|
||||
|
||||
|
||||
def _request_api_token() -> str:
|
||||
header = request.headers.get("Authorization") or ""
|
||||
if header.lower().startswith("bearer "):
|
||||
return header.split(None, 1)[1].strip()
|
||||
return (request.headers.get("X-API-Key") or request.args.get("api_key") or "").strip()
|
||||
|
||||
|
||||
def install_guards(app) -> None:
|
||||
@app.before_request
|
||||
def _auth_guard():
|
||||
if not enabled():
|
||||
return None
|
||||
g.api_token_authenticated = False
|
||||
if request.path.startswith("/api/"):
|
||||
token_user = authenticate_api_token(_request_api_token())
|
||||
if token_user:
|
||||
g.api_user_id = int(token_user["id"])
|
||||
g.api_token_authenticated = True
|
||||
endpoint = request.endpoint or ""
|
||||
if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"):
|
||||
return None
|
||||
if not current_user_id():
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"ok": False, "error": "Authentication required"}), 401
|
||||
return redirect(url_for("main.login", next=request.full_path if request.query_string else request.path))
|
||||
user = current_user()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
logout_user()
|
||||
return jsonify({"ok": False, "error": "Authentication required"}), 401 if request.path.startswith("/api/") else redirect(url_for("main.login"))
|
||||
if request.path.startswith("/api/auth/users") and not is_admin(user):
|
||||
return jsonify({"ok": False, "error": "Admin only"}), 403
|
||||
if request.path.startswith(PROFILE_READ_PREFIXES):
|
||||
profile_id = _request_profile_id()
|
||||
if profile_id and not can_access_profile(profile_id):
|
||||
return jsonify({"ok": False, "error": "Profile access denied"}), 403
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
if request.path.startswith("/api/") and not getattr(g, "api_token_authenticated", False) and not same_origin_request():
|
||||
return jsonify({"ok": False, "error": "Cross-origin API request blocked"}), 403
|
||||
if request.path.startswith("/api/profiles") and not request.path.endswith("/activate") and not is_admin(user):
|
||||
return jsonify({"ok": False, "error": "Admin only"}), 403
|
||||
profile_id = _request_profile_id()
|
||||
if request.path.startswith(RTORRENT_CONFIG_PREFIXES) and not can_write_profile(profile_id):
|
||||
return jsonify({"ok": False, "error": "Read-only profile access"}), 403
|
||||
if request.path.startswith(RTORRENT_WRITE_PREFIXES) and not can_write_profile(profile_id):
|
||||
return jsonify({"ok": False, "error": "Read-only profile access"}), 403
|
||||
return None
|
||||
|
||||
|
||||
def _request_profile_id() -> int | None:
|
||||
if request.view_args and request.view_args.get("profile_id"):
|
||||
return int(request.view_args["profile_id"])
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if payload.get("profile_id"):
|
||||
return int(payload.get("profile_id"))
|
||||
except Exception:
|
||||
pass
|
||||
from . import preferences
|
||||
profile = preferences.active_profile()
|
||||
return int(profile["id"]) if profile else None
|
||||
382
pytorrent/services/automation_rules.py
Normal file
382
pytorrent/services/automation_rules.py
Normal file
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import json
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
from .preferences import active_profile
|
||||
from .workers import enqueue
|
||||
|
||||
AUTOMATION_JOB_CHUNK_SIZE = 100
|
||||
AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'}
|
||||
|
||||
|
||||
|
||||
def _loads(value: str | None, default: Any) -> Any:
|
||||
try: return json.loads(value or '')
|
||||
except Exception: return default
|
||||
|
||||
|
||||
def _ts(value: str | None) -> float:
|
||||
if not value: return 0.0
|
||||
try: return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp()
|
||||
except Exception: return 0.0
|
||||
|
||||
|
||||
def _now_ts() -> float:
|
||||
return datetime.now(timezone.utc).timestamp()
|
||||
|
||||
|
||||
def _label_names(value: str | None) -> list[str]:
|
||||
seen = []
|
||||
for part in str(value or '').replace(';', ',').replace('|', ',').split(','):
|
||||
item = part.strip()
|
||||
if item and item not in seen: seen.append(item)
|
||||
return seen
|
||||
|
||||
|
||||
def _label_value(labels: list[str]) -> str:
|
||||
out = []
|
||||
for label in labels:
|
||||
label = str(label or '').strip()
|
||||
if label and label not in out: out.append(label)
|
||||
return ', '.join(out)
|
||||
|
||||
|
||||
def _rule_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
item = dict(row)
|
||||
item['conditions'] = _loads(item.pop('conditions_json', '[]'), [])
|
||||
item['effects'] = _loads(item.pop('effects_json', '[]'), [])
|
||||
return item
|
||||
|
||||
|
||||
def list_rules(profile_id: int | None = None, user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
if profile_id is None:
|
||||
profile = active_profile(); profile_id = int(profile['id']) if profile else None
|
||||
with connect() as conn:
|
||||
rows = conn.execute('SELECT * FROM automation_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY enabled DESC, name COLLATE NOCASE', (user_id, profile_id)).fetchall()
|
||||
rules = [_rule_row(r) for r in rows]
|
||||
if profile_id is not None:
|
||||
with connect() as conn:
|
||||
for rule in rules:
|
||||
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, '__rule__')).fetchone()
|
||||
last = row.get('last_applied_at') if row else None
|
||||
cooldown = int(rule.get('cooldown_minutes') or 0)
|
||||
remaining = max(0, int((_ts(last) + cooldown * 60) - _now_ts())) if last and cooldown > 0 else 0
|
||||
# Note: Exposes live cooldown timers for the Automations tab without changing rule behavior.
|
||||
rule['last_applied_at'] = last
|
||||
rule['cooldown_remaining_seconds'] = remaining
|
||||
return rules
|
||||
|
||||
|
||||
def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute('SELECT * FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id)).fetchone()
|
||||
if not row: raise ValueError('Rule not found')
|
||||
return _rule_row(row)
|
||||
|
||||
|
||||
def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
'name': str(rule.get('name') or 'Automation rule'),
|
||||
'enabled': bool(rule.get('enabled', True)),
|
||||
'cooldown_minutes': max(0, int(rule.get('cooldown_minutes') or 0)),
|
||||
'conditions': list(rule.get('conditions') or []),
|
||||
'effects': list(rule.get('effects') or []),
|
||||
}
|
||||
|
||||
|
||||
def export_rules(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
||||
# Note: Export contains only portable rule definitions, never DB ids or execution history.
|
||||
rules = [_portable_rule(rule) for rule in list_rules(profile_id, user_id)]
|
||||
return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'rules': rules}
|
||||
|
||||
|
||||
def import_rules(profile_id: int, payload: dict[str, Any] | list[Any], user_id: int | None = None, replace: bool = False) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
raw_rules = payload if isinstance(payload, list) else payload.get('rules', []) if isinstance(payload, dict) else []
|
||||
if not isinstance(raw_rules, list) or not raw_rules:
|
||||
raise ValueError('Import file does not contain automation rules')
|
||||
if replace:
|
||||
with connect() as conn:
|
||||
# Note: Optional replace is profile-scoped; it does not touch other profiles or history tables.
|
||||
conn.execute('DELETE FROM automation_rules WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
||||
conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,))
|
||||
imported = []
|
||||
for raw in raw_rules:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
rule = _portable_rule(raw)
|
||||
rule.pop('id', None)
|
||||
imported.append(save_rule(profile_id, rule, user_id))
|
||||
if not imported:
|
||||
raise ValueError('No valid automation rules found')
|
||||
return imported
|
||||
|
||||
|
||||
def save_rule(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule'
|
||||
conditions = data.get('conditions') or []
|
||||
effects = data.get('effects') or []
|
||||
if not isinstance(conditions, list) or not conditions: raise ValueError('Rule needs at least one condition')
|
||||
if not isinstance(effects, list) or not effects: raise ValueError('Rule needs at least one effect')
|
||||
cooldown = max(0, int(data.get('cooldown_minutes') or 0))
|
||||
enabled = 1 if data.get('enabled', True) else 0
|
||||
now = utcnow(); rule_id = int(data.get('id') or 0)
|
||||
with connect() as conn:
|
||||
if rule_id:
|
||||
conn.execute('UPDATE automation_rules SET name=?, enabled=?, conditions_json=?, effects_json=?, cooldown_minutes=?, updated_at=? WHERE id=? AND user_id=? AND profile_id=?', (name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, rule_id, user_id, profile_id))
|
||||
else:
|
||||
cur = conn.execute('INSERT INTO automation_rules(user_id,profile_id,name,enabled,conditions_json,effects_json,cooldown_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)', (user_id, profile_id, name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, now))
|
||||
rule_id = int(cur.lastrowid)
|
||||
return get_rule(rule_id, profile_id, user_id)
|
||||
|
||||
|
||||
def delete_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> None:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
conn.execute('DELETE FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id))
|
||||
conn.execute('DELETE FROM automation_rule_state WHERE rule_id=? AND profile_id=?', (rule_id, profile_id))
|
||||
|
||||
|
||||
def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
return conn.execute('SELECT * FROM automation_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', (user_id, profile_id, max(1, min(int(limit or 30), 100)))).fetchall()
|
||||
|
||||
|
||||
def clear_history(profile_id: int, user_id: int | None = None) -> int:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
# Note: Manual automation log cleanup is scoped to the active profile and current user.
|
||||
cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def _condition_true(t: dict[str, Any], cond: dict[str, Any]) -> bool:
|
||||
typ = str(cond.get('type') or '')
|
||||
if typ == 'completed': return bool(int(t.get('complete') or 0))
|
||||
if typ == 'no_seeds': return int(t.get('seeds') or 0) <= int(cond.get('seeds') or 0)
|
||||
if typ == 'ratio_gte': return float(t.get('ratio') or 0) >= float(cond.get('ratio') or 0)
|
||||
if typ == 'progress_gte': return float(t.get('progress') or 0) >= float(cond.get('progress') or 0)
|
||||
if typ == 'progress_lte': return float(t.get('progress') or 0) <= float(cond.get('progress') or 0)
|
||||
if typ == 'label_missing': return str(cond.get('label') or '').strip() not in _label_names(t.get('label'))
|
||||
if typ == 'label_has': return str(cond.get('label') or '').strip() in _label_names(t.get('label'))
|
||||
if typ == 'status': return str(t.get('status') or '').lower() == str(cond.get('status') or '').lower()
|
||||
if typ == 'path_contains': return str(cond.get('text') or '').lower() in str(t.get('path') or '').lower()
|
||||
return False
|
||||
|
||||
|
||||
def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, Any]) -> bool:
|
||||
h = str(t.get('hash') or '')
|
||||
if not h: return False
|
||||
immediate_ok = True; delayed_ok = True; now = utcnow(); now_ts = _now_ts()
|
||||
for cond in rule.get('conditions') or []:
|
||||
raw_ok = _condition_true(t, cond)
|
||||
negated = bool(cond.get('negate'))
|
||||
# Note: Negation is applied in the backend, so UI and API only store the condition flag.
|
||||
ok = (not raw_ok) if negated else raw_ok
|
||||
if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated:
|
||||
row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone()
|
||||
if ok:
|
||||
since = row['condition_since_at'] if row and row.get('condition_since_at') else now
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,last_matched_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=COALESCE(automation_rule_state.condition_since_at, excluded.condition_since_at), last_matched_at=excluded.last_matched_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, since, now, now))
|
||||
delayed_ok = delayed_ok and (now_ts - _ts(since) >= int(cond.get('minutes') or 0) * 60)
|
||||
else:
|
||||
conn.execute('UPDATE automation_rule_state SET condition_since_at=NULL, updated_at=? WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (now, rule['id'], profile_id, h)); delayed_ok = False
|
||||
else:
|
||||
immediate_ok = immediate_ok and ok
|
||||
return immediate_ok and delayed_ok
|
||||
|
||||
|
||||
def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '__rule__') -> bool:
|
||||
cooldown = int(rule.get('cooldown_minutes') or 0)
|
||||
if cooldown <= 0: return True
|
||||
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, torrent_hash)).fetchone()
|
||||
if not row or not row.get('last_applied_at'): return True
|
||||
return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60
|
||||
|
||||
|
||||
def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None:
|
||||
# Note: Cooldown is rule-level, so one batch execution blocks the whole automation until the cooldown expires.
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now))
|
||||
|
||||
|
||||
def _chunk_hashes(hashes: list[str], size: int = AUTOMATION_JOB_CHUNK_SIZE) -> list[list[str]]:
|
||||
# Note: Automation jobs use the same small-batch idea as manual bulk jobs, so long move/remove/actions remain visible and recoverable.
|
||||
safe_size = max(1, int(size or AUTOMATION_JOB_CHUNK_SIZE))
|
||||
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
|
||||
|
||||
|
||||
def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrents_by_hash: dict[str, dict[str, Any]], extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
# Note: Job context marks jobs created by automations, making the Jobs log explain what rule queued the work.
|
||||
ctx = {
|
||||
'source': 'automation',
|
||||
'rule_id': rule.get('id'),
|
||||
'rule_name': str(rule.get('name') or ''),
|
||||
'effect': eff_type,
|
||||
'bulk': len(hashes) > 1,
|
||||
'hash_count': len(hashes),
|
||||
'requested_at': utcnow(),
|
||||
'items': [
|
||||
{
|
||||
'hash': h,
|
||||
'name': str((torrents_by_hash.get(h) or {}).get('name') or ''),
|
||||
'path': str((torrents_by_hash.get(h) or {}).get('path') or ''),
|
||||
}
|
||||
for h in hashes
|
||||
],
|
||||
}
|
||||
if extra:
|
||||
ctx.update(extra)
|
||||
return ctx
|
||||
|
||||
|
||||
def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], action_name: str, hashes: list[str], payload: dict[str, Any], torrents_by_hash: dict[str, dict[str, Any]], user_id: int | None = None, context_extra: dict[str, Any] | None = None) -> list[str]:
|
||||
# Note: Light automation actions stay in one job; heavy actions are chunked for recoverability.
|
||||
job_ids: list[str] = []
|
||||
chunks = [hashes] if action_name in AUTOMATION_LIGHT_ACTIONS else _chunk_hashes(hashes)
|
||||
for index, chunk in enumerate(chunks, start=1):
|
||||
part_payload = dict(payload or {})
|
||||
part_payload['hashes'] = chunk
|
||||
part_payload['source'] = 'automation'
|
||||
if action_name not in AUTOMATION_LIGHT_ACTIONS:
|
||||
part_payload['requires_order'] = True
|
||||
extra = dict(context_extra or {})
|
||||
if len(chunks) > 1:
|
||||
extra.update({'bulk_label': f'automation-{index}', 'bulk_part': index, 'bulk_parts': len(chunks), 'parent_hash_count': len(hashes)})
|
||||
if action_name == 'move':
|
||||
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
|
||||
if action_name == 'remove':
|
||||
extra.update({'remove_data': bool(part_payload.get('remove_data'))})
|
||||
part_payload['job_context'] = _job_context(rule, str(context_extra.get('effect_type') if context_extra else action_name), chunk, torrents_by_hash, extra)
|
||||
job_ids.append(enqueue(action_name, int(profile['id']), part_payload, user_id=user_id))
|
||||
return job_ids
|
||||
|
||||
|
||||
def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]], rule: dict[str, Any], user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')]
|
||||
torrents_by_hash = {str(t.get('hash') or ''): t for t in torrents if str(t.get('hash') or '')}
|
||||
labels_by_hash = {str(t.get('hash') or ''): _label_names(t.get('label')) for t in torrents}
|
||||
applied: list[dict[str, Any]] = []
|
||||
if not hashes: return applied
|
||||
for eff in effects:
|
||||
typ = str(eff.get('type') or '')
|
||||
if typ == 'move':
|
||||
path = str(eff.get('path') or '').strip() or rtorrent.default_download_path(profile)
|
||||
payload = {
|
||||
'path': path,
|
||||
'move_data': bool(eff.get('move_data')),
|
||||
'recheck': bool(eff.get('recheck', eff.get('move_data'))),
|
||||
'keep_seeding': bool(eff.get('keep_seeding')),
|
||||
}
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'move', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'move'})
|
||||
applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'job_ids': job_ids})
|
||||
elif typ == 'add_label':
|
||||
label = str(eff.get('label') or '').strip()
|
||||
if label:
|
||||
# Note: Add-label automations are idempotent and queue only torrents that need a changed label value.
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for h in hashes:
|
||||
labels = labels_by_hash.get(h, [])
|
||||
if label in labels:
|
||||
continue
|
||||
new_labels = list(labels) + [label]
|
||||
value = _label_value(new_labels)
|
||||
labels_by_hash[h] = _label_names(value)
|
||||
grouped.setdefault(value, []).append(h)
|
||||
target_hashes = [h for group in grouped.values() for h in group]
|
||||
job_ids: list[str] = []
|
||||
for value, group_hashes in grouped.items():
|
||||
job_ids.extend(_enqueue_automation_job(profile, rule, 'set_label', group_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'add_label', 'label': label}))
|
||||
if target_hashes:
|
||||
applied.append({'type': 'add_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
|
||||
elif typ == 'remove_label':
|
||||
label = str(eff.get('label') or '').strip()
|
||||
if label:
|
||||
# Note: Remove-label automations are queued only for torrents where the requested label exists.
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for h in hashes:
|
||||
labels = labels_by_hash.get(h, [])
|
||||
if label not in labels:
|
||||
continue
|
||||
value = _label_value([x for x in labels if x != label])
|
||||
labels_by_hash[h] = _label_names(value)
|
||||
grouped.setdefault(value, []).append(h)
|
||||
target_hashes = [h for group in grouped.values() for h in group]
|
||||
job_ids: list[str] = []
|
||||
for value, group_hashes in grouped.items():
|
||||
job_ids.extend(_enqueue_automation_job(profile, rule, 'set_label', group_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'remove_label', 'label': label}))
|
||||
if target_hashes:
|
||||
applied.append({'type': 'remove_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
|
||||
elif typ == 'set_labels':
|
||||
value = _label_value(_label_names(eff.get('labels')))
|
||||
target_labels = _label_names(value)
|
||||
# Note: Set-labels queues a job only if the current labels differ from the requested exact list.
|
||||
target_hashes = [h for h in hashes if labels_by_hash.get(h, []) != target_labels]
|
||||
for h in target_hashes:
|
||||
labels_by_hash[h] = list(target_labels)
|
||||
if target_hashes:
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'set_label', target_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'set_labels', 'labels': value})
|
||||
applied.append({'type': 'set_labels', 'labels': value, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
|
||||
elif typ in {'pause', 'stop', 'start', 'resume', 'recheck', 'reannounce'}:
|
||||
# Note: Runtime actions are queued as jobs too, so automation activity is visible in the Jobs panel.
|
||||
job_ids = _enqueue_automation_job(profile, rule, typ, hashes, {}, torrents_by_hash, user_id, {'effect_type': typ})
|
||||
applied.append({'type': typ, 'count': len(hashes), 'target_hashes': hashes, 'job_ids': job_ids})
|
||||
elif typ == 'remove':
|
||||
# Note: Remove is supported for automation payloads and still goes through ordered worker jobs.
|
||||
payload = {'remove_data': bool(eff.get('remove_data'))}
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'remove', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'remove'})
|
||||
applied.append({'type': 'remove', 'count': len(hashes), 'target_hashes': hashes, 'remove_data': payload['remove_data'], 'job_ids': job_ids})
|
||||
return applied
|
||||
|
||||
|
||||
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False, rule_id: int | None = None) -> dict[str, Any]:
|
||||
profile = profile or active_profile()
|
||||
if not profile: return {'ok': False, 'error': 'No active rTorrent profile'}
|
||||
user_id = user_id or default_user_id(); profile_id = int(profile['id'])
|
||||
rules = [r for r in list_rules(profile_id, user_id) if (rule_id is None or int(r.get('id') or 0) == int(rule_id)) and (force or int(r.get('enabled') or 0))]
|
||||
if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0}
|
||||
torrents = rtorrent.list_torrents(profile); applied = []; batches = []; now = utcnow()
|
||||
planned: list[dict[str, Any]] = []
|
||||
with connect() as conn:
|
||||
for rule in rules:
|
||||
# Note: This pass only matches rules and updates condition timers; job creation is intentionally delayed until after this DB transaction commits.
|
||||
if not force and not _cooldown_ok(conn, rule, profile_id):
|
||||
continue
|
||||
matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)]
|
||||
if not matched:
|
||||
continue
|
||||
hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')]
|
||||
if hashes:
|
||||
planned.append({'rule': rule, 'matched': matched, 'hashes': hashes})
|
||||
for item in planned:
|
||||
rule = item['rule']
|
||||
matched = item['matched']
|
||||
hashes = item['hashes']
|
||||
# Note: Automation jobs are enqueued outside the rule-state transaction, preventing SQLite self-locks when enqueue() writes to jobs.
|
||||
try:
|
||||
actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, user_id)
|
||||
except Exception as exc:
|
||||
actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}]
|
||||
changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])})
|
||||
if not actions or not changed_hashes:
|
||||
# Note: Matching torrents with no real action are not logged and do not restart the cooldown.
|
||||
continue
|
||||
history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions]
|
||||
matched_by_hash = {str(t.get('hash') or ''): t for t in matched}
|
||||
with connect() as conn:
|
||||
# Note: State/history writes happen after enqueue succeeds, so failed job creation does not create misleading automation history.
|
||||
for h in changed_hashes:
|
||||
t = matched_by_hash.get(h, {})
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_matched_at,last_applied_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_matched_at=excluded.last_matched_at, last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now, now))
|
||||
applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]})
|
||||
_mark_rule_cooldown(conn, rule, profile_id, now)
|
||||
torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents'
|
||||
torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}'
|
||||
conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (user_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now))
|
||||
batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(changed_hashes), 'actions': history_actions})
|
||||
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches}
|
||||
286
pytorrent/services/backup.py
Normal file
286
pytorrent/services/backup.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
|
||||
# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped.
|
||||
BACKUP_TABLES = [
|
||||
"users", "user_profile_permissions", "user_preferences", "rtorrent_profiles",
|
||||
"disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules",
|
||||
"smart_queue_settings", "smart_queue_exclusions", "automation_rules",
|
||||
"rtorrent_config_overrides", "app_settings", "download_plan_settings",
|
||||
]
|
||||
|
||||
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||
"enabled": False,
|
||||
"interval_hours": 24,
|
||||
"retention_days": 30,
|
||||
"last_run_at": None,
|
||||
}
|
||||
BACKUP_PREVIEW_VALUE_LIMIT = 80
|
||||
BACKUP_PREVIEW_ROW_LIMIT = 3
|
||||
BACKUP_PREVIEW_SENSITIVE_KEYS = {
|
||||
"password",
|
||||
"password_hash",
|
||||
"token",
|
||||
"token_hash",
|
||||
"api_key",
|
||||
"secret",
|
||||
}
|
||||
AUTO_BACKUP_SETTINGS_KEY = "backup:auto"
|
||||
_scheduler_started = False
|
||||
_scheduler_lock = threading.Lock()
|
||||
|
||||
|
||||
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
"""Create a settings backup and return a table-count summary.
|
||||
|
||||
Note: The automatic flag is metadata only; restore/download behavior remains unchanged.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
with connect() as conn:
|
||||
for table in BACKUP_TABLES:
|
||||
try:
|
||||
payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall()
|
||||
except Exception:
|
||||
payload["tables"][table] = []
|
||||
cur = conn.execute(
|
||||
"INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)",
|
||||
(user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]),
|
||||
)
|
||||
backup_id = cur.lastrowid
|
||||
return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}}
|
||||
|
||||
|
||||
def list_backups(user_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
payload = _loads(row.get("payload_json") or "{}")
|
||||
tables = payload.get("tables") or {}
|
||||
result.append({
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"created_at": row.get("created_at"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": {key: len(value or []) for key, value in tables.items()},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("Backup not found")
|
||||
return json.loads(row["payload_json"] or "{}")
|
||||
|
||||
|
||||
def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
tables = payload.get("tables") or {}
|
||||
restored = {}
|
||||
with connect() as conn:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table in BACKUP_TABLES:
|
||||
rows = tables.get(table) or []
|
||||
if not rows:
|
||||
continue
|
||||
columns = list(rows[0].keys())
|
||||
placeholders = ",".join("?" for _ in columns)
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for row in rows:
|
||||
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
|
||||
restored[table] = len(rows)
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return {"restored": restored}
|
||||
|
||||
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM app_backups WHERE id=? AND user_id=?",
|
||||
(backup_id, user_id),
|
||||
)
|
||||
if not cur.rowcount:
|
||||
raise ValueError("Backup not found")
|
||||
return {"deleted": backup_id}
|
||||
|
||||
|
||||
|
||||
|
||||
def _loads(value: str) -> dict:
|
||||
try:
|
||||
data = json.loads(value or "{}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _settings_row_key(user_id: int | None = None) -> str:
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}"
|
||||
|
||||
|
||||
def _latest_backup_created_at(user_id: int) -> str | None:
|
||||
"""Return the newest persisted backup timestamp for scheduler recovery after restarts.
|
||||
|
||||
Note: Automatic scheduling is based on the latest database backup record, so process
|
||||
restarts cannot create repeated backups before the configured interval elapses.
|
||||
"""
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return str(row["created_at"] or "") if row and row.get("created_at") else None
|
||||
|
||||
|
||||
def _preview_value(value: object) -> object:
|
||||
"""Return a safe, compact value for backup previews without exposing secrets."""
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
text = str(value)
|
||||
return text if len(text) <= BACKUP_PREVIEW_VALUE_LIMIT else f"{text[:BACKUP_PREVIEW_VALUE_LIMIT]}..."
|
||||
|
||||
|
||||
def _preview_row(row: dict) -> dict:
|
||||
output = {}
|
||||
for key, value in row.items():
|
||||
lowered = str(key).lower()
|
||||
if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS):
|
||||
output[key] = "[hidden]"
|
||||
else:
|
||||
output[key] = _preview_value(value)
|
||||
return output
|
||||
|
||||
|
||||
def get_auto_backup_settings(user_id: int | None = None) -> dict:
|
||||
"""Return automatic backup schedule settings for the current user.
|
||||
|
||||
Note: The UI uses this as the single source for interval and retention controls.
|
||||
"""
|
||||
key = _settings_row_key(user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")}
|
||||
settings["enabled"] = bool(settings.get("enabled"))
|
||||
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
|
||||
settings["retention_days"] = max(1, int(settings.get("retention_days") or 30))
|
||||
return settings
|
||||
|
||||
|
||||
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
|
||||
"""Persist automatic backup schedule settings after validating UI input.
|
||||
|
||||
Note: Minimum interval is one hour to avoid creating excessive database rows.
|
||||
"""
|
||||
current = get_auto_backup_settings(user_id)
|
||||
settings = {
|
||||
**current,
|
||||
"enabled": bool(data.get("enabled")),
|
||||
"interval_hours": max(1, int(data.get("interval_hours") or current["interval_hours"])),
|
||||
"retention_days": max(1, int(data.get("retention_days") or current["retention_days"])),
|
||||
"last_run_at": data.get("last_run_at", current.get("last_run_at")),
|
||||
}
|
||||
key = _settings_row_key(user_id)
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings)))
|
||||
return settings
|
||||
|
||||
|
||||
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
"""Return a compact backup preview without exposing the full JSON payload in the list view.
|
||||
|
||||
Note: The preview shows included tables and example keys so users can verify settings coverage.
|
||||
"""
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
tables = payload.get("tables") or {}
|
||||
return {
|
||||
"version": payload.get("version"),
|
||||
"created_at": payload.get("created_at"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": [
|
||||
{
|
||||
"name": table,
|
||||
"rows": len(rows or []),
|
||||
"columns": list((rows[0] or {}).keys()) if rows else [],
|
||||
"sample": [_preview_row(dict(row)) for row in (rows or [])[:BACKUP_PREVIEW_ROW_LIMIT]],
|
||||
}
|
||||
for table, rows in tables.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int:
|
||||
"""Delete backups older than the configured retention window for the selected user.
|
||||
|
||||
Note: Retention is applied only to backup records, not to restored application settings.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
|
||||
with connect() as conn:
|
||||
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at<?", (user_id, cutoff))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
|
||||
"""Create an automatic backup when the saved interval has elapsed.
|
||||
|
||||
Note: The scheduler calls this periodically, while the UI controls the interval and retention values.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
settings = get_auto_backup_settings(user_id)
|
||||
if not settings.get("enabled"):
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id)
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None
|
||||
except Exception:
|
||||
last = None
|
||||
if last and now - last < timedelta(hours=settings["interval_hours"]):
|
||||
if settings.get("last_run_at") != last_value:
|
||||
settings["last_run_at"] = last_value
|
||||
save_auto_backup_settings(settings, user_id)
|
||||
return None
|
||||
backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
|
||||
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds")
|
||||
save_auto_backup_settings(settings, user_id)
|
||||
prune_old_backups(user_id, settings["retention_days"])
|
||||
return backup
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
"""Start a lightweight automatic-backup scheduler.
|
||||
|
||||
Note: It scans configured users and never blocks normal request handling.
|
||||
"""
|
||||
global _scheduler_started
|
||||
with _scheduler_lock:
|
||||
if _scheduler_started:
|
||||
return
|
||||
_scheduler_started = True
|
||||
|
||||
def loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall()
|
||||
user_ids = [int(row["id"]) for row in rows] or [default_user_id()]
|
||||
for uid in user_ids:
|
||||
maybe_create_automatic_backup(uid)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(300)
|
||||
|
||||
threading.Thread(target=loop, daemon=True, name="pytorrent-backup-scheduler").start()
|
||||
41
pytorrent/services/disk_guard.py
Normal file
41
pytorrent/services/disk_guard.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import download_planner
|
||||
|
||||
|
||||
def check(profile: dict, force: bool = False) -> dict[str, Any]:
|
||||
"""Compatibility check for disk protection.
|
||||
|
||||
Disk protection is now configured in Download Planner. The planner performs
|
||||
the pause/resume action; this helper only reports whether the current disk
|
||||
source is over the planner threshold.
|
||||
"""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
return {"ok": False, "enabled": False, "error": "Missing profile id"}
|
||||
settings = download_planner.get_settings(profile_id)
|
||||
enabled = bool(settings.get("enabled") and settings.get("auto_pause_disk_enabled"))
|
||||
if not enabled:
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id}
|
||||
usage = download_planner.disk_usage(profile, int(settings.get("user_id") or 0) or None) or {}
|
||||
threshold = max(1, min(100, int(settings.get("auto_pause_disk_percent") or 95)))
|
||||
percent = float(usage.get("percent") or 0)
|
||||
triggered = bool(usage.get("ok") and percent >= threshold)
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"profile_id": profile_id,
|
||||
"triggered": triggered,
|
||||
"rules": [{"threshold": threshold, "percent": percent, "mode": usage.get("mode"), "path": usage.get("path"), "usage": usage}] if triggered else [],
|
||||
}
|
||||
|
||||
|
||||
def assert_can_start_download(profile: dict) -> None:
|
||||
result = check(profile, force=True)
|
||||
if result.get("enabled") and result.get("triggered"):
|
||||
rule = (result.get("rules") or [{}])[0]
|
||||
raise RuntimeError(
|
||||
f"Planner disk protection blocked download start: {rule.get('percent')}% >= {rule.get('threshold')}% ({rule.get('path')})"
|
||||
)
|
||||
551
pytorrent/services/download_planner.py
Normal file
551
pytorrent/services/download_planner.py
Normal file
@@ -0,0 +1,551 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
|
||||
DEFAULTS = {
|
||||
"enabled": False,
|
||||
"name": "Default download plan",
|
||||
"profile_name": "night mode",
|
||||
"dry_run": False,
|
||||
"manual_override_until": "",
|
||||
"night_only_enabled": False,
|
||||
"night_start": "23:00",
|
||||
"night_end": "07:00",
|
||||
"quiet_hours_enabled": False,
|
||||
"quiet_start": "22:00",
|
||||
"quiet_end": "06:00",
|
||||
"weekday_down": 0,
|
||||
"weekday_up": 0,
|
||||
"weekend_down": 0,
|
||||
"weekend_up": 0,
|
||||
"hourly_schedule_enabled": False,
|
||||
"hourly_schedule": [],
|
||||
"auto_pause_cpu_enabled": False,
|
||||
"auto_pause_cpu_percent": 90,
|
||||
"auto_pause_disk_enabled": False,
|
||||
"auto_pause_disk_percent": 95,
|
||||
"network_protection_enabled": False,
|
||||
"network_max_down": 0,
|
||||
"network_max_up": 0,
|
||||
"load_protection_enabled": False,
|
||||
"load_cpu_percent": 95,
|
||||
"auto_resume": True,
|
||||
"auto_resume_grace_seconds": 0,
|
||||
"check_interval_seconds": 30,
|
||||
}
|
||||
|
||||
_LAST_RUN: dict[int, float] = {}
|
||||
_LAST_LIMITS: dict[int, tuple[int, int]] = {}
|
||||
_HIGH_CPU_SINCE: dict[int, float] = {}
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _int(value: Any, default: int = 0, lo: int = 0, hi: int = 10**9) -> int:
|
||||
try:
|
||||
return max(lo, min(hi, int(value)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _hourly_schedule(value: Any) -> list[dict]:
|
||||
rows = value if isinstance(value, list) else []
|
||||
by_hour: dict[int, dict] = {}
|
||||
for item in rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
hour = int(item.get("hour"))
|
||||
except Exception:
|
||||
continue
|
||||
if hour < 0 or hour > 23:
|
||||
continue
|
||||
by_hour[hour] = {"hour": hour, "down": _int(item.get("down"), 0), "up": _int(item.get("up"), 0)}
|
||||
return [by_hour.get(hour, {"hour": hour, "down": 0, "up": 0}) for hour in range(24)]
|
||||
|
||||
def _hourly_limit_for(settings: dict, hour: int) -> tuple[int, int] | None:
|
||||
if not settings.get("hourly_schedule_enabled"):
|
||||
return None
|
||||
rows = settings.get("hourly_schedule") or []
|
||||
for item in rows:
|
||||
if int(item.get("hour", -1)) == int(hour):
|
||||
return int(item.get("down") or 0), int(item.get("up") or 0)
|
||||
return 0, 0
|
||||
|
||||
|
||||
def _time_minutes(value: str, fallback: str) -> int:
|
||||
text = str(value or fallback).strip()
|
||||
try:
|
||||
hh, mm = text.split(":", 1)
|
||||
return max(0, min(1439, int(hh) * 60 + int(mm)))
|
||||
except Exception:
|
||||
hh, mm = fallback.split(":", 1)
|
||||
return int(hh) * 60 + int(mm)
|
||||
|
||||
|
||||
def _in_window(now_min: int, start: str, end: str) -> bool:
|
||||
s = _time_minutes(start, "00:00")
|
||||
e = _time_minutes(end, "00:00")
|
||||
if s == e:
|
||||
return True
|
||||
if s < e:
|
||||
return s <= now_min < e
|
||||
return now_min >= s or now_min < e
|
||||
|
||||
|
||||
def normalize(data: dict | None) -> dict:
|
||||
raw = {**DEFAULTS, **(data or {})}
|
||||
return {
|
||||
"enabled": _bool(raw.get("enabled")),
|
||||
"name": str(raw.get("name") or DEFAULTS["name"]).strip()[:120],
|
||||
"profile_name": str(raw.get("profile_name") or raw.get("name") or DEFAULTS["profile_name"]).strip()[:80],
|
||||
"dry_run": _bool(raw.get("dry_run")),
|
||||
"manual_override_until": str(raw.get("manual_override_until") or "")[:40],
|
||||
"night_only_enabled": _bool(raw.get("night_only_enabled")),
|
||||
"night_start": str(raw.get("night_start") or DEFAULTS["night_start"])[:5],
|
||||
"night_end": str(raw.get("night_end") or DEFAULTS["night_end"])[:5],
|
||||
"quiet_hours_enabled": _bool(raw.get("quiet_hours_enabled")),
|
||||
"quiet_start": str(raw.get("quiet_start") or DEFAULTS["quiet_start"])[:5],
|
||||
"quiet_end": str(raw.get("quiet_end") or DEFAULTS["quiet_end"])[:5],
|
||||
"weekday_down": _int(raw.get("weekday_down"), 0),
|
||||
"weekday_up": _int(raw.get("weekday_up"), 0),
|
||||
"weekend_down": _int(raw.get("weekend_down"), 0),
|
||||
"weekend_up": _int(raw.get("weekend_up"), 0),
|
||||
"hourly_schedule_enabled": _bool(raw.get("hourly_schedule_enabled")),
|
||||
"hourly_schedule": _hourly_schedule(raw.get("hourly_schedule")),
|
||||
"auto_pause_cpu_enabled": _bool(raw.get("auto_pause_cpu_enabled")),
|
||||
"auto_pause_cpu_percent": _int(raw.get("auto_pause_cpu_percent"), 90, 1, 100),
|
||||
"auto_pause_disk_enabled": _bool(raw.get("auto_pause_disk_enabled")),
|
||||
"auto_pause_disk_percent": _int(raw.get("auto_pause_disk_percent"), 95, 1, 100),
|
||||
"network_protection_enabled": _bool(raw.get("network_protection_enabled")),
|
||||
"network_max_down": _int(raw.get("network_max_down"), 0),
|
||||
"network_max_up": _int(raw.get("network_max_up"), 0),
|
||||
"load_protection_enabled": _bool(raw.get("load_protection_enabled")),
|
||||
"load_cpu_percent": _int(raw.get("load_cpu_percent"), 95, 1, 100),
|
||||
"auto_resume": _bool(raw.get("auto_resume")),
|
||||
"auto_resume_grace_seconds": _int(raw.get("auto_resume_grace_seconds"), 0, 0, 86400),
|
||||
"check_interval_seconds": _int(raw.get("check_interval_seconds"), 30, 10, 3600),
|
||||
}
|
||||
|
||||
|
||||
def _row(user_id: int, profile_id: int) -> dict | None:
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
|
||||
|
||||
def _preference_row_for_disk_source(profile_id: int, user_id: int | None = None) -> dict | None:
|
||||
from . import preferences
|
||||
user_id = user_id or default_user_id()
|
||||
return preferences.get_disk_monitor_preferences(profile_id, user_id)
|
||||
|
||||
def _legacy_disk_guard_defaults(profile_id: int, user_id: int | None = None) -> dict:
|
||||
pref = _preference_row_for_disk_source(profile_id, user_id)
|
||||
if not pref or not pref.get("disk_monitor_stop_enabled"):
|
||||
return {}
|
||||
return {
|
||||
"enabled": True,
|
||||
"auto_pause_disk_enabled": True,
|
||||
"auto_pause_disk_percent": _int(pref.get("disk_monitor_stop_threshold"), 95, 1, 100),
|
||||
"auto_resume": True,
|
||||
}
|
||||
|
||||
|
||||
def _history_key(profile_id: int) -> str:
|
||||
return f"download_planner.history.{int(profile_id)}"
|
||||
|
||||
|
||||
def _override_key(profile_id: int) -> str:
|
||||
return f"download_planner.override_until.{int(profile_id)}"
|
||||
|
||||
|
||||
def _parse_iso_ts(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
text = str(value).replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(text).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _override_until(profile_id: int) -> str:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_override_key(profile_id),)).fetchone()
|
||||
return str(row.get("value") or "") if row else ""
|
||||
|
||||
|
||||
def set_manual_override(profile_id: int, seconds: int) -> dict:
|
||||
until = ""
|
||||
seconds = _int(seconds, 0, 0, 86400)
|
||||
if seconds:
|
||||
until = datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat()
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_override_key(profile_id), until))
|
||||
return {"manual_override_until": until, "seconds": seconds}
|
||||
|
||||
|
||||
def _append_history(profile_id: int, event: str, payload: dict | None = None) -> None:
|
||||
payload = payload or {}
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_history_key(profile_id),)).fetchone()
|
||||
try:
|
||||
items = json.loads(row.get("value") or "[]") if row else []
|
||||
except Exception:
|
||||
items = []
|
||||
items.append({"at": utcnow(), "event": str(event), **payload})
|
||||
items = items[-80:]
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_history_key(profile_id), json.dumps(items)))
|
||||
|
||||
|
||||
def _history_items(profile_id: int) -> list[dict]:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_history_key(profile_id),)).fetchone()
|
||||
try:
|
||||
items = json.loads(row.get("value") or "[]") if row else []
|
||||
except Exception:
|
||||
items = []
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
|
||||
def history(profile_id: int, limit: int = 40) -> list[dict]:
|
||||
items = _history_items(profile_id)
|
||||
return list(reversed(items[-max(1, min(200, int(limit))):]))
|
||||
|
||||
|
||||
def history_count(profile_id: int) -> int:
|
||||
return len(_history_items(profile_id))
|
||||
|
||||
|
||||
def clear_history(profile_id: int) -> int:
|
||||
deleted = history_count(profile_id)
|
||||
with connect() as conn:
|
||||
# Note: Planner history is stored per profile in app_settings; clearing it does not change saved Planner rules.
|
||||
conn.execute("DELETE FROM app_settings WHERE key=?", (_history_key(profile_id),))
|
||||
return deleted
|
||||
|
||||
|
||||
def _profile_label(settings: dict) -> str:
|
||||
return str(settings.get("profile_name") or settings.get("name") or "Planner")
|
||||
|
||||
|
||||
def _next_boundary(now: datetime, settings: dict) -> str:
|
||||
candidates: list[datetime] = []
|
||||
for hour in range(24):
|
||||
if settings.get("hourly_schedule_enabled"):
|
||||
dt = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if dt <= now:
|
||||
dt = dt + __import__("datetime").timedelta(days=1)
|
||||
candidates.append(dt)
|
||||
for key in ("night_start", "night_end", "quiet_start", "quiet_end"):
|
||||
value = settings.get(key)
|
||||
if not value:
|
||||
continue
|
||||
minute = _time_minutes(str(value), "00:00")
|
||||
dt = now.replace(hour=minute // 60, minute=minute % 60, second=0, microsecond=0)
|
||||
if dt <= now:
|
||||
dt = dt.replace(day=dt.day) + __import__("datetime").timedelta(days=1)
|
||||
candidates.append(dt)
|
||||
return min(candidates).isoformat() if candidates else ""
|
||||
|
||||
def get_settings(profile_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
row = _row(user_id, profile_id)
|
||||
if not row:
|
||||
migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)})
|
||||
return {**migrated, "profile_id": int(profile_id), "user_id": int(user_id)}
|
||||
try:
|
||||
data = json.loads(row.get("settings_json") or "{}")
|
||||
except Exception:
|
||||
data = {}
|
||||
settings = {**normalize(data), "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": row.get("updated_at")}
|
||||
runtime_override = _override_until(int(profile_id))
|
||||
if runtime_override:
|
||||
settings["manual_override_until"] = runtime_override
|
||||
return settings
|
||||
|
||||
|
||||
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
settings = normalize(data)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET settings_json=excluded.settings_json, updated_at=excluded.updated_at
|
||||
""",
|
||||
(user_id, profile_id, json.dumps(settings), now),
|
||||
)
|
||||
return {**settings, "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": now}
|
||||
|
||||
|
||||
def _active_downloading_hashes(profile: dict) -> list[str]:
|
||||
rows = rtorrent.list_torrents(profile)
|
||||
hashes: list[str] = []
|
||||
for row in rows:
|
||||
if int(row.get("complete") or 0):
|
||||
continue
|
||||
if int(row.get("state") or 0) and not row.get("paused"):
|
||||
h = str(row.get("hash") or "")
|
||||
if h:
|
||||
hashes.append(h)
|
||||
return hashes
|
||||
|
||||
|
||||
def _remember_paused(profile_id: int, hashes: list[str], reason: str) -> None:
|
||||
if not hashes:
|
||||
return
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
for h in hashes:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO download_plan_paused(profile_id,torrent_hash,reason,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
(profile_id, h, reason, now, now),
|
||||
)
|
||||
|
||||
|
||||
def _planned_paused(profile_id: int) -> list[str]:
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT torrent_hash FROM download_plan_paused WHERE profile_id=?", (profile_id,)).fetchall()
|
||||
return [str(row.get("torrent_hash") or "") for row in rows if row.get("torrent_hash")]
|
||||
|
||||
|
||||
def _clear_planned(profile_id: int, hashes: list[str] | None = None) -> None:
|
||||
with connect() as conn:
|
||||
if hashes:
|
||||
conn.executemany("DELETE FROM download_plan_paused WHERE profile_id=? AND torrent_hash=?", [(profile_id, h) for h in hashes])
|
||||
else:
|
||||
conn.execute("DELETE FROM download_plan_paused WHERE profile_id=?", (profile_id,))
|
||||
|
||||
|
||||
def disk_usage(profile: dict, user_id: int | None = None) -> dict | None:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
pref = _preference_row_for_disk_source(profile_id, user_id) or {}
|
||||
try:
|
||||
paths = json.loads(pref.get("disk_monitor_paths_json") or "[]")
|
||||
except Exception:
|
||||
paths = []
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
try:
|
||||
return rtorrent.disk_usage_for_paths(
|
||||
profile,
|
||||
[str(p) for p in paths if str(p or "").strip()],
|
||||
str(pref.get("disk_monitor_mode") or "default"),
|
||||
str(pref.get("disk_monitor_selected_path") or ""),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _disk_percent(profile: dict, user_id: int | None = None) -> float | None:
|
||||
usage = disk_usage(profile, user_id)
|
||||
if usage and usage.get("ok"):
|
||||
return float(usage.get("percent") or 0)
|
||||
return None
|
||||
|
||||
|
||||
def evaluate(profile: dict, settings: dict | None = None, now: datetime | None = None) -> dict:
|
||||
settings = normalize(settings or get_settings(int(profile.get("id") or 0)))
|
||||
now = now or datetime.now().astimezone()
|
||||
override_until = settings.get("manual_override_until") or _override_until(int(profile.get("id") or 0))
|
||||
override_active = bool(_parse_iso_ts(override_until) > time.time())
|
||||
now_min = now.hour * 60 + now.minute
|
||||
weekend = now.weekday() >= 5
|
||||
reasons: list[str] = []
|
||||
pause_downloads = False
|
||||
quiet = bool(settings["quiet_hours_enabled"] and _in_window(now_min, settings["quiet_start"], settings["quiet_end"]))
|
||||
in_night = _in_window(now_min, settings["night_start"], settings["night_end"])
|
||||
if quiet:
|
||||
pause_downloads = True
|
||||
reasons.append("quiet_hours")
|
||||
if settings["night_only_enabled"] and not in_night:
|
||||
pause_downloads = True
|
||||
reasons.append("outside_night_window")
|
||||
hourly_limits = _hourly_limit_for(settings, now.hour)
|
||||
if hourly_limits is not None:
|
||||
down, up = hourly_limits
|
||||
reasons.append("hourly_schedule")
|
||||
else:
|
||||
down = int(settings["weekend_down"] if weekend else settings["weekday_down"])
|
||||
up = int(settings["weekend_up"] if weekend else settings["weekday_up"])
|
||||
if quiet or pause_downloads:
|
||||
down = 0
|
||||
cpu = None
|
||||
if settings["load_protection_enabled"]:
|
||||
cpu_load = float(psutil.cpu_percent(interval=None))
|
||||
if cpu_load >= float(settings["load_cpu_percent"]):
|
||||
pause_downloads = True
|
||||
reasons.append("high_load")
|
||||
if settings["auto_pause_cpu_enabled"]:
|
||||
cpu = float(psutil.cpu_percent(interval=None))
|
||||
pid = int(profile.get("id") or 0)
|
||||
if cpu >= float(settings["auto_pause_cpu_percent"]):
|
||||
_HIGH_CPU_SINCE.setdefault(pid, time.monotonic())
|
||||
if time.monotonic() - _HIGH_CPU_SINCE[pid] >= 10:
|
||||
pause_downloads = True
|
||||
reasons.append("high_cpu")
|
||||
else:
|
||||
_HIGH_CPU_SINCE.pop(pid, None)
|
||||
disk = None
|
||||
if settings["auto_pause_disk_enabled"]:
|
||||
disk = _disk_percent(profile, int(settings.get("user_id") or default_user_id()))
|
||||
if disk is not None and disk >= float(settings["auto_pause_disk_percent"]):
|
||||
pause_downloads = True
|
||||
reasons.append("high_disk")
|
||||
if settings["network_protection_enabled"]:
|
||||
nd = int(settings.get("network_max_down") or 0)
|
||||
nu = int(settings.get("network_max_up") or 0)
|
||||
if nd and (not down or down > nd):
|
||||
down = nd
|
||||
reasons.append("network_limit_down")
|
||||
if nu and (not up or up > nu):
|
||||
up = nu
|
||||
reasons.append("network_limit_up")
|
||||
if override_active:
|
||||
pause_downloads = False
|
||||
reasons = ["manual_override"]
|
||||
return {
|
||||
"enabled": bool(settings["enabled"]),
|
||||
"profile_id": int(profile.get("id") or 0),
|
||||
"profile_name": _profile_label(settings),
|
||||
"dry_run": bool(settings.get("dry_run")),
|
||||
"manual_override_until": override_until if override_active else "",
|
||||
"matched_rule": reasons[0] if reasons else ("weekend" if weekend else "weekday"),
|
||||
"next_change_at": _next_boundary(now, settings),
|
||||
"pause_downloads": pause_downloads,
|
||||
"reasons": reasons,
|
||||
"down": down,
|
||||
"up": up,
|
||||
"weekend": weekend,
|
||||
"quiet": quiet,
|
||||
"in_night_window": in_night,
|
||||
"cpu": cpu,
|
||||
"disk": disk,
|
||||
}
|
||||
|
||||
|
||||
def enforce(profile: dict, force: bool = False) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
settings = get_settings(profile_id)
|
||||
if not settings.get("enabled"):
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile)}
|
||||
now = time.monotonic()
|
||||
interval = int(settings.get("check_interval_seconds") or 30)
|
||||
if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
|
||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True}
|
||||
_LAST_RUN[profile_id] = now
|
||||
decision = evaluate(profile, settings)
|
||||
result: dict[str, Any] = {"ok": True, "enabled": True, **decision, "limits_changed": False, "paused": 0, "resumed": 0}
|
||||
wanted_limits = (int(decision["down"]), int(decision["up"]))
|
||||
dry_run = bool(settings.get("dry_run")) or bool(force and str(profile.get("dry_run") or "").lower() == "true")
|
||||
result["dry_run"] = dry_run
|
||||
if force or _LAST_LIMITS.get(profile_id) != wanted_limits:
|
||||
if not dry_run:
|
||||
rtorrent.set_limits(profile, wanted_limits[0], wanted_limits[1])
|
||||
_LAST_LIMITS[profile_id] = wanted_limits
|
||||
result["limits_changed"] = True
|
||||
_append_history(profile_id, "speed_limit_change", {"down": wanted_limits[0], "up": wanted_limits[1], "dry_run": dry_run})
|
||||
if decision["pause_downloads"]:
|
||||
hashes = _active_downloading_hashes(profile)
|
||||
if hashes:
|
||||
action = {"dry_run": True} if dry_run else rtorrent.action(profile, hashes, "pause", {"source": "download_planner", "reasons": decision["reasons"]})
|
||||
if not dry_run:
|
||||
_remember_paused(profile_id, hashes, ",".join(decision["reasons"]))
|
||||
result["paused"] = len(hashes)
|
||||
result["pause_result"] = action
|
||||
_append_history(profile_id, "paused_torrents", {"count": len(hashes), "reasons": decision["reasons"], "dry_run": dry_run})
|
||||
if "high_cpu" in decision["reasons"] or "high_load" in decision["reasons"]:
|
||||
_append_history(profile_id, "cpu_protection_trigger", {"cpu": decision.get("cpu"), "dry_run": dry_run})
|
||||
if "high_disk" in decision["reasons"]:
|
||||
_append_history(profile_id, "disk_protection_trigger", {"disk": decision.get("disk"), "dry_run": dry_run})
|
||||
elif settings.get("auto_resume"):
|
||||
grace = int(settings.get("auto_resume_grace_seconds") or 0)
|
||||
last_trigger = 0.0
|
||||
for item in history(profile_id, 20):
|
||||
if item.get("event") in {"paused_torrents", "cpu_protection_trigger", "disk_protection_trigger"}:
|
||||
last_trigger = _parse_iso_ts(item.get("at"))
|
||||
break
|
||||
if grace and last_trigger and time.time() - last_trigger < grace:
|
||||
result["resume_wait_seconds"] = int(grace - (time.time() - last_trigger))
|
||||
else:
|
||||
hashes = _planned_paused(profile_id)
|
||||
if hashes:
|
||||
action = {"dry_run": True} if dry_run else rtorrent.action(profile, hashes, "resume", {"source": "download_planner"})
|
||||
if not dry_run:
|
||||
_clear_planned(profile_id, hashes)
|
||||
result["resumed"] = len(hashes)
|
||||
result["resume_result"] = action
|
||||
_append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run})
|
||||
result["history"] = history(profile_id, 20)
|
||||
result["history_total"] = history_count(profile_id)
|
||||
result["preview"] = preview(profile)
|
||||
return result
|
||||
|
||||
|
||||
def preview(profile: dict) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
settings = get_settings(profile_id)
|
||||
decision = evaluate(profile, settings)
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
"profile_name": decision.get("profile_name"),
|
||||
"matched_rule": decision.get("matched_rule"),
|
||||
"next_change_at": decision.get("next_change_at"),
|
||||
"pause_downloads": decision.get("pause_downloads"),
|
||||
"down": decision.get("down"),
|
||||
"up": decision.get("up"),
|
||||
"reasons": decision.get("reasons", []),
|
||||
"manual_override_until": decision.get("manual_override_until", ""),
|
||||
"dry_run": decision.get("dry_run", False),
|
||||
}
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
from .preferences import active_profile
|
||||
from .websocket import emit_profile_event
|
||||
from . import auth
|
||||
profiles: list[dict]
|
||||
if auth.enabled():
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
||||
else:
|
||||
profile = active_profile()
|
||||
profiles = [profile] if profile else []
|
||||
for profile in profiles:
|
||||
try:
|
||||
result = enforce(profile, force=False)
|
||||
if socketio and result.get("enabled") and not result.get("skipped"):
|
||||
emit_profile_event(socketio, "download_plan_update", result, int(profile["id"]))
|
||||
except Exception as exc:
|
||||
if socketio:
|
||||
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": int(profile.get("id") or 0), "error": str(exc)}, int(profile.get("id") or 0))
|
||||
except Exception:
|
||||
pass
|
||||
if socketio:
|
||||
socketio.sleep(30)
|
||||
else:
|
||||
time.sleep(30)
|
||||
if socketio:
|
||||
socketio.start_background_task(loop)
|
||||
108
pytorrent/services/frontend_assets.py
Normal file
108
pytorrent/services/frontend_assets.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR, USE_OFFLINE_LIBS
|
||||
|
||||
LIBS_STATIC_DIR = "libs"
|
||||
LIBS_DIR = BASE_DIR / "pytorrent" / "static" / LIBS_STATIC_DIR
|
||||
BOOTSTRAP_VERSION = "5.3.3"
|
||||
BOOTSWATCH_VERSION = "5.3.3"
|
||||
FONTAWESOME_VERSION = "6.5.2"
|
||||
FLAG_ICONS_VERSION = "7.2.3"
|
||||
SWAGGER_UI_VERSION = "5"
|
||||
SOCKET_IO_VERSION = "4.7.5"
|
||||
|
||||
BOOTSTRAP_THEMES = (
|
||||
"default",
|
||||
"flatly",
|
||||
"litera",
|
||||
"lumen",
|
||||
"minty",
|
||||
"sketchy",
|
||||
"solar",
|
||||
"spacelab",
|
||||
"united",
|
||||
"zephyr",
|
||||
)
|
||||
|
||||
STATIC_ASSETS = {
|
||||
"bootstrap_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js",
|
||||
},
|
||||
"fontawesome_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/fontawesome/{FONTAWESOME_VERSION}/css/all.min.css",
|
||||
"cdn": f"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/{FONTAWESOME_VERSION}/css/all.min.css",
|
||||
},
|
||||
"flag_icons_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||
},
|
||||
"socket_io_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||
"cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||
},
|
||||
"swagger_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css",
|
||||
},
|
||||
"swagger_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]:
|
||||
theme = theme if theme in BOOTSTRAP_THEMES else "default"
|
||||
if theme == "default":
|
||||
return {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
|
||||
}
|
||||
return {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
|
||||
}
|
||||
|
||||
|
||||
def asset_path(key: str) -> str:
|
||||
return STATIC_ASSETS[key]["local" if USE_OFFLINE_LIBS else "cdn"]
|
||||
|
||||
|
||||
def bootstrap_css_path(theme: str | None = None) -> str:
|
||||
return bootstrap_css_asset(theme)["local" if USE_OFFLINE_LIBS else "cdn"]
|
||||
|
||||
|
||||
def required_offline_paths() -> list[Path]:
|
||||
paths = [LIBS_DIR.parent / item["local"] for item in STATIC_ASSETS.values()]
|
||||
paths.extend(LIBS_DIR.parent / bootstrap_css_asset(theme)["local"] for theme in BOOTSTRAP_THEMES)
|
||||
return paths
|
||||
|
||||
|
||||
def missing_offline_paths() -> list[Path]:
|
||||
missing = [path for path in required_offline_paths() if not path.is_file() or path.stat().st_size <= 0]
|
||||
required_dirs = [
|
||||
LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts",
|
||||
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/4x3",
|
||||
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1",
|
||||
]
|
||||
for directory in required_dirs:
|
||||
if not directory.is_dir() or not any(directory.iterdir()):
|
||||
missing.append(directory)
|
||||
return missing
|
||||
|
||||
|
||||
def validate_offline_assets() -> None:
|
||||
if not USE_OFFLINE_LIBS:
|
||||
return
|
||||
missing = missing_offline_paths()
|
||||
if missing:
|
||||
preview = "\n".join(f"- {path.relative_to(BASE_DIR)}" for path in missing[:20])
|
||||
extra = "" if len(missing) <= 20 else f"\n- ... and {len(missing) - 20} more"
|
||||
raise RuntimeError(
|
||||
"PYTORRENT_USE_OFFLINE_LIBS=true, but frontend libraries are missing. "
|
||||
"Run: ./scripts/download_frontend_libs.py or ./install.sh\n"
|
||||
f"Missing files:\n{preview}{extra}"
|
||||
)
|
||||
38
pytorrent/services/geoip.py
Normal file
38
pytorrent/services/geoip.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from ..config import GEOIP_DB
|
||||
|
||||
try:
|
||||
import geoip2.database
|
||||
except Exception: # pragma: no cover
|
||||
geoip2 = None
|
||||
|
||||
_reader = None
|
||||
|
||||
|
||||
def _get_reader():
|
||||
global _reader
|
||||
if _reader is not None:
|
||||
return _reader
|
||||
if not GEOIP_DB.exists() or geoip2 is None:
|
||||
return None
|
||||
_reader = geoip2.database.Reader(str(GEOIP_DB))
|
||||
return _reader
|
||||
|
||||
|
||||
@lru_cache(maxsize=50000)
|
||||
def lookup_ip(ip: str) -> dict:
|
||||
reader = _get_reader()
|
||||
if not reader:
|
||||
return {"country_iso": "", "country": "", "city": ""}
|
||||
try:
|
||||
hit = reader.city(ip)
|
||||
return {
|
||||
"country_iso": (hit.country.iso_code or "").lower(),
|
||||
"country": hit.country.name or "",
|
||||
"city": hit.city.name or "",
|
||||
}
|
||||
except Exception:
|
||||
return {"country_iso": "", "country": "", "city": ""}
|
||||
244
pytorrent/services/poller_control.py
Normal file
244
pytorrent/services/poller_control.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
|
||||
|
||||
DEFAULTS = {
|
||||
"adaptive_enabled": True,
|
||||
"safe_fallback_enabled": True,
|
||||
"active_interval_seconds": 5.0,
|
||||
"idle_interval_seconds": 15.0,
|
||||
"error_interval_seconds": 30.0,
|
||||
"torrent_list_interval_seconds": 5.0,
|
||||
"system_stats_interval_seconds": 5.0,
|
||||
"tracker_stats_interval_seconds": 300.0,
|
||||
"disk_stats_interval_seconds": 60.0,
|
||||
"queue_stats_interval_seconds": 15.0,
|
||||
"slow_stats_interval_seconds": 60.0,
|
||||
"heartbeat_interval_seconds": 15.0,
|
||||
"emit_heartbeat_on_change": True,
|
||||
"slow_response_threshold_ms": 8000.0,
|
||||
"slowdown_multiplier": 2.0,
|
||||
"recovery_after_errors": 3,
|
||||
}
|
||||
|
||||
|
||||
def _key(profile_id: int) -> str:
|
||||
return f"poller.settings.{int(profile_id)}"
|
||||
|
||||
|
||||
def _state_key(profile_id: int) -> str:
|
||||
return f"poller.runtime.{int(profile_id)}"
|
||||
|
||||
|
||||
def _coerce_float(value: Any, default: float, lo: float, hi: float) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except Exception:
|
||||
return default
|
||||
return max(lo, min(hi, number))
|
||||
|
||||
|
||||
def normalize_settings(data: dict | None) -> dict:
|
||||
raw = {**DEFAULTS, **(data or {})}
|
||||
settings = {
|
||||
"adaptive_enabled": bool(raw.get("adaptive_enabled")),
|
||||
"safe_fallback_enabled": bool(raw.get("safe_fallback_enabled", True)),
|
||||
"active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0),
|
||||
"idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
|
||||
"torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
"disk_stats_interval_seconds": _coerce_float(raw.get("disk_stats_interval_seconds"), DEFAULTS["disk_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
"queue_stats_interval_seconds": _coerce_float(raw.get("queue_stats_interval_seconds"), DEFAULTS["queue_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
"slow_stats_interval_seconds": _coerce_float(raw.get("slow_stats_interval_seconds"), DEFAULTS["slow_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
"heartbeat_interval_seconds": _coerce_float(raw.get("heartbeat_interval_seconds"), DEFAULTS["heartbeat_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
|
||||
"emit_heartbeat_on_change": bool(raw.get("emit_heartbeat_on_change")),
|
||||
"slow_response_threshold_ms": _coerce_float(raw.get("slow_response_threshold_ms"), DEFAULTS["slow_response_threshold_ms"], 100.0, 60000.0),
|
||||
"slowdown_multiplier": _coerce_float(raw.get("slowdown_multiplier"), DEFAULTS["slowdown_multiplier"], 1.0, 10.0),
|
||||
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
|
||||
}
|
||||
if settings["safe_fallback_enabled"]:
|
||||
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"):
|
||||
if settings[key] <= 0:
|
||||
settings[key] = DEFAULTS[key]
|
||||
return settings
|
||||
|
||||
|
||||
def get_settings(profile_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||
try:
|
||||
data = json.loads(row.get("value") or "{}") if row else {}
|
||||
except Exception:
|
||||
data = {}
|
||||
return normalize_settings(data)
|
||||
|
||||
|
||||
def save_settings(profile_id: int, data: dict) -> dict:
|
||||
settings = normalize_settings(data)
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings)))
|
||||
return settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfilePollState:
|
||||
profile_id: int
|
||||
last_fast_at: float = 0.0
|
||||
last_system_at: float = 0.0
|
||||
last_slow_at: float = 0.0
|
||||
last_tracker_at: float = 0.0
|
||||
last_disk_at: float = 0.0
|
||||
last_queue_at: float = 0.0
|
||||
last_heartbeat_at: float = 0.0
|
||||
last_ok: bool = True
|
||||
last_active: bool = False
|
||||
last_error: str = ""
|
||||
last_tick_ms: float = 0.0
|
||||
last_tick_started_at: float = 0.0
|
||||
last_tick_gap_ms: float = 0.0
|
||||
effective_interval_seconds: float = 0.0
|
||||
tick_count: int = 0
|
||||
sleep_hint: float = 1.0
|
||||
error_count: int = 0
|
||||
slow_count: int = 0
|
||||
skipped_emissions: int = 0
|
||||
emitted_payload_size: int = 0
|
||||
rtorrent_call_count: int = 0
|
||||
adaptive_mode: str = "normal"
|
||||
slow_task_running: bool = False
|
||||
system_task_running: bool = False
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
_STATES: dict[int, ProfilePollState] = {}
|
||||
|
||||
|
||||
def state_for(profile_id: int) -> ProfilePollState:
|
||||
profile_id = int(profile_id)
|
||||
state = _STATES.get(profile_id)
|
||||
if state is None:
|
||||
state = ProfilePollState(profile_id=profile_id)
|
||||
_STATES[profile_id] = state
|
||||
return state
|
||||
|
||||
|
||||
def interval_for(settings: dict, state: ProfilePollState) -> float:
|
||||
if not settings.get("adaptive_enabled"):
|
||||
return float(settings["active_interval_seconds"])
|
||||
if not state.last_ok:
|
||||
return float(settings["error_interval_seconds"])
|
||||
base = float(settings["active_interval_seconds"] if state.last_active else settings["idle_interval_seconds"])
|
||||
if state.adaptive_mode == "slowdown":
|
||||
return min(float(settings["error_interval_seconds"]), base * float(settings.get("slowdown_multiplier") or 2.0))
|
||||
return base
|
||||
|
||||
|
||||
def effective_fast_interval(settings: dict, state: ProfilePollState) -> float:
|
||||
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
|
||||
|
||||
|
||||
def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_fast_at) >= effective_fast_interval(settings, state)
|
||||
|
||||
|
||||
def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_system_at) >= float(settings["system_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_slow_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_slow_at) >= float(settings["slow_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_tracker_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_tracker_at) >= float(settings["tracker_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_disk_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_disk_at) >= float(settings["disk_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_queue_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_queue_at) >= float(settings["queue_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_heartbeat(now: float, settings: dict, state: ProfilePollState, changed: bool) -> bool:
|
||||
if changed and settings.get("emit_heartbeat_on_change"):
|
||||
return True
|
||||
return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"])
|
||||
|
||||
|
||||
def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict:
|
||||
now = time.monotonic()
|
||||
effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS
|
||||
previous_started_at = state.last_tick_started_at
|
||||
state.tick_count += 1
|
||||
state.last_tick_ms = round((now - started_at) * 1000.0, 2)
|
||||
state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0
|
||||
state.last_tick_started_at = started_at
|
||||
state.last_active = bool(active)
|
||||
state.effective_interval_seconds = effective_fast_interval(effective_settings, state)
|
||||
state.last_ok = bool(ok)
|
||||
state.last_error = str(error or "")
|
||||
state.emitted_payload_size = int(emitted_payload_size or 0)
|
||||
state.rtorrent_call_count = int(rtorrent_call_count or 0)
|
||||
state.skipped_emissions += int(skipped_emissions or 0)
|
||||
adaptive_enabled = bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"]))
|
||||
|
||||
if not adaptive_enabled:
|
||||
# Adaptive mode is explicitly disabled for this rTorrent profile. Keep metrics,
|
||||
# but do not enter slowdown/recovery or preserve a stale adaptive state from
|
||||
# earlier ticks; otherwise refreshes remain slow even with the toggle off.
|
||||
state.error_count = 0 if ok else state.error_count + 1
|
||||
state.slow_count = 0
|
||||
state.adaptive_mode = "fixed"
|
||||
else:
|
||||
if ok:
|
||||
state.error_count = 0
|
||||
else:
|
||||
state.error_count += 1
|
||||
threshold = float(effective_settings.get("slow_response_threshold_ms") or DEFAULTS["slow_response_threshold_ms"])
|
||||
recovery_after = int(effective_settings.get("recovery_after_errors") or DEFAULTS["recovery_after_errors"])
|
||||
if state.last_tick_ms >= threshold:
|
||||
state.slow_count += 1
|
||||
state.adaptive_mode = "slowdown"
|
||||
elif ok and state.error_count == 0 and state.slow_count:
|
||||
state.slow_count = max(0, state.slow_count - 1)
|
||||
if not ok and state.error_count >= recovery_after:
|
||||
state.adaptive_mode = "recovery"
|
||||
elif ok and state.slow_count == 0:
|
||||
state.adaptive_mode = "normal" if state.last_active else "idle"
|
||||
state.sleep_hint = max(MIN_POLL_INTERVAL_SECONDS, min(10.0, state.sleep_hint))
|
||||
state.stats = {
|
||||
"profile_id": state.profile_id,
|
||||
"tick_count": state.tick_count,
|
||||
"last_tick_ms": state.last_tick_ms,
|
||||
"last_active": state.last_active,
|
||||
"last_ok": state.last_ok,
|
||||
"last_tick_gap_ms": state.last_tick_gap_ms,
|
||||
"effective_interval_seconds": state.effective_interval_seconds,
|
||||
"configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
|
||||
"last_error": state.last_error,
|
||||
"duration_ms": state.last_tick_ms,
|
||||
"emitted_payload_size": state.emitted_payload_size,
|
||||
"rtorrent_call_count": state.rtorrent_call_count,
|
||||
"skipped_emissions": state.skipped_emissions,
|
||||
"adaptive_enabled": adaptive_enabled,
|
||||
"adaptive_mode": state.adaptive_mode,
|
||||
"error_count": state.error_count,
|
||||
"slow_count": state.slow_count,
|
||||
"updated_at": utcnow(),
|
||||
}
|
||||
return dict(state.stats)
|
||||
|
||||
|
||||
def snapshot(profile_id: int) -> dict:
|
||||
state = state_for(profile_id)
|
||||
return dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
||||
428
pytorrent/services/preferences.py
Normal file
428
pytorrent/services/preferences.py
Normal file
@@ -0,0 +1,428 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth
|
||||
|
||||
BOOTSTRAP_THEMES = {
|
||||
"default": "Default Bootstrap",
|
||||
"flatly": "Flatly",
|
||||
"litera": "Litera",
|
||||
"lumen": "Lumen",
|
||||
"minty": "Minty",
|
||||
"sketchy": "Sketchy",
|
||||
"solar": "Solar",
|
||||
"spacelab": "Spacelab",
|
||||
"united": "United",
|
||||
"zephyr": "Zephyr",
|
||||
}
|
||||
|
||||
FONT_FAMILIES = {
|
||||
"default": "Theme default",
|
||||
"adwaita-mono": "Adwaita Mono",
|
||||
"inter": "Inter",
|
||||
"system-ui": "System UI",
|
||||
"source-sans-3": "Source Sans 3",
|
||||
"jetbrains-mono": "JetBrains Mono",
|
||||
}
|
||||
|
||||
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
||||
RECOMMENDED_TABLE_COLUMNS = {
|
||||
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
|
||||
"shown": ["down_total", "to_download", "up_total", "created"],
|
||||
"mobile": {
|
||||
"status": True, "size": True, "progress": True, "down_rate": True, "up_rate": True,
|
||||
"eta": True, "seeds": True, "peers": True, "ratio": True, "path": True, "label": True,
|
||||
"ratio_group": False, "down_total": True, "to_download": True, "up_total": True,
|
||||
"created": False, "priority": False, "state": False, "active": False, "complete": False,
|
||||
"hashing": False, "message": False, "hash": False,
|
||||
},
|
||||
"mobileSmartFiltersEnabled": False,
|
||||
"widths": {
|
||||
"select": 44, "name": 389, "status": 83, "size": 75, "progress": 177,
|
||||
"down_rate": 60, "up_rate": 55, "eta": 53, "seeds": 44, "peers": 49,
|
||||
"ratio": 47, "path": 135, "label": 67, "ratio_group": 87,
|
||||
"down_total": 82, "to_download": 89, "up_total": 44, "created": 150,
|
||||
"priority": 80, "state": 70, "active": 70, "complete": 82, "hashing": 82,
|
||||
"message": 220, "hash": 280,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def recommended_table_columns_json() -> str:
|
||||
return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":"))
|
||||
|
||||
|
||||
def apply_recommended_table_columns(user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
get_preferences(user_id)
|
||||
now = utcnow()
|
||||
value = recommended_table_columns_json()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?",
|
||||
(value, now, user_id),
|
||||
)
|
||||
return get_preferences(user_id)
|
||||
|
||||
def bootstrap_css_url(theme: str | None) -> str:
|
||||
from .frontend_assets import bootstrap_css_path
|
||||
|
||||
return bootstrap_css_path(theme)
|
||||
|
||||
|
||||
def _int_setting(data: dict, key: str, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(data.get(key) if data.get(key) is not None else default)
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def list_profiles(user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
visible = auth.visible_profile_ids(user_id)
|
||||
with connect() as conn:
|
||||
if visible is None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM rtorrent_profiles ORDER BY is_default DESC, name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
if not visible:
|
||||
return []
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
return conn.execute(
|
||||
f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) ORDER BY is_default DESC, name COLLATE NOCASE",
|
||||
tuple(visible),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_profile(profile_id: int, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
if not auth.can_access_profile(profile_id, user_id):
|
||||
return None
|
||||
with connect() as conn:
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
|
||||
|
||||
def active_profile(user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
pref = conn.execute("SELECT active_rtorrent_id FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
if pref and pref.get("active_rtorrent_id") and auth.can_access_profile(int(pref["active_rtorrent_id"]), user_id):
|
||||
row = conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (pref["active_rtorrent_id"],)).fetchone()
|
||||
if row:
|
||||
return row
|
||||
profiles = list_profiles(user_id)
|
||||
return profiles[0] if profiles else None
|
||||
|
||||
|
||||
def save_profile(data: dict, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
now = utcnow()
|
||||
name = str(data.get("name") or "rTorrent").strip()
|
||||
scgi_url = str(data.get("scgi_url") or "").strip()
|
||||
timeout = _int_setting(data, "timeout_seconds", 5, 1, 300)
|
||||
max_parallel = _int_setting(data, "max_parallel_jobs", 5, 1, 64)
|
||||
light_parallel = _int_setting(data, "light_parallel_jobs", 4, 1, 64)
|
||||
light_timeout = _int_setting(data, "light_job_timeout_seconds", 300, 30, 86400)
|
||||
heavy_timeout = _int_setting(data, "heavy_job_timeout_seconds", 7200, 300, 172800)
|
||||
pending_timeout = _int_setting(data, "pending_job_timeout_seconds", 900, 60, 86400)
|
||||
is_remote = 1 if data.get("is_remote") else 0
|
||||
is_default = 1 if data.get("is_default") else 0
|
||||
if not scgi_url.startswith("scgi://"):
|
||||
raise ValueError("SCGI URL must start with scgi://")
|
||||
with connect() as conn:
|
||||
if is_default:
|
||||
conn.execute("UPDATE rtorrent_profiles SET is_default=0 WHERE user_id=?", (user_id,))
|
||||
cur = conn.execute(
|
||||
"INSERT INTO rtorrent_profiles(user_id,name,scgi_url,is_default,timeout_seconds,max_parallel_jobs,light_parallel_jobs,light_job_timeout_seconds,heavy_job_timeout_seconds,pending_job_timeout_seconds,is_remote,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, name, scgi_url, is_default, timeout, max_parallel, light_parallel, light_timeout, heavy_timeout, pending_timeout, is_remote, now, now),
|
||||
)
|
||||
profile_id = cur.lastrowid
|
||||
pref = conn.execute("SELECT active_rtorrent_id FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
if not pref or not pref.get("active_rtorrent_id") or is_default:
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
|
||||
(profile_id, now, user_id),
|
||||
)
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
|
||||
|
||||
def update_profile(profile_id: int, data: dict, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
now = utcnow()
|
||||
name = str(data.get("name") or "rTorrent").strip()
|
||||
scgi_url = str(data.get("scgi_url") or "").strip()
|
||||
timeout = _int_setting(data, "timeout_seconds", 5, 1, 300)
|
||||
max_parallel = _int_setting(data, "max_parallel_jobs", 5, 1, 64)
|
||||
light_parallel = _int_setting(data, "light_parallel_jobs", 4, 1, 64)
|
||||
light_timeout = _int_setting(data, "light_job_timeout_seconds", 300, 30, 86400)
|
||||
heavy_timeout = _int_setting(data, "heavy_job_timeout_seconds", 7200, 300, 172800)
|
||||
pending_timeout = _int_setting(data, "pending_job_timeout_seconds", 900, 60, 86400)
|
||||
is_remote = 1 if data.get("is_remote") else 0
|
||||
is_default = 1 if data.get("is_default") else 0
|
||||
if not scgi_url.startswith("scgi://"):
|
||||
raise ValueError("SCGI URL must start with scgi://")
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not row or not auth.can_write_profile(profile_id, user_id):
|
||||
raise ValueError("Profil nie istnieje")
|
||||
if is_default:
|
||||
conn.execute("UPDATE rtorrent_profiles SET is_default=0 WHERE user_id=?", (user_id,))
|
||||
conn.execute(
|
||||
"UPDATE rtorrent_profiles SET name=?, scgi_url=?, is_default=?, timeout_seconds=?, max_parallel_jobs=?, light_parallel_jobs=?, light_job_timeout_seconds=?, heavy_job_timeout_seconds=?, pending_job_timeout_seconds=?, is_remote=?, updated_at=? WHERE id=?",
|
||||
(name, scgi_url, is_default, timeout, max_parallel, light_parallel, light_timeout, heavy_timeout, pending_timeout, is_remote, now, profile_id),
|
||||
)
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
|
||||
|
||||
def delete_profile(profile_id: int, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
auth.require_profile_write(profile_id)
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM rtorrent_profiles WHERE id=?", (profile_id,))
|
||||
active = active_profile(user_id)
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
|
||||
(active["id"] if active else None, utcnow(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def activate_profile(profile_id: int, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not row or not auth.can_access_profile(profile_id, user_id):
|
||||
raise ValueError("Profil nie istnieje")
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
|
||||
(profile_id, utcnow(), user_id),
|
||||
)
|
||||
return get_profile(profile_id, user_id)
|
||||
|
||||
|
||||
|
||||
def export_profiles(user_id: int | None = None) -> dict:
|
||||
profiles = [dict(row) for row in list_profiles(user_id)]
|
||||
for p in profiles:
|
||||
p.pop("id", None)
|
||||
p.pop("user_id", None)
|
||||
p.pop("created_at", None)
|
||||
p.pop("updated_at", None)
|
||||
return {"version": 1, "profiles": profiles}
|
||||
|
||||
|
||||
def import_profiles(payload: dict, user_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
rows = payload.get("profiles") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("Invalid profiles export")
|
||||
imported = []
|
||||
for item in rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
imported.append(dict(save_profile(item, user_id)))
|
||||
return imported
|
||||
|
||||
|
||||
def _active_profile_id_for_user(user_id: int) -> int | None:
|
||||
profile = active_profile(user_id)
|
||||
try:
|
||||
return int(profile["id"]) if profile else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _clean_disk_paths(value) -> list[str]:
|
||||
try:
|
||||
parsed = json.loads(value if isinstance(value, str) else json.dumps(value or []))
|
||||
except Exception:
|
||||
parsed = []
|
||||
if not isinstance(parsed, list):
|
||||
parsed = []
|
||||
clean: list[str] = []
|
||||
for item in parsed:
|
||||
path = str(item or "").strip()
|
||||
if path and path not in clean:
|
||||
clean.append(path)
|
||||
return clean
|
||||
|
||||
|
||||
def _normalize_disk_monitor(data: dict | None) -> dict:
|
||||
data = data or {}
|
||||
mode = str(data.get("mode") or data.get("disk_monitor_mode") or "default")
|
||||
if mode not in {"default", "selected", "aggregate"}:
|
||||
mode = "default"
|
||||
try:
|
||||
threshold = int(data.get("stop_threshold") if data.get("stop_threshold") is not None else data.get("disk_monitor_stop_threshold") or 98)
|
||||
except (TypeError, ValueError):
|
||||
threshold = 98
|
||||
threshold = max(1, min(100, threshold))
|
||||
return {
|
||||
"disk_monitor_paths_json": json.dumps(_clean_disk_paths(data.get("paths_json") if data.get("paths_json") is not None else data.get("disk_monitor_paths_json"))),
|
||||
"disk_monitor_mode": mode,
|
||||
"disk_monitor_selected_path": str(data.get("selected_path") if data.get("selected_path") is not None else data.get("disk_monitor_selected_path") or "").strip(),
|
||||
"disk_monitor_stop_enabled": 1 if (data.get("stop_enabled") if data.get("stop_enabled") is not None else data.get("disk_monitor_stop_enabled")) else 0,
|
||||
"disk_monitor_stop_threshold": threshold,
|
||||
}
|
||||
|
||||
|
||||
def legacy_disk_monitor_preferences(user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {}
|
||||
return _normalize_disk_monitor(row)
|
||||
|
||||
|
||||
def get_disk_monitor_preferences(profile_id: int | None = None, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||
if not profile_id:
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM disk_monitor_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
|
||||
if row:
|
||||
return _normalize_disk_monitor(row)
|
||||
# Backward-compatible seed: existing global disk monitor values become defaults for first use of a profile.
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
|
||||
|
||||
def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||
if not profile_id:
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
current = get_disk_monitor_preferences(profile_id, user_id)
|
||||
merged = dict(current)
|
||||
for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"):
|
||||
if key in data:
|
||||
merged[key] = data.get(key)
|
||||
clean = _normalize_disk_monitor(merged)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO disk_monitor_preferences(user_id,profile_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at",
|
||||
(user_id, profile_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now),
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def get_preferences(user_id: int | None = None, profile_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
if not pref:
|
||||
now = utcnow()
|
||||
conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now))
|
||||
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
merged = dict(pref or {})
|
||||
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
||||
return merged
|
||||
|
||||
|
||||
def save_preferences(data: dict, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
|
||||
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
|
||||
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
|
||||
table_columns_json = data.get("table_columns_json")
|
||||
peers_refresh_seconds = data.get("peers_refresh_seconds")
|
||||
port_check_enabled = data.get("port_check_enabled")
|
||||
footer_items_json = data.get("footer_items_json")
|
||||
title_speed_enabled = data.get("title_speed_enabled")
|
||||
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
|
||||
automation_toasts_enabled = data.get("automation_toasts_enabled")
|
||||
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
|
||||
disk_monitor_paths_json = data.get("disk_monitor_paths_json")
|
||||
disk_monitor_mode = data.get("disk_monitor_mode")
|
||||
disk_monitor_selected_path = data.get("disk_monitor_selected_path")
|
||||
disk_monitor_stop_enabled = data.get("disk_monitor_stop_enabled")
|
||||
disk_monitor_stop_threshold = data.get("disk_monitor_stop_threshold")
|
||||
interface_scale = data.get("interface_scale")
|
||||
detail_panel_height = data.get("detail_panel_height")
|
||||
torrent_sort_json = data.get("torrent_sort_json")
|
||||
active_filter = data.get("active_filter")
|
||||
disk_payload = None
|
||||
if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)):
|
||||
disk_payload = {
|
||||
"disk_monitor_paths_json": disk_monitor_paths_json,
|
||||
"disk_monitor_mode": disk_monitor_mode,
|
||||
"disk_monitor_selected_path": disk_monitor_selected_path,
|
||||
"disk_monitor_stop_enabled": disk_monitor_stop_enabled,
|
||||
"disk_monitor_stop_threshold": disk_monitor_stop_threshold,
|
||||
}
|
||||
with connect() as conn:
|
||||
now = utcnow()
|
||||
if allowed_theme:
|
||||
conn.execute("UPDATE user_preferences SET theme=?, updated_at=? WHERE user_id=?", (allowed_theme, now, user_id))
|
||||
if bootstrap_theme:
|
||||
conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id))
|
||||
if font_family:
|
||||
conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id))
|
||||
if table_columns_json is not None:
|
||||
conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id))
|
||||
if peers_refresh_seconds is not None:
|
||||
sec = int(peers_refresh_seconds or 0)
|
||||
if sec not in {0, 10, 15, 30, 60}: sec = 0
|
||||
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
|
||||
if port_check_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
|
||||
if title_speed_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
|
||||
if tracker_favicons_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
|
||||
if automation_toasts_enabled is not None:
|
||||
# Note: Lets users silence automation-created toast noise without hiding job/history data.
|
||||
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
|
||||
if smart_queue_toasts_enabled is not None:
|
||||
# Note: Smart Queue toast noise can be disabled independently from automation notifications.
|
||||
conn.execute("UPDATE user_preferences SET smart_queue_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if smart_queue_toasts_enabled else 0, now, user_id))
|
||||
if interface_scale is not None:
|
||||
scale = int(interface_scale or 100)
|
||||
if scale < 80: scale = 80
|
||||
if scale > 140: scale = 140
|
||||
conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id))
|
||||
if footer_items_json is not None:
|
||||
# Note: Store only JSON objects so footer visibility can be extended without schema churn.
|
||||
value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json)
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
conn.execute("UPDATE user_preferences SET footer_items_json=?, updated_at=? WHERE user_id=?", (json.dumps(parsed), now, user_id))
|
||||
if detail_panel_height is not None:
|
||||
try:
|
||||
height = int(detail_panel_height or 255)
|
||||
except (TypeError, ValueError):
|
||||
height = 255
|
||||
if height < 160: height = 160
|
||||
if height > 720: height = 720
|
||||
conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id))
|
||||
if torrent_sort_json is not None:
|
||||
# Note: Persist only a compact sort object; unknown keys are ignored on the client.
|
||||
value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json)
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
try:
|
||||
direction = int(parsed.get("dir") or 1)
|
||||
except (TypeError, ValueError):
|
||||
direction = 1
|
||||
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
|
||||
sort_key = str(parsed.get("key") or "name")
|
||||
if sort_key not in allowed_sort_keys:
|
||||
sort_key = "name"
|
||||
clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1}
|
||||
conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id))
|
||||
if active_filter is not None:
|
||||
value = str(active_filter or "all").strip()
|
||||
if not value or len(value) > 180:
|
||||
value = "all"
|
||||
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
|
||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||
value = "all"
|
||||
conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id))
|
||||
if disk_payload is not None:
|
||||
save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id)
|
||||
return get_preferences(user_id)
|
||||
146
pytorrent/services/ratio_rules.py
Normal file
146
pytorrent/services/ratio_rules.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import rtorrent
|
||||
from .workers import enqueue
|
||||
|
||||
|
||||
def _age_minutes_from_epoch(value) -> int:
|
||||
try:
|
||||
created = datetime.fromtimestamp(int(value or 0), timezone.utc)
|
||||
return max(0, int((datetime.now(timezone.utc) - created).total_seconds() // 60))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _is_private(profile: dict, torrent_hash: str) -> bool:
|
||||
try:
|
||||
value = rtorrent.client_for(profile).call("d.is_private", torrent_hash)
|
||||
return bool(int(value or 0))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _group_for_torrent(groups_by_name: dict[str, dict], torrent: dict) -> dict | None:
|
||||
name = str(torrent.get("ratio_group") or "").strip()
|
||||
return groups_by_name.get(name) if name else None
|
||||
|
||||
|
||||
def _record(user_id: int, profile_id: int, group: dict, torrent: dict, action: str, status: str, reason: str, details: dict | None = None) -> None:
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO ratio_history(user_id,profile_id,group_id,group_name,torrent_hash,torrent_name,action,status,reason,details_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, profile_id, group.get("id"), group.get("name"), torrent.get("hash"), torrent.get("name"), action, status, reason, json.dumps(details or {}), now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO ratio_assignments(profile_id,torrent_hash,group_id,group_name,applied_at,last_status,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(profile_id,torrent_hash) DO UPDATE SET group_id=excluded.group_id,group_name=excluded.group_name,applied_at=excluded.applied_at,last_status=excluded.last_status,updated_at=excluded.updated_at",
|
||||
(profile_id, torrent.get("hash"), group.get("id"), group.get("name"), now if status == "applied" else None, status, now),
|
||||
)
|
||||
|
||||
|
||||
def _should_apply(profile: dict, group: dict, torrent: dict) -> tuple[bool, str]:
|
||||
if not int(group.get("enabled") or 0):
|
||||
return False, "group disabled"
|
||||
if not torrent.get("complete"):
|
||||
return False, "torrent is not complete"
|
||||
if int(group.get("ignore_private") or 0) and _is_private(profile, torrent["hash"]):
|
||||
return False, "private torrent is excluded"
|
||||
min_ratio = float(group.get("min_ratio") or 0)
|
||||
max_ratio = float(group.get("max_ratio") or 0)
|
||||
wanted_ratio = max(min_ratio, max_ratio)
|
||||
seed_time = max(int(group.get("seed_time_minutes") or 0), int(group.get("min_seed_time_minutes") or 0))
|
||||
ratio_ok = float(torrent.get("ratio") or 0) >= wanted_ratio if wanted_ratio else True
|
||||
seed_ok = _age_minutes_from_epoch(torrent.get("created")) >= seed_time if seed_time else True
|
||||
if not ratio_ok:
|
||||
return False, "ratio threshold not reached"
|
||||
if not seed_ok:
|
||||
return False, "minimum seed time not reached"
|
||||
min_upload = int(group.get("active_upload_min_bytes") or 1024)
|
||||
if int(group.get("ignore_active_upload") or 0) and int(torrent.get("up_rate") or 0) >= min_upload:
|
||||
return False, "active upload is above exception threshold"
|
||||
return True, "ratio rule applied"
|
||||
|
||||
|
||||
def check(profile: dict, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
with connect() as conn:
|
||||
groups = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
already = {row["torrent_hash"] for row in conn.execute("SELECT torrent_hash FROM ratio_assignments WHERE profile_id=? AND last_status='applied'", (profile_id,)).fetchall()}
|
||||
groups_by_name = {str(g.get("name") or ""): g for g in groups}
|
||||
applied = 0
|
||||
skipped = 0
|
||||
queued_jobs = []
|
||||
for torrent in rtorrent.list_torrents(profile):
|
||||
group = _group_for_torrent(groups_by_name, torrent)
|
||||
if not group:
|
||||
continue
|
||||
if torrent.get("hash") in already:
|
||||
skipped += 1
|
||||
continue
|
||||
ok, reason = _should_apply(profile, group, torrent)
|
||||
if not ok:
|
||||
skipped += 1
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO ratio_assignments(profile_id,torrent_hash,group_id,group_name,last_status,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(profile_id,torrent_hash) DO UPDATE SET group_id=excluded.group_id,group_name=excluded.group_name,last_status=excluded.last_status,updated_at=excluded.updated_at",
|
||||
(profile_id, torrent.get("hash"), group.get("id"), group.get("name"), reason, utcnow()),
|
||||
)
|
||||
continue
|
||||
action = str(group.get("action") or "stop")
|
||||
payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}}
|
||||
if action == "remove_data":
|
||||
api_action = "remove"
|
||||
payload["remove_data"] = True
|
||||
elif action == "move":
|
||||
api_action = "move"
|
||||
payload.update({"path": group.get("move_path") or torrent.get("path") or "", "move_data": True, "recheck": False, "keep_seeding": False})
|
||||
elif action == "set_label":
|
||||
api_action = "set_label"
|
||||
payload["label"] = group.get("set_label") or group.get("name") or ""
|
||||
else:
|
||||
api_action = action if action in {"stop", "remove", "pause"} else "stop"
|
||||
job_id = enqueue(api_action, profile_id, payload, user_id=user_id)
|
||||
queued_jobs.append(job_id)
|
||||
applied += 1
|
||||
_record(user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action})
|
||||
return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs}
|
||||
|
||||
|
||||
_scheduler_started = False
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
global _scheduler_started
|
||||
if _scheduler_started:
|
||||
return
|
||||
_scheduler_started = True
|
||||
|
||||
def loop() -> None:
|
||||
# Note: Ratio rules are evaluated periodically and actions are executed through the existing safe job queue.
|
||||
while True:
|
||||
try:
|
||||
from .preferences import get_profile
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM ratio_groups WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
for row in profiles:
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if not profile:
|
||||
continue
|
||||
result = check(profile, int(row["user_id"]))
|
||||
if socketio and result.get("applied"):
|
||||
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(300)
|
||||
|
||||
if socketio:
|
||||
socketio.start_background_task(loop)
|
||||
else:
|
||||
import threading
|
||||
threading.Thread(target=loop, daemon=True, name="pytorrent-ratio-scheduler").start()
|
||||
49
pytorrent/services/retention.py
Normal file
49
pytorrent/services/retention.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect
|
||||
|
||||
_LAST_CLEANUP = 0.0
|
||||
CLEANUP_EVERY_SECONDS = 3600
|
||||
|
||||
|
||||
def _cutoff(days: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=max(1, int(days or 1)))).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _table_exists(conn, table: str) -> bool:
|
||||
row = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def cleanup(force: bool = False) -> dict[str, int]:
|
||||
global _LAST_CLEANUP
|
||||
now_ts = datetime.now(timezone.utc).timestamp()
|
||||
if not force and now_ts - _LAST_CLEANUP < CLEANUP_EVERY_SECONDS:
|
||||
return {}
|
||||
_LAST_CLEANUP = now_ts
|
||||
|
||||
deleted: dict[str, int] = {}
|
||||
with connect() as conn:
|
||||
targets = {
|
||||
"traffic_history": ("created_at", TRAFFIC_HISTORY_RETENTION_DAYS),
|
||||
"smart_queue_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS),
|
||||
# Note: Automation history follows Smart Queue retention; rules and rule state are never deleted here.
|
||||
"automation_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS),
|
||||
"jobs": ("updated_at", JOBS_RETENTION_DAYS),
|
||||
"logs": ("created_at", LOG_RETENTION_DAYS),
|
||||
}
|
||||
for table, (column, days) in targets.items():
|
||||
if not _table_exists(conn, table):
|
||||
continue
|
||||
if table == "jobs":
|
||||
cur = conn.execute(
|
||||
f"DELETE FROM {table} WHERE {column} < ? AND status IN ('done','failed','cancelled')",
|
||||
(_cutoff(days),),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(f"DELETE FROM {table} WHERE {column} < ?", (_cutoff(days),))
|
||||
deleted[table] = int(cur.rowcount or 0)
|
||||
return deleted
|
||||
218
pytorrent/services/rss.py
Normal file
218
pytorrent/services/rss.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Iterable
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import rtorrent
|
||||
from .workers import enqueue
|
||||
|
||||
RSS_FETCH_LIMIT = 2_000_000
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return parsedate_to_datetime(value).astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _item_size(item: ET.Element) -> int:
|
||||
enc = item.find("enclosure")
|
||||
if enc is not None:
|
||||
try:
|
||||
return int(enc.get("length") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
for tag in ("size", "length"):
|
||||
try:
|
||||
return int(item.findtext(tag) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _item_category(item: ET.Element) -> str:
|
||||
values = [x.text or "" for x in item.findall("category")]
|
||||
return " ".join(values).strip()
|
||||
|
||||
|
||||
def parse_feed(raw: bytes) -> list[dict]:
|
||||
root = ET.fromstring(raw)
|
||||
items = root.findall(".//item")
|
||||
if not items and root.tag.lower().endswith("feed"):
|
||||
items = root.findall("{http://www.w3.org/2005/Atom}entry")
|
||||
parsed: list[dict] = []
|
||||
for item in items[:200]:
|
||||
title = item.findtext("title") or item.findtext("{http://www.w3.org/2005/Atom}title") or ""
|
||||
link = item.findtext("link") or ""
|
||||
atom_link = item.find("{http://www.w3.org/2005/Atom}link")
|
||||
if atom_link is not None and atom_link.get("href"):
|
||||
link = atom_link.get("href") or link
|
||||
enc = item.find("enclosure")
|
||||
if enc is not None and enc.get("url"):
|
||||
link = enc.get("url") or link
|
||||
pub_date = item.findtext("pubDate") or item.findtext("updated") or item.findtext("{http://www.w3.org/2005/Atom}updated")
|
||||
parsed.append({
|
||||
"title": title.strip(),
|
||||
"link": str(link or "").strip(),
|
||||
"size": _item_size(item),
|
||||
"category": _item_category(item),
|
||||
"published_at": _parse_dt(pub_date).isoformat(timespec="seconds") if _parse_dt(pub_date) else None,
|
||||
})
|
||||
return parsed
|
||||
|
||||
|
||||
def fetch_feed(url: str) -> list[dict]:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "pyTorrent RSS"})
|
||||
with urllib.request.urlopen(req, timeout=12) as res:
|
||||
raw = res.read(RSS_FETCH_LIMIT)
|
||||
return parse_feed(raw)
|
||||
|
||||
|
||||
def _season_episode(title: str) -> tuple[int | None, int | None]:
|
||||
match = re.search(r"S(\d{1,2})E(\d{1,3})", title or "", re.I)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
match = re.search(r"\b(\d{1,2})x(\d{1,3})\b", title or "", re.I)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return None, None
|
||||
|
||||
|
||||
def matches_rule(rule: dict, item: dict) -> tuple[bool, str]:
|
||||
title = str(item.get("title") or "")
|
||||
haystack = " ".join([title, str(item.get("category") or "")])
|
||||
pattern = str(rule.get("pattern") or ".*")
|
||||
exclude = str(rule.get("exclude_pattern") or "").strip()
|
||||
try:
|
||||
if pattern and not re.search(pattern, haystack, re.I):
|
||||
return False, "include pattern did not match"
|
||||
if exclude and re.search(exclude, haystack, re.I):
|
||||
return False, "exclude pattern matched"
|
||||
except re.error as exc:
|
||||
return False, f"invalid regex: {exc}"
|
||||
size_mb = (int(item.get("size") or 0) / 1024 / 1024) if item.get("size") else 0
|
||||
min_size = int(rule.get("min_size_mb") or 0)
|
||||
max_size = int(rule.get("max_size_mb") or 0)
|
||||
if min_size and size_mb and size_mb < min_size:
|
||||
return False, "item is below minimum size"
|
||||
if max_size and size_mb and size_mb > max_size:
|
||||
return False, "item is above maximum size"
|
||||
category = str(rule.get("category") or "").strip().lower()
|
||||
if category and category not in str(item.get("category") or "").lower() and category not in title.lower():
|
||||
return False, "category did not match"
|
||||
quality = str(rule.get("quality") or "").strip().lower()
|
||||
if quality and quality not in title.lower():
|
||||
return False, "quality did not match"
|
||||
wanted_season = rule.get("season")
|
||||
wanted_episode = rule.get("episode")
|
||||
found_season, found_episode = _season_episode(title)
|
||||
if wanted_season not in (None, "", 0) and int(wanted_season) != int(found_season or -1):
|
||||
return False, "season did not match"
|
||||
if wanted_episode not in (None, "", 0) and int(wanted_episode) != int(found_episode or -1):
|
||||
return False, "episode did not match"
|
||||
return True, "matched"
|
||||
|
||||
|
||||
def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None:
|
||||
with connect() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
|
||||
)
|
||||
except Exception:
|
||||
# Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads.
|
||||
pass
|
||||
|
||||
|
||||
def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
if only_due:
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall()
|
||||
else:
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
queued = 0
|
||||
tested = 0
|
||||
errors: list[dict] = []
|
||||
for feed in feeds:
|
||||
interval = max(5, int(feed.get("interval_minutes") or 30))
|
||||
next_check = (datetime.now(timezone.utc) + timedelta(minutes=interval)).isoformat(timespec="seconds")
|
||||
try:
|
||||
items = fetch_feed(feed["url"])
|
||||
for item in items:
|
||||
for rule in rules:
|
||||
matched, reason = matches_rule(rule, item)
|
||||
tested += 1
|
||||
if not matched:
|
||||
continue
|
||||
link = item.get("link") or ""
|
||||
if not link:
|
||||
_log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link")
|
||||
continue
|
||||
enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id)
|
||||
queued += 1
|
||||
_log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason)
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"]))
|
||||
except Exception as exc:
|
||||
errors.append({"feed_id": feed.get("id"), "error": str(exc)})
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE rss_feeds SET last_error=?,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (str(exc), now, next_check, now, feed["id"]))
|
||||
return {"queued": queued, "tested": tested, "feeds_checked": len(feeds), "errors": errors}
|
||||
|
||||
|
||||
def test_rule(feed_url: str, rule: dict) -> dict:
|
||||
items = fetch_feed(feed_url)
|
||||
matches = []
|
||||
rejected = []
|
||||
for item in items[:100]:
|
||||
matched, reason = matches_rule(rule, item)
|
||||
target = matches if matched else rejected
|
||||
target.append({**item, "reason": reason})
|
||||
return {"matches": matches[:50], "rejected": rejected[:50], "total": len(items)}
|
||||
|
||||
|
||||
_scheduler_started = False
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
global _scheduler_started
|
||||
if _scheduler_started:
|
||||
return
|
||||
_scheduler_started = True
|
||||
|
||||
def loop() -> None:
|
||||
# Note: The lightweight RSS scheduler uses persisted next_check_at values, so restarts do not reset cadence.
|
||||
while True:
|
||||
try:
|
||||
from .preferences import get_profile
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
for row in profiles:
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if profile:
|
||||
result = check(profile, int(row["user_id"]), only_due=True)
|
||||
if socketio and result.get("queued"):
|
||||
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(60)
|
||||
|
||||
if socketio:
|
||||
socketio.start_background_task(loop)
|
||||
else:
|
||||
import threading
|
||||
threading.Thread(target=loop, daemon=True, name="pytorrent-rss-scheduler").start()
|
||||
10
pytorrent/services/rtorrent/README.md
Normal file
10
pytorrent/services/rtorrent/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# rTorrent service modules
|
||||
|
||||
The old `pytorrent/services/rtorrent.py` monolith is end-of-life.
|
||||
Do not recreate it and do not add new rTorrent logic outside this directory.
|
||||
|
||||
Use focused modules in `pytorrent/services/rtorrent/` instead:
|
||||
- `client.py` for SCGI/XMLRPC transport and shared caches.
|
||||
- `system.py` for status, footer metrics, disk and remote host usage.
|
||||
- `torrents.py` for torrent list and torrent operations.
|
||||
- `files.py`, `config.py`, `diagnostics.py` for their dedicated areas.
|
||||
14
pytorrent/services/rtorrent/__init__.py
Normal file
14
pytorrent/services/rtorrent/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith.
|
||||
# All rTorrent code belongs in this package directory.
|
||||
|
||||
# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent.
|
||||
# Compatibility note: module __all__ definitions include selected private helpers used by existing routes.
|
||||
from .client import *
|
||||
from .system import *
|
||||
from .diagnostics import *
|
||||
from .files import *
|
||||
from .config import *
|
||||
from .torrents import *
|
||||
from .chunks import *
|
||||
207
pytorrent/services/rtorrent/chunks.py
Normal file
207
pytorrent/services/rtorrent/chunks.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
|
||||
|
||||
_HEX_RE = re.compile(r"[0-9a-fA-F]")
|
||||
|
||||
|
||||
def _clean_hex_bitfield(value) -> str:
|
||||
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
||||
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
|
||||
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
||||
|
||||
|
||||
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
||||
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
||||
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
|
||||
bits: list[int] = []
|
||||
for char in _clean_hex_bitfield(value):
|
||||
nibble = int(char, 16)
|
||||
bits.extend([
|
||||
1 if nibble & 0b1000 else 0,
|
||||
1 if nibble & 0b0100 else 0,
|
||||
1 if nibble & 0b0010 else 0,
|
||||
1 if nibble & 0b0001 else 0,
|
||||
])
|
||||
if limit is not None and limit >= 0:
|
||||
if len(bits) < limit:
|
||||
bits.extend([0] * (limit - len(bits)))
|
||||
return bits[:limit]
|
||||
return bits
|
||||
|
||||
|
||||
def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
|
||||
"""Classify a visual chunk cell for CSS and filtering."""
|
||||
if total <= 0:
|
||||
return "missing"
|
||||
if completed >= total:
|
||||
return "complete"
|
||||
if completed <= 0:
|
||||
return "seen" if seen else "missing"
|
||||
return "partial"
|
||||
|
||||
|
||||
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
||||
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
|
||||
if max_cells <= 0 or len(cells) <= max_cells:
|
||||
return cells
|
||||
grouped: list[dict] = []
|
||||
scale = len(cells) / float(max_cells)
|
||||
for out_idx in range(max_cells):
|
||||
start = int(math.floor(out_idx * scale))
|
||||
end = int(math.floor((out_idx + 1) * scale))
|
||||
part = cells[start:max(end, start + 1)]
|
||||
if not part:
|
||||
continue
|
||||
completed = sum(int(c.get("completed") or 0) for c in part)
|
||||
total = sum(int(c.get("total") or 0) for c in part)
|
||||
seen = any(bool(c.get("seen")) for c in part)
|
||||
percent = round((completed / total) * 100.0, 2) if total > 0 else 0.0
|
||||
grouped.append({
|
||||
"index": out_idx,
|
||||
"first_chunk": int(part[0].get("first_chunk", 0)),
|
||||
"last_chunk": int(part[-1].get("last_chunk", 0)),
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
"percent": percent,
|
||||
"seen": seen,
|
||||
"status": _chunk_status(completed, total, seen),
|
||||
"grouped": True,
|
||||
"unit_count": len(part),
|
||||
})
|
||||
return grouped
|
||||
|
||||
|
||||
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
||||
"""Create one raw cell per real torrent piece."""
|
||||
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
|
||||
cells: list[dict] = []
|
||||
for idx in range(max(0, int(total_chunks or 0))):
|
||||
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
||||
seen = idx < len(seen_bits) and bool(seen_bits[idx])
|
||||
cells.append({
|
||||
"index": idx,
|
||||
"first_chunk": idx,
|
||||
"last_chunk": idx,
|
||||
"completed": completed,
|
||||
"total": 1,
|
||||
"percent": 100.0 if completed else 0.0,
|
||||
"seen": seen,
|
||||
"status": _chunk_status(completed, 1, seen),
|
||||
"grouped": False,
|
||||
"unit_count": 1,
|
||||
})
|
||||
return cells
|
||||
|
||||
|
||||
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
||||
"""Return ruTorrent-like visual chunk data for one torrent."""
|
||||
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
|
||||
c = client_for(profile)
|
||||
values = {
|
||||
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
||||
"seen": "",
|
||||
"chunk_size": 0,
|
||||
"size_chunks": 0,
|
||||
"completed_chunks": 0,
|
||||
"chunks_hashed": 0,
|
||||
}
|
||||
optional_calls = {
|
||||
"seen": "d.chunks_seen",
|
||||
"chunk_size": "d.chunk_size",
|
||||
"size_chunks": "d.size_chunks",
|
||||
"completed_chunks": "d.completed_chunks",
|
||||
"chunks_hashed": "d.chunks_hashed",
|
||||
}
|
||||
for key, method in optional_calls.items():
|
||||
try:
|
||||
raw = c.call(method, torrent_hash)
|
||||
values[key] = _clean_hex_bitfield(raw) if key == "seen" else int(raw or 0)
|
||||
except Exception:
|
||||
values[key] = "" if key == "seen" else 0
|
||||
|
||||
total_chunks = int(values["size_chunks"] or 0)
|
||||
completed = int(values["completed_chunks"] or 0)
|
||||
if total_chunks <= 0:
|
||||
total_chunks = max(completed, len(values["bitfield"]) * 4)
|
||||
|
||||
have_bits = _hex_to_bits(values["bitfield"], total_chunks)
|
||||
seen_bits = _hex_to_bits(values["seen"], total_chunks)
|
||||
cells = _build_piece_cells(total_chunks, have_bits, seen_bits)
|
||||
|
||||
visual_cells = _group_cells(cells, max(64, min(10000, int(max_cells or 2048))))
|
||||
return {
|
||||
"hash": torrent_hash,
|
||||
"chunk_size": int(values["chunk_size"] or 0),
|
||||
"chunk_size_h": human_size(values["chunk_size"] or 0),
|
||||
"size_chunks": total_chunks,
|
||||
"completed_chunks": completed,
|
||||
"chunks_hashed": int(values["chunks_hashed"] or 0),
|
||||
"bitfield_units": len(have_bits),
|
||||
"visual_cells": len(visual_cells),
|
||||
"grouped": len(visual_cells) != len(cells),
|
||||
"cells": visual_cells,
|
||||
"summary": {
|
||||
"complete": sum(1 for c in visual_cells if c.get("status") == "complete"),
|
||||
"partial": sum(1 for c in visual_cells if c.get("status") == "partial"),
|
||||
"missing": sum(1 for c in visual_cells if c.get("status") == "missing"),
|
||||
"seen": sum(1 for c in visual_cells if c.get("status") == "seen"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk: int, last_chunk: int) -> list[dict]:
|
||||
"""Find files whose rTorrent chunk range overlaps the selected visual cells."""
|
||||
# Note: rTorrent exposes file chunk coverage through f.range_first and f.range_second; the second value is exclusive.
|
||||
rows = c.f.multicall(torrent_hash, "", "f.path=", "f.range_first=", "f.range_second=", "f.priority=")
|
||||
matches = []
|
||||
for idx, row in enumerate(rows):
|
||||
start = int(row[1] or 0)
|
||||
end_exclusive = int(row[2] or 0)
|
||||
end = max(start, end_exclusive - 1)
|
||||
if start <= last_chunk and end >= first_chunk:
|
||||
matches.append({
|
||||
"index": idx,
|
||||
"path": str(row[0] or ""),
|
||||
"range_first": start,
|
||||
"range_second": end_exclusive,
|
||||
"priority": int(row[3] or 0),
|
||||
})
|
||||
return matches
|
||||
|
||||
|
||||
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
||||
"""Run safe actions related to visual chunk selection."""
|
||||
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
|
||||
payload = payload or {}
|
||||
action = str(action or "").strip().lower()
|
||||
c = client_for(profile)
|
||||
if action == "recheck":
|
||||
c.call("d.check_hash", torrent_hash)
|
||||
return {"action": action, "message": "Torrent hash check queued", "scope": "torrent"}
|
||||
if action == "prioritize_files":
|
||||
first_chunk = max(0, int(payload.get("first_chunk") or 0))
|
||||
last_chunk = max(first_chunk, int(payload.get("last_chunk") if payload.get("last_chunk") is not None else first_chunk))
|
||||
priority = max(0, min(3, int(payload.get("priority") or 2)))
|
||||
matches = _files_touching_chunks(c, torrent_hash, first_chunk, last_chunk)
|
||||
if not matches:
|
||||
return {"action": action, "updated": [], "errors": [{"error": "No files overlap selected chunk range"}]}
|
||||
result = set_file_priorities(profile, torrent_hash, [{"index": m["index"], "priority": priority} for m in matches])
|
||||
try:
|
||||
c.call("d.update_priorities", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
result.update({"action": action, "files": matches, "priority": priority, "first_chunk": first_chunk, "last_chunk": last_chunk})
|
||||
return result
|
||||
raise ValueError("Unknown chunk action")
|
||||
|
||||
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
364
pytorrent/services/rtorrent/client.py
Normal file
364
pytorrent/services/rtorrent/client.py
Normal file
@@ -0,0 +1,364 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import posixpath
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
from xmlrpc.client import Binary, dumps, loads
|
||||
from pathlib import Path as LocalPath
|
||||
from ...utils import human_rate, human_size
|
||||
from ...db import connect, default_user_id, utcnow
|
||||
from ...config import PYTORRENT_TMP_DIR, REMOTE_READ_CHUNK_BYTES
|
||||
|
||||
|
||||
class ScgiMethod:
|
||||
def __init__(self, client: "ScgiRtorrentClient", name: str):
|
||||
self.client = client
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return ScgiMethod(self.client, f"{self.name}.{name}")
|
||||
|
||||
def __call__(self, *args):
|
||||
return self.client.call(self.name, *args)
|
||||
|
||||
|
||||
class ScgiRtorrentClient:
|
||||
"""XML-RPC over SCGI client for rTorrent network.scgi.open_port."""
|
||||
|
||||
def __init__(self, url: str, timeout: int = 5):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "scgi":
|
||||
raise ValueError("SCGI URL must start with scgi://")
|
||||
if not parsed.hostname or not parsed.port:
|
||||
raise ValueError("SCGI URL must include host and port, e.g. scgi://127.0.0.1:5000/RPC2")
|
||||
self.host = parsed.hostname
|
||||
self.port = parsed.port
|
||||
self.timeout = timeout
|
||||
self.path = parsed.path or "/RPC2"
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return ScgiMethod(self, name)
|
||||
|
||||
def call(self, method_name: str, *args):
|
||||
body = dumps(args, methodname=method_name, allow_none=True).encode("utf-8")
|
||||
headers = {
|
||||
"CONTENT_LENGTH": str(len(body)),
|
||||
"SCGI": "1",
|
||||
"REQUEST_METHOD": "POST",
|
||||
"REQUEST_URI": self.path,
|
||||
"SCRIPT_NAME": self.path,
|
||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||
"CONTENT_TYPE": "text/xml",
|
||||
}
|
||||
header_blob = b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in headers.items())
|
||||
payload = str(len(header_blob)).encode("ascii") + b":" + header_blob + b"," + body
|
||||
attempts = _scgi_retry_attempts()
|
||||
last_exc = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
|
||||
sock.settimeout(self.timeout)
|
||||
sock.sendall(payload)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
response = b"".join(chunks)
|
||||
if not response:
|
||||
raise ConnectionError("Empty response from rTorrent SCGI")
|
||||
if b"\r\n\r\n" in response:
|
||||
response = response.split(b"\r\n\r\n", 1)[1]
|
||||
elif b"\n\n" in response:
|
||||
response = response.split(b"\n\n", 1)[1]
|
||||
result, _ = loads(response)
|
||||
return result[0] if len(result) == 1 else result
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt >= attempts or not _is_transient_scgi_error(exc):
|
||||
raise
|
||||
time.sleep(_scgi_retry_delay(attempt))
|
||||
raise last_exc or ConnectionError("rTorrent SCGI call failed")
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Shared runtime caches and post-check state live in the client module so split service modules keep the same process-wide behavior as the old monolith.
|
||||
_DISK_USAGE_CACHE: dict[str, tuple[float, dict]] = {}
|
||||
_DISK_USAGE_TTL_SECONDS = 30.0
|
||||
_REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
|
||||
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
||||
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
||||
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
|
||||
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
|
||||
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
||||
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
||||
_POST_CHECK_WATCH: dict[int, dict[str, float]] = {}
|
||||
|
||||
def _scgi_retry_attempts() -> int:
|
||||
# Note: Short retry/backoff protects bulk operations from temporary Errno 111 during high rTorrent load.
|
||||
try:
|
||||
return max(1, min(10, int(os.environ.get("PYTORRENT_SCGI_RETRIES", "5"))))
|
||||
except Exception:
|
||||
return 5
|
||||
|
||||
|
||||
def _scgi_retry_delay(attempt: int) -> float:
|
||||
return min(5.0, 0.35 * (2 ** max(0, attempt - 1)))
|
||||
|
||||
|
||||
def _is_transient_scgi_error(exc: Exception) -> bool:
|
||||
# Note: Retry covers common temporary SCGI/socket errors but does not hide semantic XML-RPC errors.
|
||||
if isinstance(exc, (ConnectionRefusedError, ConnectionResetError, TimeoutError, socket.timeout)):
|
||||
return True
|
||||
err_no = getattr(exc, "errno", None)
|
||||
if err_no in {errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH}:
|
||||
return True
|
||||
msg = str(exc).lower()
|
||||
return any(text in msg for text in ("connection refused", "connection reset", "timed out", "timeout", "empty response", "pipe creation failed", "resource temporarily unavailable", "try again", "temporarily unavailable"))
|
||||
|
||||
|
||||
def client_for(profile: dict) -> ScgiRtorrentClient:
|
||||
return ScgiRtorrentClient(profile["scgi_url"], int(profile.get("timeout_seconds") or 5))
|
||||
|
||||
|
||||
_UNSUPPORTED_EXEC_METHODS: set[str] = set()
|
||||
_EXEC_TARGET_STYLE: dict[str, int] = {}
|
||||
|
||||
def _rt_execute_preview(method_name: str, call_args: tuple) -> str:
|
||||
# Note: The compact RPC summary removes long scripts from error messages while keeping the method and first arguments for diagnostics.
|
||||
preview = ", ".join(repr(x) for x in call_args[:3])
|
||||
if len(call_args) > 3:
|
||||
preview += ", ..."
|
||||
return f"{method_name}({preview})"
|
||||
|
||||
|
||||
def _rt_execute_target_variants(method: str, args: tuple) -> list[tuple]:
|
||||
# Note: Depending on version, rTorrent XML-RPC either requires or rejects an empty target; cache the working variant per method.
|
||||
variants = [("", *args), args]
|
||||
preferred = _EXEC_TARGET_STYLE.get(method)
|
||||
if preferred is not None and 0 <= preferred < len(variants):
|
||||
return [variants[preferred]] + [v for i, v in enumerate(variants) if i != preferred]
|
||||
return variants
|
||||
|
||||
|
||||
def _is_rt_method_missing(exc: Exception) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return "not defined" in msg or "no such method" in msg or "unknown method" in msg
|
||||
|
||||
|
||||
def _rt_execute_methods(method: str) -> list[str]:
|
||||
# Note: execute2.* is tried only when the base execute.* method does not exist to avoid false retry errors.
|
||||
methods = [method]
|
||||
if method.startswith("execute."):
|
||||
fallback = method.replace("execute.", "execute2.", 1)
|
||||
if fallback not in _UNSUPPORTED_EXEC_METHODS:
|
||||
methods.append(fallback)
|
||||
return methods
|
||||
|
||||
|
||||
def _rt_execute(c: ScgiRtorrentClient, method: str, *args):
|
||||
"""Run rTorrent execute.* as the rTorrent user across XML-RPC variants."""
|
||||
errors: list[str] = []
|
||||
attempts = _scgi_retry_attempts()
|
||||
for attempt in range(1, attempts + 1):
|
||||
errors.clear()
|
||||
transient_seen = False
|
||||
primary_missing = False
|
||||
for method_index, method_name in enumerate(_rt_execute_methods(method)):
|
||||
if method_name in _UNSUPPORTED_EXEC_METHODS:
|
||||
continue
|
||||
if method_index > 0 and not primary_missing:
|
||||
continue
|
||||
for call_args in _rt_execute_target_variants(method_name, args):
|
||||
try:
|
||||
result = c.call(method_name, *call_args)
|
||||
if method_name == method:
|
||||
_EXEC_TARGET_STYLE[method_name] = 0 if call_args and call_args[0] == "" else 1
|
||||
return result
|
||||
except Exception as exc:
|
||||
if _is_rt_method_missing(exc):
|
||||
_UNSUPPORTED_EXEC_METHODS.add(method_name)
|
||||
if method_name == method:
|
||||
primary_missing = True
|
||||
errors.append(f"{method_name}: method not defined")
|
||||
break
|
||||
transient_seen = transient_seen or _is_transient_scgi_error(exc)
|
||||
errors.append(f"{_rt_execute_preview(method_name, call_args)}: {exc}")
|
||||
if transient_seen and attempt < attempts:
|
||||
time.sleep(_scgi_retry_delay(attempt))
|
||||
continue
|
||||
break
|
||||
raise RuntimeError("rTorrent execute failed: " + "; ".join(errors))
|
||||
|
||||
|
||||
def _is_rt_timeout_error(exc: Exception) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return isinstance(exc, (TimeoutError, socket.timeout)) or "timed out" in msg or "timeout" in msg
|
||||
|
||||
|
||||
def _rt_execute_allow_timeout(c: ScgiRtorrentClient, method: str, *args):
|
||||
try:
|
||||
return _rt_execute(c, method, *args)
|
||||
except Exception as exc:
|
||||
if _is_rt_timeout_error(exc):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _remote_clean_path(path: str) -> str:
|
||||
path = str(path or "").strip()
|
||||
return posixpath.normpath(path) if path else path
|
||||
|
||||
|
||||
def _remote_join(*parts: str) -> str:
|
||||
cleaned = [str(p).strip().rstrip("/") for p in parts if str(p).strip()]
|
||||
return posixpath.normpath(posixpath.join(*cleaned)) if cleaned else ""
|
||||
|
||||
|
||||
def _run_remote_move(c: ScgiRtorrentClient, src: str, dst: str, poll_interval: float = 2.0) -> None:
|
||||
"""Run a remote mv without binding the transfer time to the SCGI timeout."""
|
||||
token = uuid.uuid4().hex
|
||||
status_path = f"/tmp/pytorrent-move-{token}.status"
|
||||
start_script = (
|
||||
'src=$1; dst=$2; status=$3; tmp=${status}.tmp; '
|
||||
'rm -f "$status" "$tmp"; '
|
||||
'( '
|
||||
'rc=0; '
|
||||
'parent=${dst%/*}; '
|
||||
'if [ -z "$dst" ] || [ "$dst" = "/" ]; then echo "unsafe destination: $dst" >&2; rc=5; fi; '
|
||||
'if [ $rc -eq 0 ] && [ -n "$parent" ] && [ "$parent" != "$dst" ]; then mkdir -p "$parent" || rc=$?; fi; '
|
||||
'if [ $rc -eq 0 ] && [ "$src" = "$dst" ]; then :; '
|
||||
'elif [ $rc -eq 0 ] && { [ -e "$dst" ] || [ -L "$dst" ]; } && [ ! -e "$src" ] && [ ! -L "$src" ]; then :; '
|
||||
'elif [ $rc -eq 0 ] && [ ! -e "$src" ] && [ ! -L "$src" ]; then echo "source missing: $src" >&2; rc=3; '
|
||||
'elif [ $rc -eq 0 ] && { [ -e "$dst" ] || [ -L "$dst" ]; }; then rm -rf -- "$dst" && mv -f -- "$src" "$dst" || rc=$?; '
|
||||
'elif [ $rc -eq 0 ]; then mv -f -- "$src" "$dst" || rc=$?; '
|
||||
'fi; '
|
||||
'if [ $rc -eq 0 ]; then printf "OK\n" > "$status"; '
|
||||
'else printf "ERR %s\n" "$rc" > "$status"; fi; '
|
||||
'if [ -s "$tmp" ]; then cat "$tmp" >> "$status"; fi; '
|
||||
'rm -f "$tmp" '
|
||||
') > "$tmp" 2>&1 &'
|
||||
)
|
||||
poll_script = 'status=$1; [ -f "$status" ] && cat "$status" || true'
|
||||
cleanup_script = 'rm -f "$1"'
|
||||
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "sh", "-c", start_script, "pytorrent-move-start", src, dst, status_path)
|
||||
|
||||
while True:
|
||||
time.sleep(max(0.25, poll_interval))
|
||||
try:
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-move-poll", status_path) or "").strip()
|
||||
except Exception as exc:
|
||||
# Note: During bulk moves, rTorrent may briefly not create the execute.capture pipe; polling waits and retries.
|
||||
if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc):
|
||||
continue
|
||||
raise
|
||||
if not output:
|
||||
continue
|
||||
try:
|
||||
_rt_execute(c, "execute.throw", "sh", "-c", cleanup_script, "pytorrent-move-clean", status_path)
|
||||
except Exception:
|
||||
pass
|
||||
first_line = output.splitlines()[0].strip()
|
||||
if first_line == "OK":
|
||||
return
|
||||
if first_line.startswith("ERR"):
|
||||
details = "\n".join(output.splitlines()[1:]).strip()
|
||||
raise RuntimeError(details or first_line)
|
||||
raise RuntimeError(output)
|
||||
|
||||
|
||||
def _torrent_data_path(c: ScgiRtorrentClient, torrent_hash: str) -> str:
|
||||
"""Return data path as rTorrent sees it; do not touch pyTorrent local FS."""
|
||||
try:
|
||||
src = str(c.call("d.base_path", torrent_hash) or "").strip()
|
||||
if src:
|
||||
return src
|
||||
except Exception:
|
||||
pass
|
||||
directory = str(c.call("d.directory", torrent_hash) or "").strip()
|
||||
name = str(c.call("d.name", torrent_hash) or "").strip()
|
||||
try:
|
||||
is_multi = int(c.call("d.is_multi_file", torrent_hash) or 0)
|
||||
except Exception:
|
||||
is_multi = 0
|
||||
if is_multi:
|
||||
return directory
|
||||
if directory and name:
|
||||
return _remote_join(directory, name)
|
||||
return directory
|
||||
|
||||
|
||||
def _safe_rm_rf_path(path: str) -> str:
|
||||
path = _remote_clean_path(path)
|
||||
if not path or path in {"/", "."}:
|
||||
raise ValueError("Refusing to remove an unsafe data path")
|
||||
if path.rstrip("/").count("/") < 1:
|
||||
raise ValueError(f"Refusing to remove an unsafe data path: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0) -> None:
|
||||
# Note: rm -rf runs in the background on the rTorrent side, so long deletes do not hold a single SCGI connection.
|
||||
token = uuid.uuid4().hex
|
||||
status_path = f"/tmp/pytorrent-rm-{token}.status"
|
||||
script = (
|
||||
'target=$1; status=$2; tmp=${status}.tmp; '
|
||||
'rm -f "$status" "$tmp"; '
|
||||
'( rc=0; '
|
||||
'if [ -z "$target" ] || [ "$target" = "/" ] || [ "$target" = "." ]; then echo "unsafe remove target: $target" >&2; rc=5; '
|
||||
'else rm -rf -- "$target" || rc=$?; fi; '
|
||||
'if [ $rc -eq 0 ]; then printf "OK\n" > "$status"; else printf "ERR %s\n" "$rc" > "$status"; fi; '
|
||||
'if [ -s "$tmp" ]; then cat "$tmp" >> "$status"; fi; '
|
||||
'rm -f "$tmp" ) > "$tmp" 2>&1 &'
|
||||
)
|
||||
poll_script = 'status=$1; [ -f "$status" ] && cat "$status" || true'
|
||||
cleanup_script = 'rm -f "$1"'
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "sh", "-c", script, "pytorrent-rm-start", path, status_path)
|
||||
while True:
|
||||
time.sleep(max(0.25, poll_interval))
|
||||
try:
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-rm-poll", status_path) or "").strip()
|
||||
except Exception as exc:
|
||||
# Note: Remove uses the same safe polling as move, so a temporary missing pipe does not fail the whole queue.
|
||||
if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc):
|
||||
continue
|
||||
raise
|
||||
if not output:
|
||||
continue
|
||||
try:
|
||||
_rt_execute(c, "execute.throw", "sh", "-c", cleanup_script, "pytorrent-rm-clean", status_path)
|
||||
except Exception:
|
||||
pass
|
||||
first_line = output.splitlines()[0].strip()
|
||||
if first_line == "OK":
|
||||
return
|
||||
if first_line.startswith("ERR"):
|
||||
details = "\n".join(output.splitlines()[1:]).strip()
|
||||
raise RuntimeError(details or first_line)
|
||||
raise RuntimeError(output)
|
||||
|
||||
|
||||
def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
data_path = _safe_rm_rf_path(_torrent_data_path(c, torrent_hash))
|
||||
try:
|
||||
c.call("d.stop", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.call("d.close", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
_run_remote_rm(c, data_path)
|
||||
return {"hash": torrent_hash, "removed_path": data_path}
|
||||
|
||||
|
||||
|
||||
# Note: Focused rTorrent modules share low-level helpers with wildcard imports; keep private helper names available internally.
|
||||
__all__ = [name for name in globals() if not name.startswith('__')]
|
||||
255
pytorrent/services/rtorrent/config.py
Normal file
255
pytorrent/services/rtorrent/config.py
Normal file
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
|
||||
RTORRENT_CONFIG_FIELDS = [
|
||||
{"group": "Directories", "key": "directory.default", "label": "Default download directory", "type": "text"},
|
||||
{"group": "Directories", "key": "session.path", "label": "Session path", "type": "text"},
|
||||
{"group": "Directories", "key": "system.cwd", "label": "Working directory", "type": "text", "readonly": True},
|
||||
{"group": "Network", "key": "network.port_range", "label": "Incoming port range", "type": "text", "placeholder": "49164-49164"},
|
||||
{"group": "Network", "key": "network.port_random", "label": "Random incoming port", "type": "bool"},
|
||||
{"group": "Network", "key": "network.bind_address", "label": "Bind address", "type": "text", "placeholder": "0.0.0.0"},
|
||||
{"group": "Network", "key": "network.local_address", "label": "Local address", "type": "text"},
|
||||
{"group": "Network", "key": "network.max_open_files", "label": "Max open files", "type": "number"},
|
||||
{"group": "Network", "key": "network.max_open_sockets", "label": "Max open sockets", "type": "number"},
|
||||
{"group": "Network", "key": "network.http.max_open", "label": "Max HTTP connections", "type": "number"},
|
||||
{"group": "Network", "key": "network.http.ssl_verify_peer", "label": "Verify SSL peers", "type": "bool"},
|
||||
{"group": "Network", "key": "network.xmlrpc.size_limit", "label": "XML-RPC upload size limit", "type": "text", "placeholder": "16M"},
|
||||
{"group": "Peers", "key": "throttle.min_peers.normal", "label": "Min peers downloading", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.max_peers.normal", "label": "Max peers downloading", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.min_peers.seed", "label": "Min peers seeding", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.max_peers.seed", "label": "Max peers seeding", "type": "number"},
|
||||
{"group": "Peers", "key": "trackers.numwant", "label": "Tracker numwant", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.global_down.max_rate", "label": "Global download limit B/s", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.global_up.max_rate", "label": "Global upload limit B/s", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_downloads.global", "label": "Max active downloads", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_uploads.global", "label": "Max active uploads", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_downloads.div", "label": "Max downloads per throttle", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_uploads.div", "label": "Max uploads per throttle", "type": "number"},
|
||||
{"group": "DHT / PEX", "key": "dht.mode", "label": "DHT mode", "type": "text", "placeholder": "disable/off/auto/on"},
|
||||
{"group": "DHT / PEX", "key": "dht.port", "label": "DHT port", "type": "number"},
|
||||
{"group": "DHT / PEX", "key": "protocol.pex", "label": "Peer exchange", "type": "bool"},
|
||||
{"group": "Protocol", "key": "protocol.encryption.set", "label": "Encryption flags", "type": "text", "placeholder": "allow_incoming,try_outgoing,enable_retry"},
|
||||
{"group": "Protocol", "key": "protocol.connection.leech", "label": "Leech connection type", "type": "text", "placeholder": "leech"},
|
||||
{"group": "Protocol", "key": "protocol.connection.seed", "label": "Seed connection type", "type": "text", "placeholder": "seed"},
|
||||
{"group": "Files", "key": "pieces.hash.on_completion", "label": "Hash check on completion", "type": "bool"},
|
||||
{"group": "Files", "key": "pieces.preload.type", "label": "Pieces preload type", "type": "number"},
|
||||
{"group": "Files", "key": "pieces.preload.min_size", "label": "Pieces preload min size", "type": "number"},
|
||||
{"group": "Files", "key": "pieces.preload.min_rate", "label": "Pieces preload min rate", "type": "number"},
|
||||
{"group": "Files", "key": "system.file.allocate", "label": "File allocation", "type": "number"},
|
||||
{"group": "Files", "key": "system.file.max_size", "label": "Max file size", "type": "number"},
|
||||
{"group": "System", "key": "system.umask", "label": "File umask", "type": "text", "placeholder": "0002"},
|
||||
{"group": "System", "key": "system.hostname", "label": "Hostname", "type": "text", "readonly": True},
|
||||
{"group": "System", "key": "system.client_version", "label": "Client version", "type": "text", "readonly": True},
|
||||
{"group": "System", "key": "system.library_version", "label": "Library version", "type": "text", "readonly": True},
|
||||
]
|
||||
|
||||
|
||||
def _normalize_config_value(meta: dict, value):
|
||||
if meta.get("type") == "bool":
|
||||
return "1" if str(value).lower() in {"1", "true", "yes", "on"} or value is True else "0"
|
||||
if meta.get("type") == "number":
|
||||
return str(int(value or 0))
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, int(profile_id)),
|
||||
).fetchall()
|
||||
return {r["key"]: r for r in rows}
|
||||
|
||||
|
||||
def get_config(profile: dict) -> dict:
|
||||
c = client_for(profile)
|
||||
saved = saved_config_overrides(int(profile["id"]))
|
||||
fields = []
|
||||
for meta in RTORRENT_CONFIG_FIELDS:
|
||||
item = dict(meta)
|
||||
saved_item = saved.get(meta["key"])
|
||||
try:
|
||||
item["value"] = _normalize_config_value(meta, c.call(meta["key"]))
|
||||
item["current_value"] = item["value"]
|
||||
item["ok"] = True
|
||||
except Exception as exc:
|
||||
item["value"] = ""
|
||||
item["current_value"] = ""
|
||||
item["ok"] = False
|
||||
item["error"] = str(exc)
|
||||
if saved_item:
|
||||
saved_value = _normalize_config_value(meta, saved_item.get("value"))
|
||||
baseline_raw = saved_item.get("baseline_value")
|
||||
if baseline_raw not in (None, ""):
|
||||
baseline_value = _normalize_config_value(meta, baseline_raw)
|
||||
else:
|
||||
baseline_value = _normalize_config_value(meta, item.get("current_value"))
|
||||
item["saved"] = True
|
||||
item["saved_value"] = saved_value
|
||||
item["baseline_value"] = baseline_value
|
||||
item["apply_on_start"] = bool(saved_item.get("apply_on_start"))
|
||||
item["changed"] = saved_value != baseline_value
|
||||
fields.append(item)
|
||||
return {"fields": fields, "apply_on_start": any(bool(v.get("apply_on_start")) for v in saved.values())}
|
||||
|
||||
|
||||
|
||||
def default_download_path(profile: dict) -> str:
|
||||
"""Return rTorrent default download directory for the active profile."""
|
||||
c = client_for(profile)
|
||||
errors = []
|
||||
for method in ("directory.default", "system.cwd"):
|
||||
try:
|
||||
value = str(c.call(method) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception as exc:
|
||||
errors.append(f"{method}: {exc}")
|
||||
raise RuntimeError("Cannot read rTorrent default download directory: " + "; ".join(errors))
|
||||
|
||||
def generate_config_text(values: dict) -> str:
|
||||
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
|
||||
lines = []
|
||||
for key, value in (values or {}).items():
|
||||
meta = known.get(key)
|
||||
if not meta or meta.get("readonly"):
|
||||
continue
|
||||
normalized = _normalize_config_value(meta, value)
|
||||
if meta.get("type") == "text" and any(ch.isspace() for ch in normalized):
|
||||
normalized = '"' + normalized.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
lines.append(f"{key}.set = {normalized}")
|
||||
return "\n".join(lines) + ("\n" if lines else "")
|
||||
|
||||
|
||||
def _read_rtorrent_config_value(client, key: str, meta: dict) -> str:
|
||||
return _normalize_config_value(meta, client.call(key))
|
||||
|
||||
|
||||
def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]:
|
||||
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
|
||||
user_id = default_user_id()
|
||||
now = utcnow()
|
||||
profile_id = int(profile["id"])
|
||||
baseline_values = baseline_values or {}
|
||||
clear_set = set(clear_keys or [])
|
||||
stored = []
|
||||
with connect() as conn:
|
||||
for key in clear_set:
|
||||
if key in known:
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
)
|
||||
for key, value in (values or {}).items():
|
||||
if key in clear_set:
|
||||
continue
|
||||
meta = known.get(key)
|
||||
if not meta or meta.get("readonly"):
|
||||
continue
|
||||
normalized = _normalize_config_value(meta, value)
|
||||
existing = conn.execute(
|
||||
"SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
).fetchone()
|
||||
existing_baseline = existing.get("baseline_value") if existing else None
|
||||
|
||||
# Keep the first reference value forever until the override is cleared.
|
||||
# Without this, a second save could treat already-overridden rTorrent
|
||||
# values as the new baseline and the UI would stop marking them as changed.
|
||||
if existing_baseline not in (None, ""):
|
||||
baseline = _normalize_config_value(meta, existing_baseline)
|
||||
else:
|
||||
baseline = _normalize_config_value(meta, baseline_values.get(key)) if key in baseline_values else None
|
||||
|
||||
if baseline not in (None, "") and normalized == baseline:
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
)
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(user_id, profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
|
||||
)
|
||||
stored.append(key)
|
||||
conn.execute(
|
||||
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?",
|
||||
(1 if apply_on_start else 0, now, user_id, profile_id),
|
||||
)
|
||||
return stored
|
||||
|
||||
|
||||
def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_start: bool = False, clear_keys: list[str] | None = None) -> dict:
|
||||
updated, errors = [], []
|
||||
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
|
||||
c = client_for(profile)
|
||||
baseline_values = {}
|
||||
for key, raw_value in (values or {}).items():
|
||||
meta = known.get(key)
|
||||
if not meta or meta.get("readonly"):
|
||||
continue
|
||||
try:
|
||||
baseline_values[key] = _read_rtorrent_config_value(c, key, meta)
|
||||
except Exception:
|
||||
pass
|
||||
stored = store_config_overrides(profile, values, apply_on_start, baseline_values, clear_keys)
|
||||
if not apply_now:
|
||||
return {"ok": True, "updated": [], "stored": stored, "errors": []}
|
||||
for key, raw_value in (values or {}).items():
|
||||
if key not in known:
|
||||
continue
|
||||
meta = known[key]
|
||||
if meta.get("readonly"):
|
||||
continue
|
||||
value = _normalize_config_value(meta, raw_value)
|
||||
rpc_value = int(value) if meta.get("type") in {"bool", "number"} else value
|
||||
try:
|
||||
try:
|
||||
c.call(key + ".set", "", rpc_value)
|
||||
except Exception:
|
||||
c.call(key + ".set", rpc_value)
|
||||
updated.append(key)
|
||||
except Exception as exc:
|
||||
errors.append({"key": key, "error": str(exc)})
|
||||
return {"ok": not errors, "updated": updated, "stored": stored, "errors": errors}
|
||||
|
||||
|
||||
|
||||
def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict:
|
||||
"""Remove saved UI overrides and return the freshly read rTorrent config."""
|
||||
# Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent.
|
||||
user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
).fetchone()
|
||||
removed = int((row or {}).get("count") or 0)
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
)
|
||||
config = get_config(profile)
|
||||
config["reset_removed"] = removed
|
||||
return config
|
||||
|
||||
|
||||
def apply_startup_overrides(profile: dict) -> dict:
|
||||
rows = saved_config_overrides(int(profile["id"]))
|
||||
values = {k: v.get("value") for k, v in rows.items() if v.get("apply_on_start")}
|
||||
if not values:
|
||||
return {"ok": True, "updated": [], "errors": [], "skipped": True}
|
||||
return set_config(profile, values, apply_now=True, apply_on_start=True)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Keep split module exports compatible with the previous single rtorrent.py module.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
118
pytorrent/services/rtorrent/diagnostics.py
Normal file
118
pytorrent/services/rtorrent/diagnostics.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
import shlex
|
||||
|
||||
def scgi_diagnostics(profile: dict) -> dict:
|
||||
c = client_for(profile)
|
||||
started = time.perf_counter()
|
||||
body = dumps((), methodname="system.client_version", allow_none=True).encode("utf-8")
|
||||
headers = {
|
||||
"CONTENT_LENGTH": str(len(body)),
|
||||
"SCGI": "1",
|
||||
"REQUEST_METHOD": "POST",
|
||||
"REQUEST_URI": c.path,
|
||||
"SCRIPT_NAME": c.path,
|
||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||
"CONTENT_TYPE": "text/xml",
|
||||
}
|
||||
header_blob = b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in headers.items())
|
||||
payload = str(len(header_blob)).encode("ascii") + b":" + header_blob + b"," + body
|
||||
metrics = {
|
||||
"url": profile.get("scgi_url"),
|
||||
"host": c.host,
|
||||
"port": c.port,
|
||||
"path": c.path,
|
||||
"timeout_seconds": c.timeout,
|
||||
"request_bytes": len(payload),
|
||||
}
|
||||
connect_started = time.perf_counter()
|
||||
with socket.create_connection((c.host, c.port), timeout=c.timeout) as sock:
|
||||
sock.settimeout(c.timeout)
|
||||
metrics["connect_ms"] = round((time.perf_counter() - connect_started) * 1000, 2)
|
||||
send_started = time.perf_counter()
|
||||
sock.sendall(payload)
|
||||
metrics["send_ms"] = round((time.perf_counter() - send_started) * 1000, 2)
|
||||
chunks: list[bytes] = []
|
||||
first_byte_at = None
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if chunk and first_byte_at is None:
|
||||
first_byte_at = time.perf_counter()
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
response = b"".join(chunks)
|
||||
metrics["response_bytes"] = len(response)
|
||||
metrics["first_byte_ms"] = round(((first_byte_at or time.perf_counter()) - started) * 1000, 2)
|
||||
metrics["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||
if not response:
|
||||
raise ConnectionError("Empty response from rTorrent SCGI")
|
||||
xml_response = response
|
||||
if b"\r\n\r\n" in xml_response:
|
||||
xml_response = xml_response.split(b"\r\n\r\n", 1)[1]
|
||||
elif b"\n\n" in xml_response:
|
||||
xml_response = xml_response.split(b"\n\n", 1)[1]
|
||||
result, _ = loads(xml_response)
|
||||
metrics["xml_bytes"] = len(xml_response)
|
||||
metrics["client_version"] = str(result[0]) if result else ""
|
||||
metrics["ok"] = True
|
||||
return metrics
|
||||
|
||||
|
||||
|
||||
def profile_diagnostics(profile: dict) -> dict:
|
||||
"""Lightweight per-profile diagnostics for save/test UI."""
|
||||
started = time.perf_counter()
|
||||
result = {"profile_id": profile.get("id"), "ok": False, "checks": {}}
|
||||
try:
|
||||
c = client_for(profile)
|
||||
version = str(c.call("system.client_version") or "")
|
||||
library = ""
|
||||
try:
|
||||
library = str(c.call("system.library_version") or "")
|
||||
except Exception:
|
||||
library = ""
|
||||
paths = {}
|
||||
for key, method in (("default_directory", "directory.default"), ("cwd", "system.cwd")):
|
||||
try:
|
||||
paths[key] = str(c.call(method) or "")
|
||||
except Exception as exc:
|
||||
paths[key] = {"error": str(exc)}
|
||||
write_permissions = {}
|
||||
free_disk = {}
|
||||
base = paths.get("default_directory") if isinstance(paths.get("default_directory"), str) else ""
|
||||
if base:
|
||||
try:
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"test -w {shlex.quote(base)} && printf writable || printf readonly")
|
||||
write_permissions[base] = str(out or "").strip() or "unknown"
|
||||
except Exception as exc:
|
||||
write_permissions[base] = f"error: {exc}"
|
||||
try:
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"df -Pk {shlex.quote(base)} | tail -1 | awk '{{print $4}}'")
|
||||
kb = int(str(out or "0").strip() or 0)
|
||||
free_disk[base] = {"free_bytes": kb * 1024, "free_h": human_size(kb * 1024)}
|
||||
except Exception as exc:
|
||||
free_disk[base] = {"error": str(exc)}
|
||||
result.update({
|
||||
"ok": True,
|
||||
"status": "online",
|
||||
"version": version,
|
||||
"library_version": library,
|
||||
"base_paths": paths,
|
||||
"write_permissions": write_permissions,
|
||||
"free_disk": free_disk,
|
||||
"response_time_ms": round((time.perf_counter() - started) * 1000, 2),
|
||||
})
|
||||
except Exception as exc:
|
||||
result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)})
|
||||
if result.get("ok") and result.get("response_time_ms", 0) > 1500:
|
||||
result["status"] = "slow"
|
||||
return result
|
||||
|
||||
|
||||
# Note: Keep split module exports compatible with the previous single rtorrent.py module.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
353
pytorrent/services/rtorrent/files.py
Normal file
353
pytorrent/services/rtorrent/files.py
Normal file
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
|
||||
def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
rows = client_for(profile).f.multicall(torrent_hash, "", "f.path=", "f.size_bytes=", "f.completed_chunks=", "f.size_chunks=", "f.priority=")
|
||||
files = []
|
||||
for idx, r in enumerate(rows):
|
||||
size = int(r[1] or 0)
|
||||
completed_chunks = int(r[2] or 0)
|
||||
size_chunks = int(r[3] or 0)
|
||||
progress = 100.0 if size <= 0 else round((completed_chunks / size_chunks) * 100, 2) if size_chunks else 0.0
|
||||
files.append({
|
||||
"index": idx,
|
||||
"path": r[0],
|
||||
"size": size,
|
||||
"size_h": human_size(size),
|
||||
"completed_chunks": completed_chunks,
|
||||
"size_chunks": size_chunks,
|
||||
"progress": min(100.0, max(0.0, progress)),
|
||||
"priority": int(r[4] or 0),
|
||||
})
|
||||
return files
|
||||
|
||||
|
||||
def torrent_file_tree(profile: dict, torrent_hash: str) -> dict:
|
||||
# Note: The tree is built from rTorrent file paths without changing the existing flat file API.
|
||||
root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
parts = [part for part in str(item.get("path") or "").split("/") if part]
|
||||
node = root
|
||||
prefix: list[str] = []
|
||||
for part in parts[:-1]:
|
||||
prefix.append(part)
|
||||
children = node.setdefault("children", {})
|
||||
node = children.setdefault(part, {"name": part, "path": "/".join(prefix), "type": "directory", "size": 0, "children": {}})
|
||||
name = parts[-1] if parts else str(item.get("path") or f"file-{item.get('index')}")
|
||||
child = dict(item)
|
||||
child.update({"name": name, "type": "file"})
|
||||
node.setdefault("children", {})[name] = child
|
||||
def finalize(node: dict) -> dict:
|
||||
if node.get("type") == "file":
|
||||
return node
|
||||
children = [finalize(v) for v in node.get("children", {}).values()]
|
||||
children.sort(key=lambda x: (x.get("type") != "directory", str(x.get("name") or "").lower()))
|
||||
node["children"] = children
|
||||
node["size"] = sum(int(c.get("size") or 0) for c in children)
|
||||
node["size_h"] = human_size(node["size"])
|
||||
return node
|
||||
return finalize(root)
|
||||
|
||||
|
||||
|
||||
def _torrent_file_remote_path(profile: dict, torrent_hash: str, index: int) -> tuple[dict, str]:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
selected = next((f for f in files if int(f.get("index", -1)) == int(index)), None)
|
||||
if selected is None:
|
||||
available = ", ".join(str(f.get("index")) for f in files[:20]) or "none"
|
||||
raise ValueError(f"File index {index} not found. Available indexes: {available}")
|
||||
base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
|
||||
rel = str(selected.get("path") or "").lstrip("/")
|
||||
if len(files) == 1 and base and not base.endswith("/"):
|
||||
path = base
|
||||
else:
|
||||
path = _remote_join(base, rel)
|
||||
return selected, path
|
||||
|
||||
|
||||
def download_tmp_dir() -> str:
|
||||
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return str(PYTORRENT_TMP_DIR)
|
||||
|
||||
|
||||
def _remote_readability_error(c: ScgiRtorrentClient, source_path: str) -> str | None:
|
||||
script = (
|
||||
'p=$1; '
|
||||
'command -v base64 >/dev/null 2>&1 || { echo "base64 command not found on rTorrent host"; exit 0; }; '
|
||||
'[ -e "$p" ] || { echo "source file does not exist"; exit 0; }; '
|
||||
'[ -f "$p" ] || { echo "source path is not a regular file"; exit 0; }; '
|
||||
'[ -r "$p" ] || { echo "source file is not readable by rTorrent"; exit 0; }; '
|
||||
'echo OK'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-download-check", source_path) or "").strip()
|
||||
return None if output == "OK" else (output or "source file cannot be read by rTorrent")
|
||||
|
||||
|
||||
def remote_file_readability_error(profile: dict, source_path: str) -> str | None:
|
||||
return _remote_readability_error(client_for(profile), source_path)
|
||||
|
||||
|
||||
def iter_remote_file_chunks(profile: dict, source_path: str, size: int | None = None, chunk_size: int | None = None):
|
||||
c = client_for(profile)
|
||||
clean = _remote_clean_path(source_path)
|
||||
err = _remote_readability_error(c, clean)
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
block_size = max(65536, int(chunk_size or REMOTE_READ_CHUNK_BYTES or 1048576))
|
||||
offset = 0
|
||||
emitted = 0
|
||||
script = (
|
||||
'p=$1; bs=$2; skip=$3; '
|
||||
'command -v base64 >/dev/null 2>&1 || { printf "ERR\tbase64 command not found on rTorrent host"; exit 0; }; '
|
||||
'[ -r "$p" ] || { printf "ERR\tsource file is not readable by rTorrent"; exit 0; }; '
|
||||
'dd if="$p" bs="$bs" skip="$skip" count=1 2>/dev/null | base64 | tr -d "\n"'
|
||||
)
|
||||
while size is None or emitted < int(size):
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-download-read", clean, str(block_size), str(offset)) or "")
|
||||
if output.startswith("ERR\t"):
|
||||
raise RuntimeError(output.split("\t", 1)[1] or "remote read failed")
|
||||
if not output:
|
||||
break
|
||||
try:
|
||||
chunk = __import__("base64").b64decode(output, validate=False)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"remote read returned invalid base64: {exc}") from exc
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
emitted += len(chunk)
|
||||
offset += 1
|
||||
if size is not None and emitted >= int(size):
|
||||
break
|
||||
|
||||
|
||||
def torrent_download_file_info(profile: dict, torrent_hash: str, index: int) -> dict:
|
||||
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
|
||||
err = remote_file_readability_error(profile, remote_path)
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
return {**selected, "remote_path": remote_path, "download_name": LocalPath(str(selected.get("path") or remote_path)).name}
|
||||
|
||||
|
||||
def torrent_download_zip_items(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> list[dict]:
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
items = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
_, remote_path = _torrent_file_remote_path(profile, torrent_hash, int(item["index"]))
|
||||
err = remote_file_readability_error(profile, remote_path)
|
||||
if err:
|
||||
raise RuntimeError(f"{item.get('path') or item.get('index')}: {err}")
|
||||
items.append({**item, "remote_path": remote_path})
|
||||
if not items:
|
||||
raise ValueError("No files selected")
|
||||
return items
|
||||
|
||||
|
||||
def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str:
|
||||
token = uuid.uuid4().hex
|
||||
safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80]
|
||||
target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}"
|
||||
script = (
|
||||
'src=$1; dst=$2; '
|
||||
'if [ ! -f "$src" ]; then echo "ERR\tmissing source"; exit 0; fi; '
|
||||
'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; '
|
||||
'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", source_path, target) or "").strip()
|
||||
parts = (output.splitlines()[0] if output else "").split("\t", 2)
|
||||
if len(parts) >= 2 and parts[0] == "OK":
|
||||
return parts[1]
|
||||
detail = parts[2] if len(parts) > 2 else (parts[1] if len(parts) > 1 else output)
|
||||
raise RuntimeError(detail or "Cannot stage file through rTorrent")
|
||||
|
||||
|
||||
def _remote_stage_zip(c: ScgiRtorrentClient, files: list[dict], suffix: str = ".zip") -> str:
|
||||
if not files:
|
||||
raise ValueError("No files selected")
|
||||
token = uuid.uuid4().hex
|
||||
tmp_base = download_tmp_dir().rstrip("/")
|
||||
list_path = f"{tmp_base}/pytorrent-zip-list-{token}.txt"
|
||||
zip_path = f"{tmp_base}/pytorrent-download-{token}{suffix}"
|
||||
lines = []
|
||||
for item in files:
|
||||
src = str(item.get("remote_path") or "")
|
||||
arc = str(item.get("path") or LocalPath(src).name).lstrip("/") or LocalPath(src).name
|
||||
lines.append(src.replace("\t", " ") + "\t" + arc.replace("\t", " "))
|
||||
list_data = "\n".join(lines)
|
||||
script = (
|
||||
'list=$1; zip=$2; data=$3; umask 022; printf "%s\n" "$data" > "$list"; '
|
||||
'rm -f "$zip"; tmpdir=$(mktemp -d /tmp/pytorrent-zip-XXXXXX) || exit 3; '
|
||||
'rc=0; while IFS=$(printf "\\t") read -r src arc; do '
|
||||
'[ -n "$src" ] || continue; '
|
||||
'if [ ! -f "$src" ]; then echo "missing source: $src" >&2; rc=4; break; fi; '
|
||||
'case "$arc" in /*|../*|*/../*) echo "unsafe zip path: $arc" >&2; rc=5; break;; esac; '
|
||||
'dir=${arc%/*}; if [ "$dir" != "$arc" ]; then mkdir -p "$tmpdir/$dir" || { rc=$?; break; }; fi; cp -- "$src" "$tmpdir/$arc" || { rc=$?; break; }; '
|
||||
'done; if [ $rc -eq 0 ]; then (cd "$tmpdir" && zip -qr "$zip" .) || rc=$?; fi; '
|
||||
'rm -rf "$tmpdir" "$list"; '
|
||||
'if [ $rc -eq 0 ] && [ -f "$zip" ]; then chmod 0644 "$zip" 2>/dev/null || true; printf "OK\t%s\n" "$zip"; else printf "ERR\t%s\n" "$rc"; fi'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-zip", list_path, zip_path, list_data) or "").strip()
|
||||
parts = (output.splitlines()[0] if output else "").split("\t", 1)
|
||||
if len(parts) == 2 and parts[0] == "OK":
|
||||
return parts[1]
|
||||
raise RuntimeError(output or "Cannot create ZIP through rTorrent")
|
||||
|
||||
|
||||
def _remote_remove_staged(profile: dict, path: str) -> None:
|
||||
clean = str(path or "")
|
||||
tmp_prefix = download_tmp_dir().rstrip("/") + "/pytorrent-download-"
|
||||
if not clean.startswith(tmp_prefix):
|
||||
return
|
||||
try:
|
||||
_rt_execute(client_for(profile), "execute.throw", "rm", "-f", clean)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def torrent_staged_file_path(profile: dict, torrent_hash: str, index: int) -> dict:
|
||||
c = client_for(profile)
|
||||
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
|
||||
suffix = LocalPath(str(selected.get("path") or "file")).suffix
|
||||
staged = _remote_stage_path(c, remote_path, suffix)
|
||||
return {**selected, "remote_path": remote_path, "staged_path": staged, "download_name": LocalPath(str(selected.get("path") or staged)).name}
|
||||
|
||||
|
||||
def torrent_staged_zip_path(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
items = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
_, remote_path = _torrent_file_remote_path(profile, torrent_hash, int(item["index"]))
|
||||
items.append({**item, "remote_path": remote_path})
|
||||
staged = _remote_stage_zip(c, items)
|
||||
return {"staged_path": staged, "count": len(items)}
|
||||
|
||||
|
||||
def _torrent_raw_from_method(c: ScgiRtorrentClient, torrent_hash: str) -> bytes | None:
|
||||
for method in ("d.get_metafile", "d.metafile"):
|
||||
try:
|
||||
value = c.call(method, torrent_hash)
|
||||
except Exception:
|
||||
continue
|
||||
if hasattr(value, "data"):
|
||||
data = value.data
|
||||
elif isinstance(value, bytes):
|
||||
data = value
|
||||
elif isinstance(value, str):
|
||||
data = value.encode("latin-1", "ignore")
|
||||
else:
|
||||
data = None
|
||||
if data:
|
||||
return bytes(data)
|
||||
return None
|
||||
|
||||
|
||||
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str:
|
||||
for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"):
|
||||
try:
|
||||
value = str(c.call(method, torrent_hash) or "").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def export_torrent_file(profile: dict, torrent_hash: str) -> dict:
|
||||
c = client_for(profile)
|
||||
name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash
|
||||
filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name
|
||||
raw = _torrent_raw_from_method(c, torrent_hash)
|
||||
if raw:
|
||||
target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent"
|
||||
target.write_bytes(raw)
|
||||
return {"path": str(target), "download_name": filename, "local": True}
|
||||
source = _torrent_source_file(c, torrent_hash)
|
||||
if not source:
|
||||
raise RuntimeError("Cannot find torrent source file in rTorrent")
|
||||
staged = _remote_stage_path(c, source, ".torrent")
|
||||
return {"path": staged, "download_name": filename, "local": False}
|
||||
|
||||
|
||||
def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict:
|
||||
"""Set rTorrent file priorities for one torrent.
|
||||
|
||||
Note: Keeps the existing /files/priority API behavior and returns per-file errors
|
||||
instead of failing the whole batch on one invalid item.
|
||||
"""
|
||||
c = client_for(profile)
|
||||
updated = []
|
||||
errors = []
|
||||
for item in files or []:
|
||||
try:
|
||||
index = int(item.get("index"))
|
||||
priority = int(item.get("priority"))
|
||||
if priority < 0 or priority > 3:
|
||||
raise ValueError("Priority must be between 0 and 3")
|
||||
target = f"{torrent_hash}:f{index}"
|
||||
c.call("f.priority.set", target, priority)
|
||||
updated.append({"index": index, "priority": priority})
|
||||
except Exception as exc:
|
||||
errors.append({"item": item, "error": str(exc)})
|
||||
return {"updated": updated, "errors": errors}
|
||||
|
||||
def set_folder_priority(profile: dict, torrent_hash: str, folder_path: str, priority: int) -> dict:
|
||||
# Note: Folder priority applies the same rTorrent file priority to every descendant path.
|
||||
folder = str(folder_path or "").strip().strip("/")
|
||||
updates = []
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
path = str(item.get("path") or "").strip("/")
|
||||
if not folder or path == folder or path.startswith(folder + "/"):
|
||||
updates.append({"index": item["index"], "priority": int(priority)})
|
||||
if not updates:
|
||||
return {"updated": [], "errors": [{"folder": folder_path, "error": "No files matched folder"}]}
|
||||
return set_file_priorities(profile, torrent_hash, updates)
|
||||
|
||||
|
||||
def torrent_local_file_path(profile: dict, torrent_hash: str, index: int) -> str:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
selected = next((f for f in files if int(f.get("index", -1)) == int(index)), None)
|
||||
if not selected:
|
||||
raise ValueError("File index not found")
|
||||
base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
|
||||
rel = str(selected.get("path") or "").lstrip("/")
|
||||
if len(files) == 1 and base and not base.endswith("/"):
|
||||
path = base
|
||||
else:
|
||||
path = _remote_join(base, rel)
|
||||
# Note: HTTP file serving is enabled only for local profiles to avoid pretending remote files exist locally.
|
||||
if int(profile.get("is_remote") or 0):
|
||||
raise ValueError("HTTP file download is available only for local rTorrent profiles")
|
||||
local = LocalPath(path).resolve()
|
||||
if not local.exists() or not local.is_file():
|
||||
raise FileNotFoundError(f"Local file is not available: {local}")
|
||||
return str(local)
|
||||
|
||||
|
||||
def torrent_local_file_paths(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> list[dict]:
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
out = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
out.append({**item, "local_path": torrent_local_file_path(profile, torrent_hash, int(item["index"]))})
|
||||
return out
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Keep split module exports compatible with the previous single rtorrent.py module.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
4
pytorrent/services/rtorrent/shared.py
Normal file
4
pytorrent/services/rtorrent/shared.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Note: Backward-compatible internal alias for modules created during refactor.
|
||||
from .client import *
|
||||
488
pytorrent/services/rtorrent/system.py
Normal file
488
pytorrent/services/rtorrent/system.py
Normal file
@@ -0,0 +1,488 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from threading import RLock
|
||||
|
||||
from .client import *
|
||||
from .config import default_download_path
|
||||
from ...utils import human_size
|
||||
|
||||
|
||||
def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions."""
|
||||
# Note: Directory browsing stays remote-side, matching the original monolithic service behavior.
|
||||
c = client_for(profile)
|
||||
base = _remote_clean_path(path or default_download_path(profile))
|
||||
script = (
|
||||
'base=$1; '
|
||||
'[ -d "$base" ] || exit 2; '
|
||||
'dfline=$(df -Pk "$base" 2>/dev/null | awk "NR==2{print \\$2,\\$3,\\$4,\\$5}"); '
|
||||
'dir_count=0; file_count=0; '
|
||||
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
||||
'[ -e "$p" ] || continue; '
|
||||
'if [ -d "$p" ]; then dir_count=$((dir_count+1)); name=${p##*/}; printf "D\\t%s\\t%s\\n" "$name" "$p"; '
|
||||
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
||||
'done; '
|
||||
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
||||
'[ -n "$dfline" ] && printf "F\\t%s\\n" "$dfline"'
|
||||
)
|
||||
output = _rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-browse", base)
|
||||
dirs = []
|
||||
dir_count = 0
|
||||
file_count = 0
|
||||
disk_total = disk_used = disk_free = 0
|
||||
disk_percent = 0
|
||||
for line in str(output or "").splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
marker, rest = line.split("\t", 1)
|
||||
if marker == "D" and "\t" in rest:
|
||||
name, full_path = rest.split("\t", 1)
|
||||
if name not in {".", ".."}:
|
||||
dirs.append({"name": name, "path": full_path})
|
||||
elif marker == "M" and "\t" in rest:
|
||||
first, second = rest.split("\t", 1)
|
||||
try:
|
||||
dir_count = int(first or 0)
|
||||
file_count = int(second or 0)
|
||||
except Exception:
|
||||
dir_count = file_count = 0
|
||||
elif marker == "F":
|
||||
parts = rest.split()
|
||||
if len(parts) >= 4:
|
||||
try:
|
||||
disk_total = int(parts[0]) * 1024
|
||||
disk_used = int(parts[1]) * 1024
|
||||
disk_free = int(parts[2]) * 1024
|
||||
disk_percent = int(str(parts[3]).rstrip("%") or 0)
|
||||
except Exception:
|
||||
disk_total = disk_used = disk_free = disk_percent = 0
|
||||
dirs.sort(key=lambda x: x["name"].lower())
|
||||
parent = posixpath.dirname(base.rstrip("/")) or "/"
|
||||
if parent == base:
|
||||
parent = base
|
||||
# Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts.
|
||||
return {
|
||||
"path": base,
|
||||
"parent": parent,
|
||||
"dirs": dirs[:300],
|
||||
"source": "rtorrent",
|
||||
"dir_count": dir_count,
|
||||
"file_count": file_count,
|
||||
"total": disk_total,
|
||||
"used": disk_used,
|
||||
"free": disk_free,
|
||||
"total_h": human_size(disk_total),
|
||||
"used_h": human_size(disk_used),
|
||||
"free_h": human_size(disk_free),
|
||||
"used_percent": disk_percent,
|
||||
}
|
||||
|
||||
def remote_public_ip(profile: dict, force: bool = False) -> str:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
now = time.monotonic()
|
||||
cached = _REMOTE_PUBLIC_IP_CACHE.get(profile_id)
|
||||
if cached and not force and now - cached[0] < _REMOTE_PUBLIC_IP_TTL_SECONDS:
|
||||
return cached[1]
|
||||
script = (
|
||||
'for url in https://ifconfig.co https://ifconfig.me https://ipapi.linuxiarz.pl http://ifconfig.co http://ifconfig.me; do '
|
||||
'ip=$(curl -fsS --max-time 8 "$url" 2>/dev/null | tr -d "\r" | head -n 1 | sed "s/[^0-9a-fA-F:.]//g"); '
|
||||
'if [ -n "$ip" ]; then printf "%s" "$ip"; exit 0; fi; '
|
||||
'done; exit 1'
|
||||
)
|
||||
value = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script) or "").strip()
|
||||
if not value:
|
||||
raise RuntimeError("Cannot read remote public IP")
|
||||
_REMOTE_PUBLIC_IP_CACHE[profile_id] = (now, value)
|
||||
return value
|
||||
|
||||
|
||||
def remote_system_usage(profile: dict, force: bool = False) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
now = time.monotonic()
|
||||
cached = _REMOTE_USAGE_CACHE.get(profile_id)
|
||||
if cached and not force and now - cached[0] < _REMOTE_USAGE_TTL_SECONDS:
|
||||
usage = dict(cached[1])
|
||||
usage["cached"] = True
|
||||
return usage
|
||||
script = (
|
||||
'read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat; '
|
||||
'total1=$((user+nice+system+idle+iowait+irq+softirq+steal)); idle1=$((idle+iowait)); '
|
||||
'sleep 1; '
|
||||
'read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat; '
|
||||
'total2=$((user+nice+system+idle+iowait+irq+softirq+steal)); idle2=$((idle+iowait)); '
|
||||
'dt=$((total2-total1)); di=$((idle2-idle1)); '
|
||||
'cpu_pct=$(awk -v dt="$dt" -v di="$di" "BEGIN { if (dt > 0) printf \"%.1f\", (dt-di)*100/dt; else printf \"0.0\" }"); '
|
||||
"mem_total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo); "
|
||||
"mem_avail=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo); "
|
||||
'ram_pct=$(awk -v t="$mem_total" -v a="$mem_avail" "BEGIN { if (t > 0) printf \"%.1f\", (t-a)*100/t; else printf \"0.0\" }"); '
|
||||
'printf "%s %s" "$cpu_pct" "$ram_pct"'
|
||||
)
|
||||
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script) or "").strip()
|
||||
parts = output.split()
|
||||
if len(parts) < 2:
|
||||
raise RuntimeError(f"Cannot read remote CPU/RAM usage: {output}")
|
||||
usage = {"cpu": float(parts[0]), "ram": float(parts[1]), "source": "rtorrent-remote", "usage_source": "rtorrent-remote", "cached": False}
|
||||
_REMOTE_USAGE_CACHE[profile_id] = (now, usage)
|
||||
return dict(usage)
|
||||
|
||||
|
||||
def _usage_dict(total: int, used: int, free: int) -> dict:
|
||||
total = max(0, int(total or 0))
|
||||
used = max(0, int(used or 0))
|
||||
free = max(0, int(free or 0))
|
||||
pct = round((used / total) * 100, 1) if total else 0.0
|
||||
return {
|
||||
"ok": True,
|
||||
"total": total,
|
||||
"used": used,
|
||||
"free": free,
|
||||
"total_h": human_size(total),
|
||||
"used_h": human_size(used),
|
||||
"free_h": human_size(free),
|
||||
"percent": pct,
|
||||
}
|
||||
|
||||
|
||||
def _statvfs_usage(path: str) -> dict:
|
||||
stat = os.statvfs(path)
|
||||
total = int(stat.f_blocks * stat.f_frsize)
|
||||
free = int(stat.f_bavail * stat.f_frsize)
|
||||
used = max(0, total - free)
|
||||
return _usage_dict(total, used, free)
|
||||
|
||||
|
||||
def _remote_df_usage(profile: dict, path: str) -> dict:
|
||||
# Note: Disk paths belong to the rTorrent host. Query df through rTorrent so NFS/Btrfs mounts are measured correctly.
|
||||
clean_path = _remote_clean_path(path or os.sep)
|
||||
cache_key = f"remote-df:{profile.get('id')}:{clean_path}"
|
||||
now = time.monotonic()
|
||||
cached = _DISK_USAGE_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < _DISK_USAGE_TTL_SECONDS:
|
||||
return dict(cached[1])
|
||||
script = (
|
||||
'path=$1; '
|
||||
'if [ ! -e "$path" ]; then echo "ERR\tmissing path"; exit 0; fi; '
|
||||
'line=$(df -Pk "$path" 2>/dev/null | tail -n 1); '
|
||||
'if [ -z "$line" ]; then echo "ERR\tdf failed"; exit 0; fi; '
|
||||
'set -- $line; pct=${5%\\%}; '
|
||||
'if [ -z "$2" ] || [ -z "$3" ] || [ -z "$4" ]; then echo "ERR\tdf parse failed"; exit 0; fi; '
|
||||
'printf "OK\t%s\t%s\t%s\t%s\t%s\n" "$2" "$3" "$4" "$pct" "$6"'
|
||||
)
|
||||
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script, "pytorrent-df", clean_path) or "").strip()
|
||||
first_line = output.splitlines()[0] if output else ""
|
||||
parts = first_line.split("\t")
|
||||
if len(parts) >= 6 and parts[0] == "OK":
|
||||
total = int(parts[1]) * 1024
|
||||
used = int(parts[2]) * 1024
|
||||
free = int(parts[3]) * 1024
|
||||
usage = _usage_dict(total, used, free)
|
||||
usage.update({"path": clean_path, "source_path": parts[5] or clean_path, "fallback": False, "measure_source": "rtorrent-df"})
|
||||
else:
|
||||
error = parts[1] if len(parts) > 1 else (output or "df returned no data")
|
||||
usage = {"ok": False, "path": clean_path, "source_path": clean_path, "error": error, "percent": 0, "measure_source": "rtorrent-df"}
|
||||
_DISK_USAGE_CACHE[cache_key] = (now, dict(usage))
|
||||
return usage
|
||||
|
||||
|
||||
def _disk_usage_for_path(profile: dict, path: str, allow_parent_fallback: bool = False) -> dict:
|
||||
clean_path = _remote_clean_path(path or os.sep)
|
||||
try:
|
||||
return _remote_df_usage(profile, clean_path)
|
||||
except Exception as remote_exc:
|
||||
try:
|
||||
usage = _statvfs_usage(clean_path)
|
||||
usage.update({"path": clean_path, "source_path": clean_path, "fallback": False, "measure_source": "local-statvfs", "warning": str(remote_exc)})
|
||||
return usage
|
||||
except Exception as first_exc:
|
||||
usage = {"ok": False, "path": clean_path, "source_path": clean_path, "error": str(first_exc), "warning": str(remote_exc), "percent": 0}
|
||||
if not allow_parent_fallback:
|
||||
return usage
|
||||
probe = os.path.abspath(clean_path or os.sep)
|
||||
seen = set()
|
||||
while probe and probe not in seen:
|
||||
seen.add(probe)
|
||||
parent = os.path.dirname(probe)
|
||||
if parent == probe:
|
||||
break
|
||||
probe = parent
|
||||
try:
|
||||
usage = _statvfs_usage(probe)
|
||||
usage.update({"path": clean_path, "source_path": probe, "fallback": True, "measure_source": "local-statvfs", "warning": str(first_exc)})
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return usage
|
||||
|
||||
|
||||
def disk_usage_for_default_path(profile: dict) -> dict:
|
||||
"""Filesystem usage for the rTorrent default download directory."""
|
||||
path = default_download_path(profile)
|
||||
cache_key = f"default-disk:{profile.get('id')}:{path}"
|
||||
now = time.monotonic()
|
||||
cached = _DISK_USAGE_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < _DISK_USAGE_TTL_SECONDS:
|
||||
return dict(cached[1])
|
||||
usage = _disk_usage_for_path(profile, path, allow_parent_fallback=True)
|
||||
_DISK_USAGE_CACHE[cache_key] = (now, dict(usage))
|
||||
return usage
|
||||
|
||||
|
||||
def disk_usage_for_paths(profile: dict, paths: list[str] | None = None, mode: str = 'default', selected_path: str = '') -> dict:
|
||||
# Note: Aggregate/selected modes measure exact user paths on the rTorrent host; they do not fall back to parent/root partitions.
|
||||
default_path = default_download_path(profile)
|
||||
mode = mode if mode in {'default', 'selected', 'aggregate'} else 'default'
|
||||
user_paths: list[str] = []
|
||||
for item in paths or []:
|
||||
path = _remote_clean_path(str(item or '').strip())
|
||||
if path and path not in user_paths:
|
||||
user_paths.append(path)
|
||||
selected_path = _remote_clean_path(str(selected_path or '').strip())
|
||||
if mode == 'selected':
|
||||
source_paths = [selected_path] if selected_path else list(user_paths)
|
||||
elif mode == 'aggregate':
|
||||
source_paths = list(user_paths)
|
||||
else:
|
||||
source_paths = [default_path]
|
||||
if mode in {'selected', 'aggregate'} and not source_paths:
|
||||
source_paths = [default_path]
|
||||
clean_paths: list[str] = []
|
||||
for item in source_paths:
|
||||
path = _remote_clean_path(str(item or '').strip())
|
||||
if path and path not in clean_paths:
|
||||
clean_paths.append(path)
|
||||
entries = [_disk_usage_for_path(profile, path, allow_parent_fallback=(mode == 'default')) for path in clean_paths]
|
||||
chosen = entries[0] if entries else _disk_usage_for_path(profile, default_path, allow_parent_fallback=True)
|
||||
if mode == 'selected' and selected_path:
|
||||
chosen = next((x for x in entries if x.get('path') == selected_path), chosen)
|
||||
elif mode == 'aggregate':
|
||||
ok_entries = [x for x in entries if x.get('ok')]
|
||||
total = sum(int(x.get('total') or 0) for x in ok_entries)
|
||||
used = sum(int(x.get('used') or 0) for x in ok_entries)
|
||||
free = sum(int(x.get('free') or 0) for x in ok_entries)
|
||||
chosen = _usage_dict(total, used, free) if ok_entries else {"ok": False, "total": 0, "used": 0, "free": 0, "total_h": "0 B", "used_h": "0 B", "free_h": "0 B", "percent": 0}
|
||||
chosen.update({'path': 'aggregate', 'source_path': 'aggregate', 'fallback': False, 'measure_source': 'rtorrent-df'})
|
||||
chosen = dict(chosen)
|
||||
chosen['mode'] = mode
|
||||
chosen['paths'] = entries
|
||||
return chosen
|
||||
|
||||
|
||||
|
||||
_STATUS_META_CACHE: dict[int, dict[str, Any]] = {}
|
||||
_STATUS_META_LOCK = RLock()
|
||||
|
||||
|
||||
def _profile_cache_key(profile: dict) -> int:
|
||||
return int(profile.get("id") or 0)
|
||||
|
||||
|
||||
def _adaptive_meta_ttl(duration_ms: float) -> float:
|
||||
# Note: Slow rTorrent metadata calls get a longer TTL, while fast servers keep the footer fresh.
|
||||
if duration_ms >= 5000:
|
||||
return 30.0
|
||||
if duration_ms >= 2000:
|
||||
return 15.0
|
||||
if duration_ms >= 800:
|
||||
return 8.0
|
||||
return 3.0
|
||||
|
||||
|
||||
def _cached_rtorrent_meta(profile: dict, c: Any) -> dict[str, Any]:
|
||||
profile_id = _profile_cache_key(profile)
|
||||
now = time.monotonic()
|
||||
with _STATUS_META_LOCK:
|
||||
cached = _STATUS_META_CACHE.get(profile_id)
|
||||
if cached and now < float(cached.get("expires_at") or 0):
|
||||
meta = dict(cached.get("value") or {})
|
||||
meta["status_meta_cache"] = {"hit": True, "ttl_seconds": cached.get("ttl_seconds"), "duration_ms": cached.get("duration_ms")}
|
||||
return meta
|
||||
started = time.monotonic()
|
||||
version = str(c.system.client_version())
|
||||
try:
|
||||
down_limit = int(c.throttle.global_down.max_rate())
|
||||
except Exception:
|
||||
down_limit = 0
|
||||
try:
|
||||
up_limit = int(c.throttle.global_up.max_rate())
|
||||
except Exception:
|
||||
up_limit = 0
|
||||
meta = {
|
||||
"version": version,
|
||||
"down_limit": down_limit,
|
||||
"up_limit": up_limit,
|
||||
"down_limit_h": human_rate(down_limit) if down_limit else "∞",
|
||||
"up_limit_h": human_rate(up_limit) if up_limit else "∞",
|
||||
"open_sockets": _safe_rtorrent_first_int(c, ("network.open_sockets",)),
|
||||
"max_open_sockets": _safe_rtorrent_first_int(c, ("network.max_open_sockets",)),
|
||||
"open_files": _safe_rtorrent_first_int(c, ("network.open_files", "network.current_open_files", "network.open_file_count")),
|
||||
"max_open_files": _safe_rtorrent_first_int(c, ("network.max_open_files",)),
|
||||
"open_http": _safe_rtorrent_first_int(c, ("network.http.open", "network.http.current_open", "network.http.current_opened", "network.http.open_sockets")),
|
||||
"max_open_http": _safe_rtorrent_first_int(c, ("network.http.max_open",)),
|
||||
"max_downloads_global": _safe_rtorrent_first_int(c, ("throttle.max_downloads.global",)),
|
||||
"max_uploads_global": _safe_rtorrent_first_int(c, ("throttle.max_uploads.global",)),
|
||||
"listen_port": _rtorrent_listen_port(c),
|
||||
"rtorrent_time": _safe_rtorrent_time(c),
|
||||
}
|
||||
duration_ms = round((time.monotonic() - started) * 1000.0, 2)
|
||||
ttl = _adaptive_meta_ttl(duration_ms)
|
||||
with _STATUS_META_LOCK:
|
||||
_STATUS_META_CACHE[profile_id] = {"value": dict(meta), "expires_at": now + ttl, "ttl_seconds": ttl, "duration_ms": duration_ms}
|
||||
meta["status_meta_cache"] = {"hit": False, "ttl_seconds": ttl, "duration_ms": duration_ms}
|
||||
return meta
|
||||
|
||||
|
||||
def clear_profile_runtime_caches(profile_id: int) -> dict[str, int]:
|
||||
"""Clear rTorrent runtime caches that are scoped to a single profile."""
|
||||
# Note: This is used by Cleanup to force fresh disk/status/remote readings without restarting pyTorrent.
|
||||
profile_id = int(profile_id or 0)
|
||||
removed = {"disk_usage": 0, "remote_usage": 0, "remote_public_ip": 0, "status_meta": 0}
|
||||
prefix_candidates = (f"default-disk:{profile_id}:", f"remote-df:{profile_id}:")
|
||||
for key in list(_DISK_USAGE_CACHE.keys()):
|
||||
if any(str(key).startswith(prefix) for prefix in prefix_candidates):
|
||||
_DISK_USAGE_CACHE.pop(key, None)
|
||||
removed["disk_usage"] += 1
|
||||
if _REMOTE_USAGE_CACHE.pop(profile_id, None) is not None:
|
||||
removed["remote_usage"] += 1
|
||||
if _REMOTE_PUBLIC_IP_CACHE.pop(profile_id, None) is not None:
|
||||
removed["remote_public_ip"] += 1
|
||||
with _STATUS_META_LOCK:
|
||||
if _STATUS_META_CACHE.pop(profile_id, None) is not None:
|
||||
removed["status_meta"] += 1
|
||||
return removed
|
||||
|
||||
def _safe_rtorrent_int(callable_obj, default=None):
|
||||
"""Return an integer rTorrent metric without failing the whole status poll."""
|
||||
try:
|
||||
value = callable_obj()
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_rtorrent_value(callable_obj, default=None):
|
||||
"""Return any rTorrent metric without failing the whole status poll."""
|
||||
try:
|
||||
value = callable_obj()
|
||||
return default if value is None else value
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
|
||||
def _rtorrent_read_candidates(method_name: str) -> tuple[str, ...]:
|
||||
"""Return getter variants used by different rTorrent XMLRPC builds."""
|
||||
name = str(method_name or "").strip()
|
||||
if not name:
|
||||
return tuple()
|
||||
candidates = [name]
|
||||
if not name.endswith("="):
|
||||
candidates.append(f"{name}=")
|
||||
else:
|
||||
candidates.append(name.rstrip("="))
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _safe_rtorrent_first_int(c, method_names, default=None):
|
||||
"""Try several rTorrent XMLRPC getter names and return the first integer value."""
|
||||
for method_name in method_names:
|
||||
for candidate in _rtorrent_read_candidates(method_name):
|
||||
value = _safe_rtorrent_int(lambda name=candidate: c.call(name), None)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _safe_rtorrent_first_value(c, method_names, default=None):
|
||||
"""Try several rTorrent XMLRPC getter names and return the first non-empty value."""
|
||||
for method_name in method_names:
|
||||
for candidate in _rtorrent_read_candidates(method_name):
|
||||
value = _safe_rtorrent_value(lambda name=candidate: c.call(name), None)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _rtorrent_listen_port(c):
|
||||
"""Return the configured incoming port, preferring network.port_range over port-open state."""
|
||||
port_range = _safe_rtorrent_first_value(c, ("network.port_range",))
|
||||
if port_range:
|
||||
first = str(port_range).split("-", 1)[0].strip()
|
||||
if first:
|
||||
return first
|
||||
value = _safe_rtorrent_first_value(c, ("network.port_open", "network.open_port"))
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
def _safe_rtorrent_time(c):
|
||||
"""Read rTorrent server time when supported; otherwise let the browser clock remain authoritative."""
|
||||
candidates = (
|
||||
lambda: c.system.time_seconds(),
|
||||
lambda: c.system.time(),
|
||||
)
|
||||
for candidate in candidates:
|
||||
value = _safe_rtorrent_int(candidate)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def system_status(profile: dict, rows: list[dict] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
meta = _cached_rtorrent_meta(profile, c)
|
||||
if rows is None:
|
||||
from .torrents import list_torrents
|
||||
rows = list_torrents(profile)
|
||||
else:
|
||||
rows = list(rows)
|
||||
# Note: ruTorrent-style footer metadata is cached adaptively; live speeds still come from fresh torrent rows.
|
||||
checking_count = sum(1 for t in rows if t.get("status") == "Checking" or int(t.get("hashing") or 0) > 0)
|
||||
active_downloads = sum(1 for t in rows if not t["complete"] and t["state"] and not t.get("paused") and t.get("status") != "Checking")
|
||||
active_uploads = sum(1 for t in rows if t["complete"] and t["state"] and not t.get("paused"))
|
||||
return {
|
||||
"ok": True,
|
||||
"version": meta.get("version"),
|
||||
"total": len(rows),
|
||||
"active": sum(1 for t in rows if t["state"]),
|
||||
"seeding": sum(1 for t in rows if t["complete"] and t["state"] and not t.get("paused")),
|
||||
"leeching": sum(1 for t in rows if not t["complete"] and t["state"] and not t.get("paused") and t.get("status") != "Checking"),
|
||||
"checking": checking_count,
|
||||
"paused": sum(1 for t in rows if t.get("paused")),
|
||||
"stopped": sum(1 for t in rows if not t["state"]),
|
||||
"down_rate": sum(t["down_rate"] for t in rows),
|
||||
"down_rate_h": human_rate(sum(t["down_rate"] for t in rows)),
|
||||
"up_rate": sum(t["up_rate"] for t in rows),
|
||||
"up_rate_h": human_rate(sum(t["up_rate"] for t in rows)),
|
||||
"down_limit": meta.get("down_limit", 0),
|
||||
"up_limit": meta.get("up_limit", 0),
|
||||
"down_limit_h": meta.get("down_limit_h", "∞"),
|
||||
"up_limit_h": meta.get("up_limit_h", "∞"),
|
||||
"total_down": sum(t["down_total"] for t in rows),
|
||||
"total_up": sum(t["up_total"] for t in rows),
|
||||
"total_down_h": human_size(sum(t["down_total"] for t in rows)),
|
||||
"total_up_h": human_size(sum(t["up_total"] for t in rows)),
|
||||
"open_sockets": meta.get("open_sockets"),
|
||||
"max_open_sockets": meta.get("max_open_sockets"),
|
||||
"open_files": meta.get("open_files"),
|
||||
"max_open_files": meta.get("max_open_files"),
|
||||
"open_http": meta.get("open_http"),
|
||||
"max_open_http": meta.get("max_open_http"),
|
||||
"active_downloads": active_downloads,
|
||||
"max_downloads_global": meta.get("max_downloads_global"),
|
||||
"active_uploads": active_uploads,
|
||||
"max_uploads_global": meta.get("max_uploads_global"),
|
||||
"listen_port": meta.get("listen_port"),
|
||||
"rtorrent_time": meta.get("rtorrent_time"),
|
||||
"status_meta_cache": meta.get("status_meta_cache", {}),
|
||||
"disk": disk_usage_for_default_path(profile),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Export private cache-backed helpers where the old monolith exposed them through services.rtorrent.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
879
pytorrent/services/rtorrent/torrents.py
Normal file
879
pytorrent/services/rtorrent/torrents.py
Normal file
@@ -0,0 +1,879 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
from .system import disk_usage_for_default_path
|
||||
|
||||
|
||||
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _parse_xmlrpc_size_limit(value) -> int:
|
||||
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K."""
|
||||
# Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes.
|
||||
text = str(value or '').strip().lower()
|
||||
if not text:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
multiplier = 1
|
||||
if text[-1:] in {'k', 'm', 'g'}:
|
||||
suffix = text[-1]
|
||||
text = text[:-1]
|
||||
multiplier = {'k': 1024, 'm': 1024 * 1024, 'g': 1024 * 1024 * 1024}[suffix]
|
||||
try:
|
||||
return max(1, int(float(text) * multiplier))
|
||||
except Exception:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
|
||||
|
||||
def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
"""Return the current rTorrent XML-RPC request size limit."""
|
||||
# Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC.
|
||||
try:
|
||||
raw = client_for(profile).call('network.xmlrpc.size_limit')
|
||||
limit = _parse_xmlrpc_size_limit(raw)
|
||||
return {'ok': True, 'raw': str(raw), 'bytes': limit, 'human': human_size(limit)}
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'raw': '', 'bytes': XMLRPC_DEFAULT_SIZE_LIMIT_BYTES, 'human': human_size(XMLRPC_DEFAULT_SIZE_LIMIT_BYTES), 'error': str(exc)}
|
||||
|
||||
|
||||
def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int:
|
||||
"""Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file."""
|
||||
# Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file.
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f'd.directory.set={directory}')
|
||||
if label:
|
||||
commands.append(f'd.custom1.set={label}')
|
||||
method = 'load.raw' if file_priorities else ('load.raw_start' if start else 'load.raw')
|
||||
return len(dumps(("", Binary(data), *commands), methodname=method, allow_none=True).encode('utf-8'))
|
||||
|
||||
|
||||
def validate_torrent_upload_size(profile: dict, data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> dict:
|
||||
"""Check whether a .torrent upload fits the active rTorrent XML-RPC size limit."""
|
||||
limit = xmlrpc_size_limit(profile)
|
||||
request_bytes = estimate_torrent_upload_request_size(data, start, directory, label, file_priorities)
|
||||
allowed = request_bytes <= int(limit.get('bytes') or XMLRPC_DEFAULT_SIZE_LIMIT_BYTES)
|
||||
return {
|
||||
'ok': allowed,
|
||||
'request_bytes': request_bytes,
|
||||
'request_h': human_size(request_bytes),
|
||||
'limit_bytes': int(limit.get('bytes') or XMLRPC_DEFAULT_SIZE_LIMIT_BYTES),
|
||||
'limit_h': limit.get('human') or human_size(XMLRPC_DEFAULT_SIZE_LIMIT_BYTES),
|
||||
'limit_raw': limit.get('raw') or '',
|
||||
'limit_read_ok': bool(limit.get('ok')),
|
||||
'limit_error': limit.get('error') or '',
|
||||
'setting': 'network.xmlrpc.size_limit',
|
||||
'suggested_value': '16M',
|
||||
}
|
||||
|
||||
|
||||
def _mark_post_check_watch(profile_id: int, torrent_hash: str) -> None:
|
||||
if not torrent_hash:
|
||||
return
|
||||
_POST_CHECK_WATCH.setdefault(int(profile_id), {})[str(torrent_hash)] = time.time()
|
||||
|
||||
|
||||
def _clear_post_check_watch(profile_id: int, torrent_hash: str) -> None:
|
||||
profile_watch = _POST_CHECK_WATCH.get(int(profile_id))
|
||||
if not profile_watch:
|
||||
return
|
||||
profile_watch.pop(str(torrent_hash), None)
|
||||
if not profile_watch:
|
||||
_POST_CHECK_WATCH.pop(int(profile_id), None)
|
||||
|
||||
|
||||
def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
|
||||
profile_watch = _POST_CHECK_WATCH.get(int(profile_id)) or {}
|
||||
started_at = profile_watch.get(str(torrent_hash))
|
||||
if not started_at:
|
||||
return False
|
||||
age = time.time() - started_at
|
||||
if age > _POST_CHECK_WATCH_TTL_SECONDS:
|
||||
_clear_post_check_watch(profile_id, torrent_hash)
|
||||
return False
|
||||
# Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet.
|
||||
return age >= _POST_CHECK_WATCH_MIN_SECONDS
|
||||
|
||||
|
||||
def _label_names(value: str) -> list[str]:
|
||||
names: list[str] = []
|
||||
for part in str(value or "").replace(";", ",").replace("|", ",").split(","):
|
||||
label = part.strip()
|
||||
if label and label not in names:
|
||||
names.append(label)
|
||||
return names
|
||||
|
||||
|
||||
def _label_value(labels: list[str]) -> str:
|
||||
return ", ".join([label for label in labels if str(label or "").strip()])
|
||||
|
||||
|
||||
def _without_post_check_download_label(value: str | None) -> str:
|
||||
return _label_value([label for label in _label_names(str(value or "")) if label != POST_CHECK_DOWNLOAD_LABEL])
|
||||
|
||||
|
||||
def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, current_label: str | None = None) -> bool:
|
||||
label_source = current_label
|
||||
if label_source is None:
|
||||
try:
|
||||
label_source = str(c.call("d.custom1", str(torrent_hash or "")) or "")
|
||||
except Exception:
|
||||
label_source = ""
|
||||
labels = _label_names(str(label_source or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
# Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue.
|
||||
c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
|
||||
return True
|
||||
|
||||
|
||||
def _message_indicates_active_check(message: str) -> bool:
|
||||
msg = str(message or "").lower()
|
||||
if not msg:
|
||||
return False
|
||||
finished_markers = ("complete", "completed", "finished", "success", "succeeded", "failed", "done")
|
||||
if any(marker in msg for marker in finished_markers):
|
||||
return False
|
||||
active_markers = ("checking", "hashing", "hash check queued", "hash check scheduled", "check hash queued", "recheck queued", "rechecking")
|
||||
return any(marker in msg for marker in active_markers)
|
||||
|
||||
|
||||
def _row_progress_complete(row: dict) -> bool:
|
||||
size = int(row.get("size") or 0)
|
||||
completed = int(row.get("completed_bytes") or 0)
|
||||
return bool(row.get("complete")) or (size > 0 and completed >= size) or float(row.get("progress") or 0) >= 100.0
|
||||
|
||||
|
||||
def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool:
|
||||
labels = _label_names(str(row.get("label") or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
status = str(row.get("status") or "").lower()
|
||||
started_after_wait = bool(int(row.get("state") or 0)) and status != "checking"
|
||||
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
|
||||
return False
|
||||
# Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
|
||||
clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
|
||||
row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
|
||||
return True
|
||||
|
||||
|
||||
def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict[str, dict] | None = None) -> list[dict]:
|
||||
"""Start complete torrents after check; stop and label incomplete ones for Smart Queue."""
|
||||
previous_rows = previous_rows or {}
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
c = client_for(profile)
|
||||
changes: list[dict] = []
|
||||
for row in rows:
|
||||
h = str(row.get("hash") or "")
|
||||
prev = previous_rows.get(h) or {}
|
||||
try:
|
||||
if h and _cleanup_post_check_label_if_ready(c, row):
|
||||
changes.append({"hash": h, "action": "remove_post_check_label"})
|
||||
except Exception as exc:
|
||||
changes.append({"hash": h, "action": "remove_post_check_label_failed", "error": str(exc)})
|
||||
was_checking = str(prev.get("status") or "") == "Checking" or int(prev.get("hashing") or 0) > 0
|
||||
watched_recheck = _is_post_check_watched(profile_id, h)
|
||||
is_checking = str(row.get("status") or "") == "Checking" or int(row.get("hashing") or 0) > 0
|
||||
if not h or not (was_checking or watched_recheck) or is_checking:
|
||||
continue
|
||||
complete = _row_progress_complete(row)
|
||||
try:
|
||||
if complete:
|
||||
# Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
|
||||
start_result = start_or_resume_hash(c, h)
|
||||
clear_post_check_download_label(c, h, str(row.get("label") or ""))
|
||||
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
|
||||
changes.append({"hash": h, "action": "start_seed_after_check", "complete": True, "result": start_result})
|
||||
else:
|
||||
labels = _label_names(str(row.get("label") or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
labels.append(POST_CHECK_DOWNLOAD_LABEL)
|
||||
label_value = _label_value(labels)
|
||||
# Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
|
||||
c.call("d.stop", h)
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
except Exception:
|
||||
pass
|
||||
c.call("d.custom1.set", h, label_value)
|
||||
row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value})
|
||||
changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
|
||||
_clear_post_check_watch(profile_id, h)
|
||||
except Exception as exc:
|
||||
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
|
||||
return changes
|
||||
|
||||
|
||||
TORRENT_FIELDS = [
|
||||
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
||||
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
|
||||
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
|
||||
"d.custom=py_ratio_group", "d.message=", "d.hashing=", "d.is_active=", "d.is_multi_file=",
|
||||
]
|
||||
|
||||
TORRENT_OPTIONAL_FIELDS = [
|
||||
"d.timestamp.finished=",
|
||||
]
|
||||
|
||||
|
||||
def human_duration(seconds: int) -> str:
|
||||
# Note: Download ETA is derived locally from remaining bytes and current download speed.
|
||||
seconds = max(0, int(seconds or 0))
|
||||
if seconds <= 0:
|
||||
return '-'
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, _ = divmod(rem, 60)
|
||||
if days:
|
||||
return f"{days}d {hours}h"
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def normalize_row(row: list) -> dict:
|
||||
size = int(row[4] or 0)
|
||||
completed = int(row[5] or 0)
|
||||
progress = 100.0 if size <= 0 and int(row[3] or 0) else round((completed / size) * 100, 2) if size else 0.0
|
||||
ratio_raw = int(row[6] or 0)
|
||||
down_rate = int(row[8] or 0)
|
||||
up_rate = int(row[7] or 0)
|
||||
remaining_bytes = max(0, size - completed)
|
||||
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not int(row[3] or 0) else 0
|
||||
directory = str(row[14] or "")
|
||||
base_path = str(row[15] or "")
|
||||
is_multi_file = int(row[22] or 0) if len(row) > 22 else 0
|
||||
completed_at = int(row[23] or 0) if len(row) > 23 else 0
|
||||
|
||||
# Show the selected download location only. Hide the torrent root
|
||||
# directory for multi-file torrents and the filename for single-file
|
||||
# torrents. Data deletion still uses the full d.base_path elsewhere.
|
||||
if base_path and base_path != "/":
|
||||
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
elif directory and is_multi_file and directory != "/":
|
||||
display_parent = posixpath.dirname(directory.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
elif directory:
|
||||
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
|
||||
else:
|
||||
display_path = ""
|
||||
msg = str(row[19] or "")
|
||||
msg_l = msg.lower()
|
||||
hashing = int(row[20] or 0) if len(row) > 20 else 0
|
||||
is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0)
|
||||
state = int(row[2] or 0)
|
||||
complete = int(row[3] or 0)
|
||||
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
|
||||
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
|
||||
is_paused = bool(state) and not bool(is_active) and not is_checking
|
||||
status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
to_download_bytes = remaining_bytes if not complete else 0
|
||||
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
|
||||
return {
|
||||
"hash": str(row[0] or ""),
|
||||
"name": str(row[1] or ""),
|
||||
"state": state,
|
||||
"active": is_active,
|
||||
"paused": is_paused,
|
||||
"complete": complete,
|
||||
"size": size,
|
||||
"size_h": human_size(size),
|
||||
"completed_bytes": completed,
|
||||
"progress": progress,
|
||||
"ratio": round(ratio_raw / 1000, 3),
|
||||
"up_rate": up_rate,
|
||||
"up_rate_h": human_rate(up_rate),
|
||||
"down_rate": down_rate,
|
||||
"down_rate_h": human_rate(down_rate),
|
||||
"eta_seconds": eta_seconds,
|
||||
"eta_h": human_duration(eta_seconds) if eta_seconds else "-",
|
||||
"up_total": int(row[9] or 0),
|
||||
"up_total_h": human_size(row[9] or 0),
|
||||
"down_total": int(row[10] or 0),
|
||||
"down_total_h": human_size(row[10] or 0),
|
||||
"to_download": to_download_bytes,
|
||||
"to_download_h": human_size(to_download_bytes) if to_download_bytes else "",
|
||||
"peers": int(row[11] or 0),
|
||||
"seeds": int(row[12] or 0),
|
||||
"priority": int(row[13] or 0),
|
||||
"path": display_path,
|
||||
"created": int(row[16] or 0),
|
||||
"completed_at": completed_at,
|
||||
"label": str(row[17] or ""),
|
||||
"ratio_group": str(row[18] or ""),
|
||||
"message": msg,
|
||||
"status": status,
|
||||
"hashing": hashing,
|
||||
}
|
||||
|
||||
|
||||
def list_torrents(profile: dict) -> list[dict]:
|
||||
c = client_for(profile)
|
||||
try:
|
||||
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
|
||||
except Exception:
|
||||
# Keep compatibility with older rTorrent builds that do not expose optional timestamp fields.
|
||||
rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
|
||||
return [normalize_row(list(row)) for row in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
fields = [
|
||||
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=",
|
||||
"p.up_rate=", "p.port=", "p.is_encrypted=", "p.is_incoming=",
|
||||
"p.is_snubbed=", "p.is_banned=",
|
||||
]
|
||||
try:
|
||||
rows = client_for(profile).p.multicall(torrent_hash, "", *fields)
|
||||
except Exception:
|
||||
fields = ["p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", "p.up_rate=", "p.port=", "p.is_encrypted="]
|
||||
rows = client_for(profile).p.multicall(torrent_hash, "", *fields)
|
||||
peers = []
|
||||
for idx, r in enumerate(rows):
|
||||
peers.append({
|
||||
"index": idx,
|
||||
"ip": r[0],
|
||||
"client": r[1],
|
||||
"completed": int(r[2] or 0),
|
||||
"down_rate": int(r[3] or 0),
|
||||
"down_rate_h": human_rate(r[3] or 0),
|
||||
"up_rate": int(r[4] or 0),
|
||||
"up_rate_h": human_rate(r[4] or 0),
|
||||
"port": int(r[5] or 0),
|
||||
"encrypted": bool(r[6]) if len(r) > 6 else False,
|
||||
"incoming": bool(r[7]) if len(r) > 7 else False,
|
||||
"snubbed": bool(r[8]) if len(r) > 8 else False,
|
||||
"banned": bool(r[9]) if len(r) > 9 else False,
|
||||
})
|
||||
return peers
|
||||
|
||||
|
||||
|
||||
|
||||
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
|
||||
errors = []
|
||||
for method, args in candidates:
|
||||
try:
|
||||
result = c.call(method, *args)
|
||||
return {"ok": True, "method": method, "result": result}
|
||||
except Exception as exc:
|
||||
errors.append(f"{method}: {exc}")
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
|
||||
|
||||
def _tracker_domain(url: str) -> str:
|
||||
raw = str(url or '').strip()
|
||||
if not raw:
|
||||
return ''
|
||||
parsed = urlparse(raw if '://' in raw else f'http://{raw}')
|
||||
host = (parsed.hostname or '').lower().strip('.')
|
||||
if host.startswith('www.'):
|
||||
host = host[4:]
|
||||
return host
|
||||
|
||||
|
||||
def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict:
|
||||
"""Return tracker domains grouped by torrent for the sidebar filter."""
|
||||
# Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list.
|
||||
hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
|
||||
if not hashes:
|
||||
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')]
|
||||
hashes = hashes[:max(1, int(limit or 1000))]
|
||||
by_hash: dict[str, list[dict]] = {}
|
||||
counts: dict[str, dict] = {}
|
||||
errors = []
|
||||
for h in hashes:
|
||||
try:
|
||||
items = []
|
||||
seen = set()
|
||||
for tr in torrent_trackers(profile, h):
|
||||
url = str(tr.get('url') or '')
|
||||
domain = _tracker_domain(url)
|
||||
if not domain or domain in seen:
|
||||
continue
|
||||
seen.add(domain)
|
||||
item = {'domain': domain, 'url': url}
|
||||
items.append(item)
|
||||
row = counts.setdefault(domain, {'domain': domain, 'url': url, 'count': 0})
|
||||
row['count'] += 1
|
||||
by_hash[h] = items
|
||||
except Exception as exc:
|
||||
errors.append({'hash': h, 'error': str(exc)})
|
||||
by_hash[h] = []
|
||||
trackers = sorted(counts.values(), key=lambda x: (-int(x.get('count') or 0), str(x.get('domain') or '')))
|
||||
return {'hashes': by_hash, 'trackers': trackers, 'errors': errors, 'scanned': len(hashes)}
|
||||
|
||||
def _safe_tracker_call(c: ScgiRtorrentClient, method: str, target: str, default=None):
|
||||
try:
|
||||
return c.call(method, target)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _tracker_target(torrent_hash: str, index: int) -> str:
|
||||
return f"{torrent_hash}:t{int(index)}"
|
||||
|
||||
def _tracker_int(value, default=None):
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _tracker_rows(c: ScgiRtorrentClient, torrent_hash: str) -> list[list]:
|
||||
fields = ("t.url=", "t.is_enabled=", "t.scrape_complete=", "t.scrape_incomplete=", "t.scrape_downloaded=")
|
||||
errors: list[str] = []
|
||||
for args in ((torrent_hash, "", *fields), ("", torrent_hash, *fields)):
|
||||
try:
|
||||
rows = c.call("t.multicall", *args)
|
||||
return [list(r) for r in (rows or [])]
|
||||
except Exception as exc:
|
||||
errors.append(f"t.multicall{args[:2]}: {exc}")
|
||||
# Note: Fallback keeps the sidebar tracker filter usable on rTorrent builds without t.multicall scrape fields.
|
||||
total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or 0
|
||||
rows: list[list] = []
|
||||
for index in range(max(0, total)):
|
||||
target = _tracker_target(torrent_hash, index)
|
||||
url = _safe_tracker_call(c, "t.url", target, "")
|
||||
if not url:
|
||||
for args in ((torrent_hash, index), ("", torrent_hash, index)):
|
||||
try:
|
||||
url = c.call("t.url", *args)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if url:
|
||||
enabled = _safe_tracker_call(c, "t.is_enabled", target, 1)
|
||||
rows.append([url, enabled, None, None, None])
|
||||
if rows:
|
||||
return rows
|
||||
raise RuntimeError("Cannot read trackers: " + "; ".join(errors))
|
||||
|
||||
|
||||
def torrent_trackers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
c = client_for(profile)
|
||||
rows = _tracker_rows(c, torrent_hash)
|
||||
trackers = []
|
||||
for idx, r in enumerate(rows):
|
||||
target = _tracker_target(torrent_hash, idx)
|
||||
last_announce = _safe_tracker_call(c, "t.activity_time_last", target, 0)
|
||||
scrape_time = _safe_tracker_call(c, "t.scrape_time_last", target, 0)
|
||||
if not last_announce:
|
||||
last_announce = scrape_time
|
||||
next_announce = _safe_tracker_call(c, "t.activity_time_next", target, 0)
|
||||
raw_seeds = _tracker_int(r[2], None)
|
||||
raw_peers = _tracker_int(r[3], None)
|
||||
raw_downloaded = _tracker_int(r[4], None)
|
||||
has_scrape = bool(_tracker_int(scrape_time, 0)) or raw_seeds not in (None, 0) or raw_peers not in (None, 0) or raw_downloaded not in (None, 0)
|
||||
trackers.append({
|
||||
"index": idx,
|
||||
"url": str(r[0] or ""),
|
||||
"enabled": bool(r[1]),
|
||||
"seeds": raw_seeds if has_scrape else None,
|
||||
"peers": raw_peers if has_scrape else None,
|
||||
"downloaded": raw_downloaded if has_scrape else None,
|
||||
"has_scrape": has_scrape,
|
||||
"last_announce": int(last_announce or 0),
|
||||
"next_announce": int(next_announce or 0),
|
||||
})
|
||||
return trackers
|
||||
|
||||
def tracker_action(profile: dict, torrent_hash: str, action_name: str, payload: dict | None = None) -> dict:
|
||||
payload = payload or {}
|
||||
c = client_for(profile)
|
||||
if action_name == "reannounce":
|
||||
return _call_first(c, [
|
||||
("d.tracker_announce", (torrent_hash,)),
|
||||
("d.tracker_announce", ("", torrent_hash)),
|
||||
("d.tracker_announce.force", (torrent_hash,)),
|
||||
])
|
||||
if action_name == "add":
|
||||
url = str(payload.get("url") or "").strip()
|
||||
if not url:
|
||||
raise ValueError("Missing tracker URL")
|
||||
return _call_first(c, [
|
||||
("d.tracker.insert", (torrent_hash, "", url)),
|
||||
("d.tracker.insert", (torrent_hash, 0, url)),
|
||||
("d.tracker.insert", ("", torrent_hash, "", url)),
|
||||
])
|
||||
if action_name in {"delete", "remove"}:
|
||||
# Note: Deleting trackers is guarded to keep at least one tracker attached to the torrent.
|
||||
index = int(payload.get("index", -1))
|
||||
if index < 0:
|
||||
raise ValueError("Invalid tracker index")
|
||||
total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or len(torrent_trackers(profile, torrent_hash))
|
||||
if total <= 1:
|
||||
raise ValueError("Cannot delete the last tracker")
|
||||
if index >= total:
|
||||
raise ValueError("Invalid tracker index")
|
||||
return _call_first(c, [
|
||||
("d.tracker.remove", (torrent_hash, index)),
|
||||
("d.tracker.remove", (torrent_hash, "", index)),
|
||||
("d.tracker.erase", (torrent_hash, index)),
|
||||
("d.tracker.erase", (torrent_hash, "", index)),
|
||||
("d.tracker.delete", (torrent_hash, index)),
|
||||
("d.tracker.delete", (torrent_hash, "", index)),
|
||||
])
|
||||
raise ValueError(f"Unknown tracker action: {action_name}")
|
||||
|
||||
|
||||
|
||||
def _int_rpc(c: ScgiRtorrentClient, method: str, h: str, default: int = 0) -> int:
|
||||
try:
|
||||
return int(c.call(method, h) or 0)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> str:
|
||||
try:
|
||||
return str(c.call(method, h) or '')
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
||||
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
||||
state = _int_rpc(c, 'd.state', h)
|
||||
active = _int_rpc(c, 'd.is_active', h)
|
||||
opened = _int_rpc(c, 'd.is_open', h)
|
||||
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
|
||||
return {
|
||||
'state': state,
|
||||
'open': opened,
|
||||
'active': active,
|
||||
'paused': bool(state and opened and not active),
|
||||
'stopped': not bool(state),
|
||||
'message': _str_rpc(c, 'd.message', h),
|
||||
}
|
||||
|
||||
|
||||
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Pause an active rTorrent item without stopping or closing it."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
try:
|
||||
if before.get('stopped'):
|
||||
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
|
||||
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
# Note: Smart Queue frees a slot with d.pause, not d.stop, so later d.resume behaves like ruTorrent.
|
||||
c.call('d.pause', h)
|
||||
result['commands'].append('d.pause')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Stop an active rTorrent item without using pause semantics."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched.
|
||||
c.call('d.stop', h)
|
||||
result['commands'].append('d.stop')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Resume only a paused rTorrent item; never convert it through stop/start."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
|
||||
return result
|
||||
if before.get('active'):
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
|
||||
# because those commands belong to Stopped/Open state, not a clean Paused state.
|
||||
c.call('d.resume', h)
|
||||
result['commands'].append('d.resume')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start: bool = False) -> dict:
|
||||
"""Start stopped torrents or resume real paused torrents.
|
||||
|
||||
Smart Queue passes prefer_start=True for candidates that were selected as stopped.
|
||||
This avoids treating rTorrent's intermediate open/inactive state after a check as
|
||||
a user pause and sending only d.resume, which can leave items pending forever.
|
||||
"""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
|
||||
if before.get('active'):
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
|
||||
if before.get('paused') and not prefer_start:
|
||||
# Note: Manual Start keeps the clean pause-to-resume path. Do not classify every
|
||||
# state=1/active=0 item as paused; after auto-check this can be only a transient
|
||||
# open/inactive rTorrent state and needs d.open + d.start.
|
||||
resumed = resume_paused_hash(c, h)
|
||||
resumed['mode'] = 'resume_paused'
|
||||
return resumed
|
||||
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
try:
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||
try:
|
||||
c.call('d.try_start', h)
|
||||
result['commands'].append('d.try_start')
|
||||
except Exception as exc2:
|
||||
result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}')
|
||||
result['ok'] = False
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = result.get('ok', True)
|
||||
return result
|
||||
|
||||
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
|
||||
payload = payload or {}
|
||||
resume_state = resume_state or {}
|
||||
completed_hashes = set(str(x) for x in (resume_state.get("completed_hashes") or []))
|
||||
previous_results = list(resume_state.get("results") or [])
|
||||
|
||||
def mark_done(torrent_hash: str, item: dict, results: list) -> None:
|
||||
completed_hashes.add(str(torrent_hash))
|
||||
state = {"completed_hashes": sorted(completed_hashes), "results": results}
|
||||
if checkpoint:
|
||||
checkpoint(state, len(completed_hashes), len(torrent_hashes))
|
||||
|
||||
def pending_hashes() -> list[str]:
|
||||
return [h for h in torrent_hashes if str(h) not in completed_hashes]
|
||||
|
||||
c = client_for(profile)
|
||||
methods = {
|
||||
"stop": "d.stop",
|
||||
"recheck": "d.check_hash",
|
||||
"reannounce": "d.tracker_announce",
|
||||
"remove": "d.erase",
|
||||
}
|
||||
if name == "set_label":
|
||||
label = str(payload.get("label") or "").strip()
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
c.call("d.custom1.set", h, label)
|
||||
item = {"hash": h, "label": label}
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "label": label, "results": results}
|
||||
if name == "set_ratio_group":
|
||||
group = str(payload.get("ratio_group") or "").strip()
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
c.call("d.custom.set", h, "py_ratio_group", group)
|
||||
item = {"hash": h, "ratio_group": group}
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "ratio_group": group, "results": results}
|
||||
if name == "move":
|
||||
path = _remote_clean_path(payload.get("path") or "")
|
||||
move_data = bool(payload.get("move_data"))
|
||||
recheck = bool(payload.get("recheck", move_data))
|
||||
keep_seeding = bool(payload.get("keep_seeding"))
|
||||
# Note: Automations can force seeding after a physical move even if the torrent was not active before.
|
||||
if not path:
|
||||
raise ValueError("Missing path")
|
||||
results = previous_results
|
||||
if move_data:
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "mkdir", "-p", path)
|
||||
for h in pending_hashes():
|
||||
item = {"hash": h, "path": path, "move_data": move_data, "keep_seeding": keep_seeding}
|
||||
try:
|
||||
was_state = int(c.call("d.state", h) or 0)
|
||||
except Exception:
|
||||
was_state = 0
|
||||
try:
|
||||
was_active = int(c.call("d.is_active", h) or 0)
|
||||
except Exception:
|
||||
was_active = was_state
|
||||
if move_data:
|
||||
if was_state == 0:
|
||||
c.call("d.directory.set", h, path)
|
||||
item["move_data"] = False
|
||||
item["skipped"] = "state is 0; data is not present, only directory updated"
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
continue
|
||||
src = _remote_clean_path(_torrent_data_path(c, h))
|
||||
if not src:
|
||||
raise ValueError(f"Cannot determine source path for {h}")
|
||||
dst = _remote_join(path, posixpath.basename(src.rstrip("/")))
|
||||
if src != dst:
|
||||
try:
|
||||
c.call("d.stop", h)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
except Exception:
|
||||
pass
|
||||
_run_remote_move(c, src, dst)
|
||||
item["moved_from"] = src
|
||||
item["moved_to"] = dst
|
||||
else:
|
||||
item["skipped"] = "source and destination are the same"
|
||||
c.call("d.directory.set", h, path)
|
||||
if recheck:
|
||||
try:
|
||||
c.call("d.check_hash", h)
|
||||
except Exception as exc:
|
||||
item["recheck_error"] = str(exc)
|
||||
if keep_seeding or was_state or was_active:
|
||||
try:
|
||||
c.call("d.start", h)
|
||||
item["started_after_move"] = True
|
||||
except Exception as exc:
|
||||
item["start_after_move_error"] = str(exc)
|
||||
else:
|
||||
c.call("d.directory.set", h, path)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "results": results}
|
||||
if name == "pause":
|
||||
# Note: The app pause action is now a pure d.pause so later resume works without stop/start.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = pause_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name in {"resume", "unpause"}:
|
||||
# Note: Resume/Unpause uses only d.resume for Paused state.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = resume_paused_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name == "start":
|
||||
# Note: Start separates Stopped from Paused; paused items go through d.resume, stopped items through d.start.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = start_or_resume_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
|
||||
method = methods.get(name)
|
||||
if not method:
|
||||
raise ValueError(f"Unknown action: {name}")
|
||||
remove_data = bool(payload.get("remove_data")) if name == "remove" else False
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = {"hash": h}
|
||||
if remove_data:
|
||||
item = _remove_torrent_data(c, h)
|
||||
c.call(method, h)
|
||||
if name == "recheck":
|
||||
# Note: Recheck is tracked so even very fast checks still receive the after-check start/stop policy.
|
||||
_mark_post_check_watch(int(profile.get("id") or 0), h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": remove_data, "results": results}
|
||||
|
||||
def add_magnet(profile: dict, uri: str, start: bool = True, directory: str = "", label: str = "") -> dict:
|
||||
c = client_for(profile)
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f"d.directory.set={directory}")
|
||||
if label:
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
if start:
|
||||
c.load.start_verbose("", uri, *commands)
|
||||
else:
|
||||
c.load.normal("", uri, *commands)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def set_limits(profile: dict, down: int | None, up: int | None):
|
||||
"""Set global speed limits in bytes/s.
|
||||
|
||||
rTorrent XML-RPC setters need an empty target string as the first
|
||||
argument. Without it rTorrent returns: target must be a string.
|
||||
"""
|
||||
c = client_for(profile)
|
||||
if down is not None:
|
||||
c.call("throttle.global_down.max_rate.set", "", int(down))
|
||||
if up is not None:
|
||||
c.call("throttle.global_up.max_rate.set", "", int(up))
|
||||
return {"ok": True, "down": int(down or 0), "up": int(up or 0)}
|
||||
|
||||
|
||||
def add_torrent_raw(profile: dict, data: bytes, start: bool = True, directory: str = "", label: str = "", file_priorities: list[dict] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f"d.directory.set={directory}")
|
||||
if label:
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
# Note: File selection before start loads the torrent stopped, changes priorities, then starts it if requested.
|
||||
method = "load.raw" if file_priorities else ("load.raw_start" if start else "load.raw")
|
||||
c.call(method, "", Binary(data), *commands)
|
||||
info_hash = ""
|
||||
if file_priorities:
|
||||
try:
|
||||
from ..torrent_meta import parse_torrent
|
||||
info_hash = parse_torrent(data).get("info_hash") or ""
|
||||
set_file_priorities(profile, info_hash, file_priorities)
|
||||
if start:
|
||||
c.call("d.start", info_hash)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "info_hash": info_hash, "error": str(exc)}
|
||||
return {"ok": True, "info_hash": info_hash}
|
||||
|
||||
|
||||
|
||||
# Note: Export all service functions, including compatibility helpers used by routes and older imports.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
1993
pytorrent/services/rtorrent_original_TO_DELETE
Normal file
1993
pytorrent/services/rtorrent_original_TO_DELETE
Normal file
File diff suppressed because it is too large
Load Diff
1438
pytorrent/services/smart_queue.py
Normal file
1438
pytorrent/services/smart_queue.py
Normal file
File diff suppressed because it is too large
Load Diff
159
pytorrent/services/speed_peaks.py
Normal file
159
pytorrent/services/speed_peaks.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from .rtorrent import human_rate
|
||||
|
||||
_SESSION_STARTED_AT = utcnow()
|
||||
_CACHE: dict[int, dict[str, Any]] = {}
|
||||
_LOADED = False
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _empty_peak(profile_id: int, all_time: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
# Note: One in-memory structure keeps the current session and all-time record for the rTorrent profile.
|
||||
all_time = all_time or {}
|
||||
return {
|
||||
"profile_id": int(profile_id),
|
||||
"session_started_at": _SESSION_STARTED_AT,
|
||||
"session_down_peak": 0,
|
||||
"session_up_peak": 0,
|
||||
"session_down_peak_at": None,
|
||||
"session_up_peak_at": None,
|
||||
"all_time_down_peak": int(all_time.get("all_time_down_peak") or 0),
|
||||
"all_time_up_peak": int(all_time.get("all_time_up_peak") or 0),
|
||||
"all_time_down_peak_at": all_time.get("all_time_down_peak_at"),
|
||||
"all_time_up_peak_at": all_time.get("all_time_up_peak_at"),
|
||||
}
|
||||
|
||||
|
||||
def load_cache() -> None:
|
||||
# Note: All-time records are loaded on application start, while the session record starts from zero.
|
||||
global _LOADED
|
||||
with _LOCK:
|
||||
if _LOADED:
|
||||
return
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM transfer_speed_peaks").fetchall()
|
||||
for row in rows:
|
||||
profile_id = int(row.get("profile_id") or 0)
|
||||
if profile_id:
|
||||
_CACHE[profile_id] = _empty_peak(profile_id, row)
|
||||
_LOADED = True
|
||||
|
||||
|
||||
def _ensure_profile(profile_id: int) -> dict[str, Any]:
|
||||
# Note: Lazy loading protects profiles added after startup from empty records.
|
||||
profile_id = int(profile_id)
|
||||
item = _CACHE.get(profile_id)
|
||||
if item:
|
||||
return item
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM transfer_speed_peaks WHERE profile_id=?", (profile_id,)).fetchone()
|
||||
item = _empty_peak(profile_id, row)
|
||||
_CACHE[profile_id] = item
|
||||
return item
|
||||
|
||||
|
||||
def _persist(item: dict[str, Any]) -> None:
|
||||
# Note: SQLite is updated only when a new session or all-time record appears.
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO transfer_speed_peaks(
|
||||
profile_id, session_started_at, session_down_peak, session_up_peak,
|
||||
session_down_peak_at, session_up_peak_at, all_time_down_peak,
|
||||
all_time_up_peak, all_time_down_peak_at, all_time_up_peak_at,
|
||||
created_at, updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
session_started_at=excluded.session_started_at,
|
||||
session_down_peak=excluded.session_down_peak,
|
||||
session_up_peak=excluded.session_up_peak,
|
||||
session_down_peak_at=excluded.session_down_peak_at,
|
||||
session_up_peak_at=excluded.session_up_peak_at,
|
||||
all_time_down_peak=excluded.all_time_down_peak,
|
||||
all_time_up_peak=excluded.all_time_up_peak,
|
||||
all_time_down_peak_at=excluded.all_time_down_peak_at,
|
||||
all_time_up_peak_at=excluded.all_time_up_peak_at,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
int(item["profile_id"]),
|
||||
item["session_started_at"],
|
||||
int(item["session_down_peak"]),
|
||||
int(item["session_up_peak"]),
|
||||
item.get("session_down_peak_at"),
|
||||
item.get("session_up_peak_at"),
|
||||
int(item["all_time_down_peak"]),
|
||||
int(item["all_time_up_peak"]),
|
||||
item.get("all_time_down_peak_at"),
|
||||
item.get("all_time_up_peak_at"),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _public(item: dict[str, Any]) -> dict[str, Any]:
|
||||
# Note: The frontend receives bytes/s and ready labels matching the existing speed format.
|
||||
return {
|
||||
"session_started_at": item["session_started_at"],
|
||||
"session": {
|
||||
"down": int(item["session_down_peak"]),
|
||||
"up": int(item["session_up_peak"]),
|
||||
"down_h": human_rate(int(item["session_down_peak"])),
|
||||
"up_h": human_rate(int(item["session_up_peak"])),
|
||||
"down_at": item.get("session_down_peak_at"),
|
||||
"up_at": item.get("session_up_peak_at"),
|
||||
},
|
||||
"all_time": {
|
||||
"down": int(item["all_time_down_peak"]),
|
||||
"up": int(item["all_time_up_peak"]),
|
||||
"down_h": human_rate(int(item["all_time_down_peak"])),
|
||||
"up_h": human_rate(int(item["all_time_up_peak"])),
|
||||
"down_at": item.get("all_time_down_peak_at"),
|
||||
"up_at": item.get("all_time_up_peak_at"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def record(profile_id: int, down_rate: int = 0, up_rate: int = 0) -> dict[str, Any]:
|
||||
# Note: The poller calls this in the background; the database updates only after a record is beaten.
|
||||
load_cache()
|
||||
down_rate = max(0, int(down_rate or 0))
|
||||
up_rate = max(0, int(up_rate or 0))
|
||||
measured_at = utcnow()
|
||||
changed = False
|
||||
with _LOCK:
|
||||
item = _ensure_profile(int(profile_id))
|
||||
if down_rate > int(item["session_down_peak"]):
|
||||
item["session_down_peak"] = down_rate
|
||||
item["session_down_peak_at"] = measured_at
|
||||
changed = True
|
||||
if up_rate > int(item["session_up_peak"]):
|
||||
item["session_up_peak"] = up_rate
|
||||
item["session_up_peak_at"] = measured_at
|
||||
changed = True
|
||||
if down_rate > int(item["all_time_down_peak"]):
|
||||
item["all_time_down_peak"] = down_rate
|
||||
item["all_time_down_peak_at"] = measured_at
|
||||
changed = True
|
||||
if up_rate > int(item["all_time_up_peak"]):
|
||||
item["all_time_up_peak"] = up_rate
|
||||
item["all_time_up_peak_at"] = measured_at
|
||||
changed = True
|
||||
result = _public(item)
|
||||
if changed:
|
||||
_persist(item)
|
||||
return result
|
||||
|
||||
|
||||
def current(profile_id: int) -> dict[str, Any]:
|
||||
# Note: The REST API can show the latest known record without forcing a new measurement.
|
||||
load_cache()
|
||||
with _LOCK:
|
||||
return _public(_ensure_profile(int(profile_id)))
|
||||
26
pytorrent/services/startup_config.py
Normal file
26
pytorrent/services/startup_config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from time import sleep
|
||||
from . import preferences, rtorrent
|
||||
|
||||
_started = False
|
||||
|
||||
|
||||
def schedule_startup_config_apply(socketio, delay_seconds: int = 60) -> None:
|
||||
"""Apply saved rTorrent UI overrides after pyTorrent has been running for a moment."""
|
||||
global _started
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
|
||||
def runner():
|
||||
sleep(max(0, int(delay_seconds)))
|
||||
try:
|
||||
for profile in preferences.list_profiles():
|
||||
result = rtorrent.apply_startup_overrides(profile)
|
||||
if not result.get("skipped"):
|
||||
socketio.emit("rtorrent_config_applied", {"profile_id": profile["id"], "result": result})
|
||||
except Exception as exc:
|
||||
socketio.emit("rtorrent_config_applied", {"ok": False, "error": str(exc)})
|
||||
|
||||
socketio.start_background_task(runner)
|
||||
68
pytorrent/services/torrent_cache.py
Normal file
68
pytorrent/services/torrent_cache.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import RLock
|
||||
from time import time
|
||||
from . import rtorrent
|
||||
|
||||
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
|
||||
|
||||
|
||||
class TorrentCache:
|
||||
def __init__(self):
|
||||
self._lock = RLock()
|
||||
self._data: dict[int, dict[str, dict]] = {}
|
||||
self._errors: dict[int, str] = {}
|
||||
self._updated_at: dict[int, float] = {}
|
||||
|
||||
def snapshot(self, profile_id: int) -> list[dict]:
|
||||
with self._lock:
|
||||
return list(self._data.get(profile_id, {}).values())
|
||||
|
||||
def error(self, profile_id: int) -> str:
|
||||
with self._lock:
|
||||
return self._errors.get(profile_id, "")
|
||||
|
||||
def clear_profile(self, profile_id: int) -> int:
|
||||
"""Clear cached torrent rows for one profile and return removed row count."""
|
||||
# Note: Cleanup clears only in-memory rows for the selected profile; rTorrent data is untouched.
|
||||
profile_id = int(profile_id or 0)
|
||||
with self._lock:
|
||||
removed = len(self._data.get(profile_id, {}))
|
||||
self._data.pop(profile_id, None)
|
||||
self._errors.pop(profile_id, None)
|
||||
self._updated_at.pop(profile_id, None)
|
||||
return removed
|
||||
|
||||
def refresh(self, profile: dict) -> dict:
|
||||
profile_id = int(profile["id"])
|
||||
try:
|
||||
rows = rtorrent.list_torrents(profile)
|
||||
with self._lock:
|
||||
old = dict(self._data.get(profile_id, {}))
|
||||
post_check_changes = rtorrent.apply_post_check_policy(profile, rows, old)
|
||||
fresh = {t["hash"]: t for t in rows}
|
||||
with self._lock:
|
||||
added = [v for h, v in fresh.items() if h not in old]
|
||||
removed = [h for h in old.keys() if h not in fresh]
|
||||
updated = []
|
||||
for h, new in fresh.items():
|
||||
prev = old.get(h)
|
||||
if not prev:
|
||||
continue
|
||||
patch = {"hash": h}
|
||||
for key, value in new.items():
|
||||
if prev.get(key) != value:
|
||||
patch[key] = value
|
||||
if len(patch) > 1:
|
||||
updated.append(patch)
|
||||
self._data[profile_id] = fresh
|
||||
self._errors[profile_id] = ""
|
||||
self._updated_at[profile_id] = time()
|
||||
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
self._errors[profile_id] = str(exc)
|
||||
return {"ok": False, "profile_id": profile_id, "error": str(exc), "added": [], "updated": [], "removed": []}
|
||||
|
||||
|
||||
torrent_cache = TorrentCache()
|
||||
155
pytorrent/services/torrent_creator.py
Normal file
155
pytorrent/services/torrent_creator.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_PIECE_KIB = 256
|
||||
MIN_PIECE_KIB = 16
|
||||
MAX_PIECE_KIB = 16384
|
||||
|
||||
|
||||
def _bencode(value: Any) -> bytes:
|
||||
if isinstance(value, bool):
|
||||
value = int(value)
|
||||
if isinstance(value, int):
|
||||
return b"i" + str(value).encode("ascii") + b"e"
|
||||
if isinstance(value, bytes):
|
||||
return str(len(value)).encode("ascii") + b":" + value
|
||||
if isinstance(value, str):
|
||||
raw = value.encode("utf-8")
|
||||
return str(len(raw)).encode("ascii") + b":" + raw
|
||||
if isinstance(value, (list, tuple)):
|
||||
return b"l" + b"".join(_bencode(item) for item in value) + b"e"
|
||||
if isinstance(value, dict):
|
||||
items = []
|
||||
for key in sorted(value.keys(), key=lambda k: k.encode("utf-8") if isinstance(k, str) else bytes(k)):
|
||||
bkey = key.encode("utf-8") if isinstance(key, str) else bytes(key)
|
||||
items.append(_bencode(bkey) + _bencode(value[key]))
|
||||
return b"d" + b"".join(items) + b"e"
|
||||
raise TypeError(f"Unsupported bencode value: {type(value)!r}")
|
||||
|
||||
|
||||
def _clean_tracker_lines(raw: str) -> list[str]:
|
||||
lines = []
|
||||
seen = set()
|
||||
for item in str(raw or "").replace("\r", "\n").split("\n"):
|
||||
url = item.strip()
|
||||
if not url or url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
lines.append(url)
|
||||
return lines
|
||||
|
||||
|
||||
def _normalize_piece_size(piece_size_kib: int | str | None) -> int:
|
||||
try:
|
||||
kib = int(piece_size_kib or DEFAULT_PIECE_KIB)
|
||||
except Exception:
|
||||
kib = DEFAULT_PIECE_KIB
|
||||
kib = max(MIN_PIECE_KIB, min(MAX_PIECE_KIB, kib))
|
||||
return kib * 1024
|
||||
|
||||
|
||||
def _safe_path_parts(path: Path) -> list[str]:
|
||||
parts = [part for part in path.parts if part not in {"", ".", ".."}]
|
||||
if not parts:
|
||||
raise ValueError("File path inside torrent is empty")
|
||||
return parts
|
||||
|
||||
|
||||
def _iter_files(source: Path) -> list[tuple[Path, list[str], int]]:
|
||||
if source.is_file():
|
||||
return [(source, [source.name], source.stat().st_size)]
|
||||
if not source.is_dir():
|
||||
raise ValueError("Source must be an existing file or directory")
|
||||
rows: list[tuple[Path, list[str], int]] = []
|
||||
for root, dirs, files in os.walk(source):
|
||||
dirs[:] = sorted(d for d in dirs if not (Path(root) / d).is_symlink())
|
||||
for filename in sorted(files):
|
||||
full = Path(root) / filename
|
||||
if full.is_symlink() or not full.is_file():
|
||||
continue
|
||||
rel = full.relative_to(source)
|
||||
rows.append((full, _safe_path_parts(rel), full.stat().st_size))
|
||||
if not rows:
|
||||
raise ValueError("Source directory does not contain regular files")
|
||||
return rows
|
||||
|
||||
|
||||
def _piece_hashes(files: list[tuple[Path, list[str], int]], piece_size: int) -> bytes:
|
||||
pieces = bytearray()
|
||||
buffer = bytearray()
|
||||
for full, _parts, _size in files:
|
||||
with full.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(max(64 * 1024, min(piece_size, 1024 * 1024)))
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
while len(buffer) >= piece_size:
|
||||
piece = bytes(buffer[:piece_size])
|
||||
del buffer[:piece_size]
|
||||
pieces.extend(hashlib.sha1(piece).digest())
|
||||
if buffer:
|
||||
pieces.extend(hashlib.sha1(bytes(buffer)).digest())
|
||||
return bytes(pieces)
|
||||
|
||||
|
||||
def build_torrent(
|
||||
source_path: str,
|
||||
trackers: str = "",
|
||||
comment: str = "",
|
||||
source: str = "",
|
||||
piece_size_kib: int | str | None = DEFAULT_PIECE_KIB,
|
||||
private: bool = False,
|
||||
created_by: str = "pyTorrent",
|
||||
) -> dict[str, Any]:
|
||||
source_path = str(source_path or "").strip()
|
||||
if not source_path:
|
||||
raise ValueError("Source path is required")
|
||||
path = Path(source_path).expanduser().resolve()
|
||||
files = _iter_files(path)
|
||||
piece_size = _normalize_piece_size(piece_size_kib)
|
||||
|
||||
info: dict[str, Any] = {
|
||||
"name": path.name,
|
||||
"piece length": piece_size,
|
||||
"pieces": _piece_hashes(files, piece_size),
|
||||
}
|
||||
if private:
|
||||
info["private"] = 1
|
||||
if source:
|
||||
info["source"] = str(source).strip()
|
||||
if path.is_file():
|
||||
info["length"] = files[0][2]
|
||||
else:
|
||||
info["files"] = [{"length": size, "path": parts} for _full, parts, size in files]
|
||||
|
||||
tracker_lines = _clean_tracker_lines(trackers)
|
||||
meta: dict[str, Any] = {
|
||||
"created by": created_by,
|
||||
"creation date": int(time.time()),
|
||||
"info": info,
|
||||
}
|
||||
if tracker_lines:
|
||||
meta["announce"] = tracker_lines[0]
|
||||
meta["announce-list"] = [[url] for url in tracker_lines]
|
||||
if comment:
|
||||
meta["comment"] = str(comment).strip()
|
||||
|
||||
data = _bencode(meta)
|
||||
info_hash = hashlib.sha1(_bencode(info)).hexdigest().upper()
|
||||
return {
|
||||
"data": data,
|
||||
"filename": f"{path.name}.torrent",
|
||||
"info_hash": info_hash,
|
||||
"source_parent": str(path.parent),
|
||||
"file_count": len(files),
|
||||
"total_size": sum(size for _full, _parts, size in files),
|
||||
"piece_size": piece_size,
|
||||
"private": bool(private),
|
||||
"trackers": tracker_lines,
|
||||
}
|
||||
150
pytorrent/services/torrent_meta.py
Normal file
150
pytorrent/services/torrent_meta.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
class BencodeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class BencodeReader:
|
||||
def __init__(self, data: bytes):
|
||||
self.data = data
|
||||
self.pos = 0
|
||||
|
||||
def parse(self) -> Any:
|
||||
value = self._read_value()
|
||||
if self.pos != len(self.data):
|
||||
raise BencodeError("Trailing data in torrent file")
|
||||
return value
|
||||
|
||||
def _read_value(self) -> Any:
|
||||
if self.pos >= len(self.data):
|
||||
raise BencodeError("Unexpected end of bencoded data")
|
||||
token = self.data[self.pos:self.pos + 1]
|
||||
if token == b"i":
|
||||
return self._read_int()
|
||||
if token == b"l":
|
||||
return self._read_list()
|
||||
if token == b"d":
|
||||
return self._read_dict()
|
||||
if b"0" <= token <= b"9":
|
||||
return self._read_bytes()
|
||||
raise BencodeError(f"Invalid bencode token at offset {self.pos}")
|
||||
|
||||
def _read_int(self) -> int:
|
||||
self.pos += 1
|
||||
end = self.data.find(b"e", self.pos)
|
||||
if end < 0:
|
||||
raise BencodeError("Unterminated integer")
|
||||
raw = self.data[self.pos:end]
|
||||
self.pos = end + 1
|
||||
return int(raw)
|
||||
|
||||
def _read_bytes(self) -> bytes:
|
||||
colon = self.data.find(b":", self.pos)
|
||||
if colon < 0:
|
||||
raise BencodeError("Invalid byte string length")
|
||||
length = int(self.data[self.pos:colon])
|
||||
self.pos = colon + 1
|
||||
end = self.pos + length
|
||||
if end > len(self.data):
|
||||
raise BencodeError("Byte string exceeds input size")
|
||||
value = self.data[self.pos:end]
|
||||
self.pos = end
|
||||
return value
|
||||
|
||||
def _read_list(self) -> list[Any]:
|
||||
self.pos += 1
|
||||
out: list[Any] = []
|
||||
while self.pos < len(self.data) and self.data[self.pos:self.pos + 1] != b"e":
|
||||
out.append(self._read_value())
|
||||
if self.pos >= len(self.data):
|
||||
raise BencodeError("Unterminated list")
|
||||
self.pos += 1
|
||||
return out
|
||||
|
||||
def _read_dict(self) -> dict[bytes, Any]:
|
||||
self.pos += 1
|
||||
out: dict[bytes, Any] = {}
|
||||
while self.pos < len(self.data) and self.data[self.pos:self.pos + 1] != b"e":
|
||||
key = self._read_bytes()
|
||||
out[key] = self._read_value()
|
||||
if self.pos >= len(self.data):
|
||||
raise BencodeError("Unterminated dictionary")
|
||||
self.pos += 1
|
||||
return out
|
||||
|
||||
|
||||
def bencode(value: Any) -> bytes:
|
||||
if isinstance(value, int):
|
||||
return b"i" + str(value).encode("ascii") + b"e"
|
||||
if isinstance(value, bytes):
|
||||
return str(len(value)).encode("ascii") + b":" + value
|
||||
if isinstance(value, str):
|
||||
raw = value.encode("utf-8")
|
||||
return str(len(raw)).encode("ascii") + b":" + raw
|
||||
if isinstance(value, list):
|
||||
return b"l" + b"".join(bencode(item) for item in value) + b"e"
|
||||
if isinstance(value, dict):
|
||||
items = sorted(value.items(), key=lambda item: item[0] if isinstance(item[0], bytes) else str(item[0]).encode("utf-8"))
|
||||
raw = []
|
||||
for key, item in items:
|
||||
raw.append(bencode(key if isinstance(key, bytes) else str(key)))
|
||||
raw.append(bencode(item))
|
||||
return b"d" + b"".join(raw) + b"e"
|
||||
raise TypeError(f"Unsupported bencode type: {type(value)!r}")
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", "replace")
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def parse_torrent(data: bytes) -> dict:
|
||||
# Note: The parser is dependency-free so .torrent preview works in offline installations.
|
||||
root = BencodeReader(data).parse()
|
||||
if not isinstance(root, dict) or b"info" not in root:
|
||||
raise BencodeError("Missing torrent info dictionary")
|
||||
info = root[b"info"]
|
||||
if not isinstance(info, dict):
|
||||
raise BencodeError("Invalid torrent info dictionary")
|
||||
info_hash = hashlib.sha1(bencode(info)).hexdigest().upper()
|
||||
name = _text(info.get(b"name") or "")
|
||||
piece_length = int(info.get(b"piece length") or 0)
|
||||
private = int(info.get(b"private") or 0)
|
||||
files: list[dict] = []
|
||||
total = 0
|
||||
if b"files" in info:
|
||||
for entry in info.get(b"files") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
length = int(entry.get(b"length") or 0)
|
||||
path_parts = [_text(part) for part in entry.get(b"path") or []]
|
||||
rel_path = str(PurePosixPath(name, *path_parts)) if path_parts else name
|
||||
total += length
|
||||
files.append({"path": rel_path, "size": length})
|
||||
else:
|
||||
length = int(info.get(b"length") or 0)
|
||||
total = length
|
||||
files.append({"path": name, "size": length})
|
||||
announce = _text(root.get(b"announce") or "")
|
||||
trackers = [announce] if announce else []
|
||||
for tier in root.get(b"announce-list") or []:
|
||||
for tracker in tier if isinstance(tier, list) else [tier]:
|
||||
value = _text(tracker)
|
||||
if value and value not in trackers:
|
||||
trackers.append(value)
|
||||
return {
|
||||
"name": name,
|
||||
"info_hash": info_hash,
|
||||
"size": total,
|
||||
"file_count": len(files),
|
||||
"files": files,
|
||||
"trackers": trackers,
|
||||
"piece_length": piece_length,
|
||||
"private": private,
|
||||
}
|
||||
209
pytorrent/services/torrent_stats.py
Normal file
209
pytorrent/services/torrent_stats.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from . import rtorrent
|
||||
from .torrent_cache import torrent_cache
|
||||
|
||||
CACHE_SECONDS = 15 * 60
|
||||
_STARTUP_DELAY_SECONDS = 3 * 60
|
||||
_STARTED_AT = time.monotonic()
|
||||
_LOCK = threading.Lock()
|
||||
_BACKGROUND_LOCK = threading.Lock()
|
||||
_BACKGROUND_PROFILE_IDS: set[int] = set()
|
||||
|
||||
|
||||
def _human_size(value: int | float) -> str:
|
||||
size = float(value or 0)
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
|
||||
if abs(size) < 1024 or unit == "PiB":
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
|
||||
size /= 1024
|
||||
return f"{size:.1f} PiB"
|
||||
|
||||
|
||||
def _empty(profile_id: int, error: str = "") -> dict[str, Any]:
|
||||
now = utcnow()
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
"torrent_count": 0,
|
||||
"complete_count": 0,
|
||||
"incomplete_count": 0,
|
||||
"total_torrent_size": 0,
|
||||
"total_torrent_size_h": _human_size(0),
|
||||
"total_file_size": 0,
|
||||
"total_file_size_h": _human_size(0),
|
||||
"file_count": 0,
|
||||
"seeds_total": 0,
|
||||
"peers_total": 0,
|
||||
"down_rate_total": 0,
|
||||
"up_rate_total": 0,
|
||||
"down_rate_total_h": "0 B/s",
|
||||
"up_rate_total_h": "0 B/s",
|
||||
"sampled_torrents": 0,
|
||||
"errors": [],
|
||||
"error": error,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"age_seconds": 0,
|
||||
"stale": True,
|
||||
}
|
||||
|
||||
|
||||
def _load_cached(profile_id: int) -> dict[str, Any] | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM torrent_stats_cache WHERE profile_id=?", (profile_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
payload = json.loads(row.get("payload_json") or "{}")
|
||||
payload["created_at"] = row.get("created_at")
|
||||
payload["updated_at"] = row.get("updated_at")
|
||||
try:
|
||||
payload["age_seconds"] = max(0, int(time.time() - float(row.get("updated_epoch") or 0)))
|
||||
except Exception:
|
||||
payload["age_seconds"] = 0
|
||||
payload["stale"] = payload["age_seconds"] >= CACHE_SECONDS
|
||||
return payload
|
||||
|
||||
|
||||
def _save(profile_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
now = utcnow()
|
||||
payload = dict(payload)
|
||||
payload["updated_at"] = now
|
||||
payload["age_seconds"] = 0
|
||||
payload["stale"] = False
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO torrent_stats_cache(profile_id,payload_json,created_at,updated_at,updated_epoch)
|
||||
VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
payload_json=excluded.payload_json,
|
||||
updated_at=excluded.updated_at,
|
||||
updated_epoch=excluded.updated_epoch
|
||||
""",
|
||||
(profile_id, json.dumps(payload), now, now, time.time()),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def collect(profile: dict) -> dict[str, Any]:
|
||||
"""Collect heavier torrent/file statistics on demand or every cache window."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
torrents = rtorrent.list_torrents(profile)
|
||||
total_torrent_size = sum(int(t.get("size") or 0) for t in torrents)
|
||||
seeds_total = sum(int(t.get("seeds") or 0) for t in torrents)
|
||||
peers_total = sum(int(t.get("peers") or 0) for t in torrents)
|
||||
down_rate_total = sum(int(t.get("down_rate") or 0) for t in torrents)
|
||||
up_rate_total = sum(int(t.get("up_rate") or 0) for t in torrents)
|
||||
total_file_size = 0
|
||||
file_count = 0
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
# Note: File metadata is queried per torrent only during cached statistics refresh, not during every UI poll.
|
||||
for torrent in torrents:
|
||||
h = str(torrent.get("hash") or "")
|
||||
if not h:
|
||||
continue
|
||||
try:
|
||||
files = rtorrent.torrent_files(profile, h)
|
||||
file_count += len(files)
|
||||
total_file_size += sum(int(f.get("size") or 0) for f in files)
|
||||
except Exception as exc:
|
||||
errors.append({"hash": h, "name": str(torrent.get("name") or ""), "error": str(exc)})
|
||||
|
||||
torrent_cache.refresh(profile)
|
||||
payload = {
|
||||
"profile_id": profile_id,
|
||||
"torrent_count": len(torrents),
|
||||
"complete_count": sum(1 for t in torrents if int(t.get("complete") or 0)),
|
||||
"incomplete_count": sum(1 for t in torrents if not int(t.get("complete") or 0)),
|
||||
"total_torrent_size": total_torrent_size,
|
||||
"total_torrent_size_h": _human_size(total_torrent_size),
|
||||
"total_file_size": total_file_size,
|
||||
"total_file_size_h": _human_size(total_file_size),
|
||||
"file_count": file_count,
|
||||
"seeds_total": seeds_total,
|
||||
"peers_total": peers_total,
|
||||
"down_rate_total": down_rate_total,
|
||||
"up_rate_total": up_rate_total,
|
||||
"down_rate_total_h": rtorrent.human_rate(down_rate_total),
|
||||
"up_rate_total_h": rtorrent.human_rate(up_rate_total),
|
||||
"sampled_torrents": len(torrents),
|
||||
"errors": errors[:25],
|
||||
"error": "" if not errors else f"File metadata failed for {len(errors)} torrent(s)",
|
||||
"created_at": utcnow(),
|
||||
}
|
||||
return _save(profile_id, payload)
|
||||
|
||||
|
||||
def get(profile: dict | None, force: bool = False) -> dict[str, Any]:
|
||||
if not profile:
|
||||
return _empty(0, "No active rTorrent profile")
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
cached = _load_cached(profile_id)
|
||||
if cached and not force and not cached.get("stale"):
|
||||
return cached
|
||||
if cached and not force:
|
||||
return cached
|
||||
with _LOCK:
|
||||
cached = _load_cached(profile_id)
|
||||
if cached and not force and not cached.get("stale"):
|
||||
return cached
|
||||
return collect(profile)
|
||||
|
||||
|
||||
def maybe_refresh(profile: dict | None, force: bool = False) -> dict[str, Any] | None:
|
||||
if not profile:
|
||||
return None
|
||||
if not force and time.monotonic() - _STARTED_AT < _STARTUP_DELAY_SECONDS:
|
||||
return None
|
||||
cached = _load_cached(int(profile.get("id") or 0))
|
||||
if cached and not cached.get("stale") and not force:
|
||||
return cached
|
||||
try:
|
||||
return get(profile, force=True)
|
||||
except Exception:
|
||||
return cached
|
||||
|
||||
|
||||
def queue_refresh(socketio, profile: dict | None, force: bool = False, emit_update: bool = True, room: str | None = None) -> dict[str, Any] | None:
|
||||
"""Schedule heavier statistics refresh outside the main WebSocket/system poller."""
|
||||
if not profile:
|
||||
return None
|
||||
if not force and time.monotonic() - _STARTED_AT < _STARTUP_DELAY_SECONDS:
|
||||
return _load_cached(int(profile.get("id") or 0))
|
||||
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
cached = _load_cached(profile_id)
|
||||
if cached and not cached.get("stale") and not force:
|
||||
return cached
|
||||
|
||||
with _BACKGROUND_LOCK:
|
||||
if profile_id in _BACKGROUND_PROFILE_IDS:
|
||||
return cached
|
||||
_BACKGROUND_PROFILE_IDS.add(profile_id)
|
||||
|
||||
profile_snapshot = dict(profile)
|
||||
|
||||
def runner():
|
||||
try:
|
||||
# Note: This can query file metadata per torrent, so it never runs inside the fast CPU/RAM/disk poller.
|
||||
stats = get(profile_snapshot, force=True)
|
||||
if emit_update and stats:
|
||||
payload = {"profile_id": profile_id, "stats": stats}
|
||||
socketio.emit("torrent_stats_update", payload, to=room) if room else socketio.emit("torrent_stats_update", payload)
|
||||
except Exception as exc:
|
||||
if emit_update:
|
||||
payload = {"profile_id": profile_id, "ok": False, "error": str(exc)}
|
||||
socketio.emit("torrent_stats_update", payload, to=room) if room else socketio.emit("torrent_stats_update", payload)
|
||||
finally:
|
||||
with _BACKGROUND_LOCK:
|
||||
_BACKGROUND_PROFILE_IDS.discard(profile_id)
|
||||
|
||||
socketio.start_background_task(runner)
|
||||
return cached
|
||||
136
pytorrent/services/torrent_summary.py
Normal file
136
pytorrent/services/torrent_summary.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from threading import RLock
|
||||
from time import time
|
||||
|
||||
SUMMARY_CACHE_TTL_SECONDS = 60
|
||||
|
||||
_ERROR_PATTERNS = (
|
||||
"error",
|
||||
"failed",
|
||||
"failure",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"tracker",
|
||||
"could not",
|
||||
"cannot",
|
||||
"refused",
|
||||
"unreachable",
|
||||
"denied",
|
||||
)
|
||||
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "stopped")
|
||||
_summary_cache: dict[int, dict] = {}
|
||||
_summary_lock = RLock()
|
||||
|
||||
|
||||
def _number(row: dict, key: str) -> int:
|
||||
try:
|
||||
return int(float(row.get(key) or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _has_error(row: dict) -> bool:
|
||||
message = str(row.get("message") or "").strip().lower()
|
||||
return bool(message and any(pattern in message for pattern in _ERROR_PATTERNS))
|
||||
|
||||
|
||||
def _is_checking(row: dict) -> bool:
|
||||
return str(row.get("status") or "") == "Checking" or _number(row, "hashing") > 0
|
||||
|
||||
|
||||
def _matches(row: dict, summary_type: str) -> bool:
|
||||
status = str(row.get("status") or "")
|
||||
checking = _is_checking(row)
|
||||
if summary_type == "all":
|
||||
return True
|
||||
if summary_type == "downloading":
|
||||
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||
if summary_type == "seeding":
|
||||
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||
if summary_type == "paused":
|
||||
return not checking and (bool(row.get("paused")) or status == "Paused")
|
||||
if summary_type == "checking":
|
||||
return checking
|
||||
if summary_type == "error":
|
||||
return _has_error(row)
|
||||
if summary_type == "stopped":
|
||||
# Note: Stopped count follows the UI filter exactly, so torrents being hash-checked do not inflate an empty Stopped list.
|
||||
return not checking and not bool(row.get("state"))
|
||||
return False
|
||||
|
||||
|
||||
def _empty_bucket() -> dict:
|
||||
return {
|
||||
"count": 0,
|
||||
"size": 0,
|
||||
"disk_bytes": 0,
|
||||
"completed_bytes": 0,
|
||||
"remaining_bytes": 0,
|
||||
"progress_percent": 0.0,
|
||||
"remaining_percent": 100.0,
|
||||
# Kept for backward compatibility with older clients; not used by the filters UI.
|
||||
"down_total": 0,
|
||||
"up_total": 0,
|
||||
}
|
||||
|
||||
|
||||
def build_summary(rows: list[dict]) -> dict:
|
||||
filters = {summary_type: _empty_bucket() for summary_type in _SUMMARY_TYPES}
|
||||
for row in rows:
|
||||
for summary_type in _SUMMARY_TYPES:
|
||||
if not _matches(row, summary_type):
|
||||
continue
|
||||
bucket = filters[summary_type]
|
||||
bucket["count"] += 1
|
||||
size = _number(row, "size")
|
||||
completed = min(size, _number(row, "completed_bytes")) if size else _number(row, "completed_bytes")
|
||||
bucket["size"] += size
|
||||
bucket["completed_bytes"] += completed
|
||||
bucket["disk_bytes"] += completed
|
||||
bucket["down_total"] += _number(row, "down_total")
|
||||
bucket["up_total"] += _number(row, "up_total")
|
||||
for bucket in filters.values():
|
||||
bucket["remaining_bytes"] = max(0, bucket["size"] - bucket["completed_bytes"])
|
||||
if bucket["size"] > 0:
|
||||
bucket["progress_percent"] = round((bucket["completed_bytes"] / bucket["size"]) * 100, 1)
|
||||
bucket["remaining_percent"] = round(100 - bucket["progress_percent"], 1)
|
||||
else:
|
||||
bucket["progress_percent"] = 0.0
|
||||
bucket["remaining_percent"] = 0.0
|
||||
now = time()
|
||||
return {
|
||||
"filters": filters,
|
||||
"cache_ttl_seconds": SUMMARY_CACHE_TTL_SECONDS,
|
||||
"generated_at_epoch": now,
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
|
||||
def cached_summary(profile_id: int, rows: list[dict], force: bool = False) -> dict:
|
||||
now = time()
|
||||
with _summary_lock:
|
||||
cached = _summary_cache.get(int(profile_id))
|
||||
rows_count = len(rows or [])
|
||||
cached_count = int(((cached or {}).get("filters") or {}).get("all", {}).get("count") or 0)
|
||||
cache_is_fresh = cached and now - float(cached.get("generated_at_epoch") or 0) < SUMMARY_CACHE_TTL_SECONDS
|
||||
cache_is_usable = cache_is_fresh and not (cached_count == 0 and rows_count > 0)
|
||||
if not force and cache_is_usable:
|
||||
result = deepcopy(cached)
|
||||
result["cached"] = True
|
||||
return result
|
||||
result = build_summary(rows or [])
|
||||
# Do not cache an empty cold-start snapshot. On first connection the cache may be populated
|
||||
# before rTorrent refresh finishes, which would otherwise show zeros for the full TTL.
|
||||
if rows_count > 0 or force:
|
||||
_summary_cache[int(profile_id)] = deepcopy(result)
|
||||
return result
|
||||
|
||||
|
||||
def invalidate_summary(profile_id: int | None = None) -> None:
|
||||
with _summary_lock:
|
||||
if profile_id is None:
|
||||
_summary_cache.clear()
|
||||
else:
|
||||
_summary_cache.pop(int(profile_id), None)
|
||||
440
pytorrent/services/tracker_cache.py
Normal file
440
pytorrent/services/tracker_cache.py
Normal file
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR
|
||||
from ..db import connect, utcnow
|
||||
|
||||
TRACKER_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
FAVICON_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
TRACKER_SCAN_LIMIT = 80
|
||||
FAVICON_DIR = BASE_DIR / "data" / "tracker_favicons"
|
||||
PUBLIC_FAVICON_BASE = "/static/tracker_favicons"
|
||||
_TRACKER_SCAN_LOCKS: dict[int, threading.Lock] = {}
|
||||
_TRACKER_SCAN_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
class _IconParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.icons: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs):
|
||||
if tag.lower() != "link":
|
||||
return
|
||||
data = {str(k).lower(): str(v or "") for k, v in attrs}
|
||||
rel = re.sub(r"\s+", " ", data.get("rel", "").lower()).strip()
|
||||
href = data.get("href", "").strip()
|
||||
if href and "icon" in rel:
|
||||
self.icons.append(href)
|
||||
|
||||
|
||||
def _now_epoch() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def tracker_domain(url: str) -> str:
|
||||
raw = str(url or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parsed = urllib.parse.urlparse(raw if "://" in raw else f"http://{raw}")
|
||||
host = (parsed.hostname or "").lower().strip(".")
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
return host
|
||||
|
||||
|
||||
def _root_domain(domain: str) -> str:
|
||||
parts = [p for p in str(domain or "").lower().strip(".").split(".") if p]
|
||||
if len(parts) <= 2:
|
||||
return ".".join(parts)
|
||||
# Note: Tracker favicon discovery needs the real main site first; for t.pte.nu that is pte.nu, not t.pte.nu.
|
||||
known_second_level_suffixes = {"co", "com", "net", "org", "gov", "edu", "ac"}
|
||||
if len(parts[-1]) == 2 and parts[-2] in known_second_level_suffixes and len(parts) >= 3:
|
||||
return ".".join(parts[-3:])
|
||||
return ".".join(parts[-2:])
|
||||
|
||||
|
||||
def _safe_filename(domain: str) -> str:
|
||||
return re.sub(r"[^a-z0-9_.-]+", "_", domain.lower()).strip("._") or "tracker"
|
||||
|
||||
|
||||
def _read_cached(profile_id: int, hashes: list[str], ttl: int) -> tuple[dict[str, list[dict]], set[str]]:
|
||||
if not hashes:
|
||||
return {}, set()
|
||||
now = _now_epoch()
|
||||
cached: dict[str, list[dict]] = {}
|
||||
fresh: set[str] = set()
|
||||
with connect() as conn:
|
||||
for start in range(0, len(hashes), 900):
|
||||
chunk = hashes[start:start + 900]
|
||||
placeholders = ",".join("?" for _ in chunk)
|
||||
rows = conn.execute(
|
||||
f"SELECT torrent_hash, trackers_json, updated_epoch FROM tracker_summary_cache WHERE profile_id=? AND torrent_hash IN ({placeholders})",
|
||||
(profile_id, *chunk),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
h = str(row.get("torrent_hash") or "")
|
||||
try:
|
||||
items = json.loads(row.get("trackers_json") or "[]")
|
||||
except Exception:
|
||||
items = []
|
||||
cached[h] = items if isinstance(items, list) else []
|
||||
if now - float(row.get("updated_epoch") or 0) < ttl:
|
||||
fresh.add(h)
|
||||
return cached, fresh
|
||||
|
||||
|
||||
def _store(profile_id: int, torrent_hash: str, trackers: list[dict]) -> None:
|
||||
now = utcnow()
|
||||
epoch = _now_epoch()
|
||||
compact = []
|
||||
seen = set()
|
||||
for item in trackers:
|
||||
domain = tracker_domain(str(item.get("url") or item.get("domain") or "")) or str(item.get("domain") or "")
|
||||
if not domain or domain in seen:
|
||||
continue
|
||||
seen.add(domain)
|
||||
compact.append({"domain": domain, "url": str(item.get("url") or "")})
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tracker_summary_cache(profile_id, torrent_hash, trackers_json, updated_at, updated_epoch)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
ON CONFLICT(profile_id, torrent_hash) DO UPDATE SET
|
||||
trackers_json=excluded.trackers_json,
|
||||
updated_at=excluded.updated_at,
|
||||
updated_epoch=excluded.updated_epoch
|
||||
""",
|
||||
(profile_id, torrent_hash, json.dumps(compact), now, epoch),
|
||||
)
|
||||
|
||||
|
||||
def summary(profile: dict, hashes: list[str], loader, scan_limit: int = TRACKER_SCAN_LIMIT, include_favicons: bool = False) -> dict:
|
||||
"""Build tracker sidebar data from disk cache and refresh a small batch per request."""
|
||||
# Note: Tracker data is cached per torrent hash, so huge rTorrent libraries are never scanned in one UI request.
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
clean_hashes = [str(h or "").strip() for h in hashes if str(h or "").strip()]
|
||||
cached, fresh = _read_cached(profile_id, clean_hashes, TRACKER_CACHE_TTL_SECONDS)
|
||||
missing = [h for h in clean_hashes if h not in fresh]
|
||||
errors: list[dict] = []
|
||||
scanned_now = 0
|
||||
for h in missing[:max(0, int(scan_limit or 0))]:
|
||||
try:
|
||||
trackers = loader(h)
|
||||
_store(profile_id, h, trackers)
|
||||
cached[h] = [{"domain": tracker_domain(t.get("url") or t.get("domain") or ""), "url": str(t.get("url") or "")} for t in trackers]
|
||||
fresh.add(h)
|
||||
scanned_now += 1
|
||||
except Exception as exc:
|
||||
errors.append({"hash": h, "error": str(exc)})
|
||||
by_hash: dict[str, list[dict]] = {}
|
||||
counts: dict[str, dict] = {}
|
||||
for h in clean_hashes:
|
||||
items = []
|
||||
seen = set()
|
||||
for item in cached.get(h, []):
|
||||
domain = tracker_domain(str(item.get("url") or item.get("domain") or "")) or str(item.get("domain") or "")
|
||||
if not domain or domain in seen:
|
||||
continue
|
||||
seen.add(domain)
|
||||
row = {"domain": domain, "url": str(item.get("url") or "")}
|
||||
items.append(row)
|
||||
bucket = counts.setdefault(domain, {"domain": domain, "url": row["url"], "count": 0})
|
||||
bucket["count"] += 1
|
||||
if not bucket.get("url") and row["url"]:
|
||||
bucket["url"] = row["url"]
|
||||
by_hash[h] = items
|
||||
trackers = sorted(counts.values(), key=lambda x: (-int(x.get("count") or 0), str(x.get("domain") or "")))
|
||||
if include_favicons:
|
||||
# Note: Summary returns only already cached static favicon URLs; network favicon discovery stays outside the hot tracker count path.
|
||||
for item in trackers:
|
||||
item["favicon_url"] = favicon_public_url(str(item.get("domain") or ""), enabled=True, create=False)
|
||||
pending = max(0, len([h for h in clean_hashes if h not in fresh]))
|
||||
return {"hashes": by_hash, "trackers": trackers, "errors": errors[:25], "scanned": len(clean_hashes), "scanned_now": scanned_now, "pending": pending, "cached": len(clean_hashes) - pending}
|
||||
|
||||
|
||||
|
||||
def _scan_lock(profile_id: int) -> threading.Lock:
|
||||
with _TRACKER_SCAN_LOCKS_GUARD:
|
||||
if profile_id not in _TRACKER_SCAN_LOCKS:
|
||||
_TRACKER_SCAN_LOCKS[profile_id] = threading.Lock()
|
||||
return _TRACKER_SCAN_LOCKS[profile_id]
|
||||
|
||||
|
||||
def warm_summary_cache(profile: dict, hashes: list[str], loader, batch_size: int = TRACKER_SCAN_LIMIT) -> bool:
|
||||
"""Start a non-blocking tracker cache warmup for large libraries."""
|
||||
# Note: Tracker cache warming runs in one background thread per profile, so F5 returns cached data immediately instead of waiting for rTorrent scans.
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
clean_hashes = [str(h or "").strip() for h in hashes if str(h or "").strip()]
|
||||
if not profile_id or not clean_hashes:
|
||||
return False
|
||||
lock = _scan_lock(profile_id)
|
||||
if lock.locked():
|
||||
return False
|
||||
|
||||
def _worker():
|
||||
if not lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
result = summary(profile, clean_hashes, loader, scan_limit=max(1, int(batch_size or TRACKER_SCAN_LIMIT)), include_favicons=False)
|
||||
if int(result.get("pending") or 0) <= 0 or int(result.get("scanned_now") or 0) <= 0:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
threading.Thread(target=_worker, name=f"tracker-cache-warm-{profile_id}", daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def favicon_public_url(domain: str, enabled: bool = True, create: bool = False, force: bool = False) -> str:
|
||||
"""Return the static URL for a cached tracker favicon, optionally creating or refreshing it first."""
|
||||
# Note: Favicon files stay in data/tracker_favicons, but the browser loads them via the static/tracker_favicons symlink.
|
||||
clean = tracker_domain(domain)
|
||||
if not enabled or not clean:
|
||||
return ""
|
||||
if create:
|
||||
favicon_path(clean, enabled=True, force=force)
|
||||
cached = _cached_favicon(clean)
|
||||
now = _now_epoch()
|
||||
if not cached or now - float(cached.get("updated_epoch") or 0) >= FAVICON_CACHE_TTL_SECONDS:
|
||||
return ""
|
||||
path = Path(str(cached.get("file_path") or ""))
|
||||
if not path.exists() or not path.is_file():
|
||||
return ""
|
||||
try:
|
||||
rel = path.resolve().relative_to(FAVICON_DIR.resolve())
|
||||
except Exception:
|
||||
rel = Path(path.name)
|
||||
return f"{PUBLIC_FAVICON_BASE}/{urllib.parse.quote(str(rel).replace(chr(92), '/'))}"
|
||||
|
||||
def _fetch(url: str, limit: int = 262144) -> tuple[bytes, str, str]:
|
||||
# Note: Favicon discovery uses browser-like headers and a certificate fallback, because tracker login pages/CDNs often reject minimal Python requests.
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; pyTorrent favicon fetcher)",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Connection": "close",
|
||||
},
|
||||
)
|
||||
|
||||
def _read(context=None):
|
||||
with urllib.request.urlopen(req, timeout=8, context=context) as resp:
|
||||
data = resp.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
data = data[:limit]
|
||||
content_type = str(resp.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
final_url = str(resp.geturl() or url)
|
||||
return data, content_type, final_url
|
||||
|
||||
try:
|
||||
return _read()
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
if isinstance(reason, ssl.SSLError) or "CERTIFICATE_VERIFY_FAILED" in str(exc):
|
||||
return _read(ssl._create_unverified_context())
|
||||
raise
|
||||
|
||||
|
||||
def _is_icon(data: bytes, content_type: str, url: str) -> bool:
|
||||
"""Validate that downloaded bytes are a browser-readable image, not only an image-like HTTP header."""
|
||||
# Note: Some trackers serve a broken /favicon.ico with image/vnd.microsoft.icon; pyTorrent now validates bytes before caching it.
|
||||
if not data or len(data) < 16:
|
||||
return False
|
||||
head = data[:32]
|
||||
lower = data[:512].lstrip().lower()
|
||||
if head.startswith(b"\x00\x00\x01\x00") or head.startswith(b"\x00\x00\x02\x00"):
|
||||
try:
|
||||
count = int.from_bytes(data[4:6], "little")
|
||||
except Exception:
|
||||
count = 0
|
||||
return 0 < count <= 256 and len(data) >= 6 + (16 * count)
|
||||
if head.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return True
|
||||
if head.startswith(b"\xff\xd8\xff"):
|
||||
return True
|
||||
if head.startswith((b"GIF87a", b"GIF89a")):
|
||||
return True
|
||||
if head.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
||||
return True
|
||||
if lower.startswith(b"<svg") or b"<svg" in lower[:256]:
|
||||
return True
|
||||
ctype = content_type.lower()
|
||||
if ctype in {"image/svg+xml"}:
|
||||
return b"<svg" in lower[:512]
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _attr_value(tag: str, name: str) -> str:
|
||||
# Note: Accept quoted and unquoted HTML attributes so favicon discovery works with compact/minified tracker pages.
|
||||
match = re.search(rf"\b{name}\s*=\s*(['\"])(.*?)\1", tag, re.I | re.S)
|
||||
if match:
|
||||
return match.group(2).strip()
|
||||
match = re.search(rf"\b{name}\s*=\s*([^\s>]+)", tag, re.I | re.S)
|
||||
return match.group(1).strip().strip("'\"") if match else ""
|
||||
|
||||
|
||||
def _extract_icon_hrefs(html: str) -> list[str]:
|
||||
# Note: Read any <link rel=...icon... href=...> order, including shortcut icon and relative CDN paths.
|
||||
hrefs: list[str] = []
|
||||
parser = _IconParser()
|
||||
try:
|
||||
parser.feed(html)
|
||||
hrefs.extend(parser.icons)
|
||||
except Exception:
|
||||
pass
|
||||
for match in re.finditer(r"<link\b[^>]*>", html, re.I | re.S):
|
||||
tag = match.group(0)
|
||||
rel = _attr_value(tag, "rel").lower()
|
||||
href = _attr_value(tag, "href")
|
||||
if href and "icon" in rel:
|
||||
hrefs.append(href)
|
||||
clean = []
|
||||
seen = set()
|
||||
for href in hrefs:
|
||||
href = str(href or "").strip()
|
||||
if href and href not in seen:
|
||||
seen.add(href)
|
||||
clean.append(href)
|
||||
return clean
|
||||
|
||||
|
||||
def _tracker_icon_hosts(domain: str) -> list[str]:
|
||||
host = tracker_domain(domain)
|
||||
root = _root_domain(host)
|
||||
# Note: Direct favicon fallback checks the tracker host first, then the main domain.
|
||||
return [h for h in dict.fromkeys([host, root]) if h]
|
||||
|
||||
|
||||
def _tracker_html_hosts(domain: str) -> list[str]:
|
||||
host = tracker_domain(domain)
|
||||
root = _root_domain(host)
|
||||
# Note: HTML discovery checks the main site first, because tracker announce hosts often return text/plain.
|
||||
return [h for h in dict.fromkeys([root, host]) if h]
|
||||
|
||||
|
||||
def _favicon_candidates(domain: str) -> list[str]:
|
||||
candidates = []
|
||||
for h in _tracker_icon_hosts(domain):
|
||||
candidates.extend([f"https://{h}/favicon.ico", f"http://{h}/favicon.ico"])
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _html_icon_candidates(domain: str, errors: list[str] | None = None) -> list[str]:
|
||||
urls = []
|
||||
for h in _tracker_html_hosts(domain):
|
||||
for scheme in ("https", "http"):
|
||||
base = f"{scheme}://{h}/"
|
||||
try:
|
||||
data, ctype, final_url = _fetch(base, limit=524288)
|
||||
except Exception as exc:
|
||||
if errors is not None:
|
||||
errors.append(f"{base}: {exc}")
|
||||
continue
|
||||
lower = data[:4096].lower()
|
||||
if "html" not in ctype and b"<html" not in lower and b"<link" not in data.lower():
|
||||
if errors is not None:
|
||||
errors.append(f"{base}: response is not html ({ctype or 'unknown content-type'})")
|
||||
continue
|
||||
html = data.decode("utf-8", errors="ignore")
|
||||
for href in _extract_icon_hrefs(html):
|
||||
urls.append(urllib.parse.urljoin(final_url, href))
|
||||
return list(dict.fromkeys(urls))
|
||||
|
||||
|
||||
def _cached_favicon(domain: str):
|
||||
clean = tracker_domain(domain)
|
||||
if not clean:
|
||||
return None
|
||||
with connect() as conn:
|
||||
return conn.execute("SELECT * FROM tracker_favicon_cache WHERE domain=?", (clean,)).fetchone()
|
||||
|
||||
|
||||
def favicon_cache_row(domain: str):
|
||||
"""Note: Expose the favicon cache row for diagnostics without duplicating SQL in routes or CLI."""
|
||||
return _cached_favicon(domain)
|
||||
|
||||
|
||||
def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tuple[Path | None, str | None]:
|
||||
clean = tracker_domain(domain)
|
||||
if not enabled or not clean:
|
||||
return None, None
|
||||
cached = _cached_favicon(clean)
|
||||
now = _now_epoch()
|
||||
if cached and not force and now - float(cached.get("updated_epoch") or 0) < FAVICON_CACHE_TTL_SECONDS:
|
||||
path = Path(str(cached.get("file_path") or ""))
|
||||
mime = str(cached.get("mime_type") or mimetypes.guess_type(path.name)[0] or "image/x-icon")
|
||||
if path.exists() and path.is_file():
|
||||
try:
|
||||
if _is_icon(path.read_bytes()[:524288], mime, str(cached.get("source_url") or path.name)):
|
||||
return path, mime
|
||||
except Exception:
|
||||
pass
|
||||
if cached.get("error"):
|
||||
return None, None
|
||||
# Note: Favicon lookup checks the main-domain HTML first, then tracker HTML, then direct /favicon.ico fallbacks.
|
||||
FAVICON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
errors = []
|
||||
candidates = _html_icon_candidates(clean, errors) + _favicon_candidates(clean)
|
||||
candidates = list(dict.fromkeys(candidates))
|
||||
idx = 0
|
||||
while idx < len(candidates):
|
||||
url = candidates[idx]
|
||||
idx += 1
|
||||
try:
|
||||
data, ctype, final_url = _fetch(url, limit=524288)
|
||||
if not _is_icon(data, ctype, final_url):
|
||||
errors.append(f"{url}: invalid icon ({ctype or 'unknown content-type'}, {len(data)} bytes)")
|
||||
continue
|
||||
ext = Path(urllib.parse.urlparse(final_url).path).suffix.lower() or mimetypes.guess_extension(ctype) or ".ico"
|
||||
if ext not in {".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp"}:
|
||||
ext = ".ico"
|
||||
path = FAVICON_DIR / f"{_safe_filename(clean)}{ext}"
|
||||
path.write_bytes(data)
|
||||
mime = ctype if ctype.startswith("image/") else (mimetypes.guess_type(path.name)[0] or "image/x-icon")
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tracker_favicon_cache(domain, source_url, file_path, mime_type, updated_at, updated_epoch, error)
|
||||
VALUES(?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
source_url=excluded.source_url,
|
||||
file_path=excluded.file_path,
|
||||
mime_type=excluded.mime_type,
|
||||
updated_at=excluded.updated_at,
|
||||
updated_epoch=excluded.updated_epoch,
|
||||
error=NULL
|
||||
""",
|
||||
(clean, final_url, str(path), mime, utcnow(), now),
|
||||
)
|
||||
return path, mime
|
||||
except Exception as exc:
|
||||
errors.append(f"{url}: {exc}")
|
||||
# HTML is checked once before direct /favicon.ico probes; do not guess cdn/static/www hosts unless HTML points there.
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tracker_favicon_cache(domain, source_url, file_path, mime_type, updated_at, updated_epoch, error)
|
||||
VALUES(?, '', '', '', ?, ?, ?)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
updated_at=excluded.updated_at,
|
||||
updated_epoch=excluded.updated_epoch,
|
||||
error=excluded.error
|
||||
""",
|
||||
(clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"),
|
||||
)
|
||||
return None, None
|
||||
117
pytorrent/services/traffic_history.py
Normal file
117
pytorrent/services/traffic_history.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect, utcnow
|
||||
from . import retention
|
||||
|
||||
_LAST_WRITE: dict[int, float] = {}
|
||||
WRITE_EVERY_SECONDS = 60
|
||||
|
||||
|
||||
def _now_ts() -> float:
|
||||
return datetime.now(timezone.utc).timestamp()
|
||||
|
||||
|
||||
def record(profile_id: int, down_rate: int = 0, up_rate: int = 0, total_down: int = 0, total_up: int = 0, force: bool = False) -> None:
|
||||
"""Store compact transfer samples. One sample per minute per profile keeps SQLite small."""
|
||||
profile_id = int(profile_id)
|
||||
now_ts = _now_ts()
|
||||
if not force and now_ts - _LAST_WRITE.get(profile_id, 0.0) < WRITE_EVERY_SECONDS:
|
||||
return
|
||||
_LAST_WRITE[profile_id] = now_ts
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO traffic_history(profile_id,down_rate,up_rate,total_down,total_up,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(profile_id, int(down_rate or 0), int(up_rate or 0), int(total_down or 0), int(total_up or 0), utcnow()),
|
||||
)
|
||||
retention.cleanup()
|
||||
|
||||
|
||||
def _range_to_cutoff(range_name: str) -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
if range_name == "15m":
|
||||
return now - timedelta(minutes=15)
|
||||
if range_name == "1h":
|
||||
return now - timedelta(hours=1)
|
||||
if range_name == "3h":
|
||||
return now - timedelta(hours=3)
|
||||
if range_name == "6h":
|
||||
return now - timedelta(hours=6)
|
||||
if range_name == "24h":
|
||||
return now - timedelta(hours=24)
|
||||
if range_name == "30d":
|
||||
return now - timedelta(days=30)
|
||||
if range_name == "90d":
|
||||
return now - timedelta(days=90)
|
||||
return now - timedelta(days=7)
|
||||
|
||||
|
||||
def _bucket_for(range_name: str) -> str:
|
||||
if range_name in {"15m", "1h", "3h"}:
|
||||
return "%Y-%m-%d %H:%M"
|
||||
if range_name in {"6h", "24h"}:
|
||||
return "%Y-%m-%d %H:00"
|
||||
return "%Y-%m-%d"
|
||||
|
||||
|
||||
def _row_value(row: Any, key: str, index: int, default: Any = 0) -> Any:
|
||||
# connect() uses dict_factory, so SQLite rows are dicts. The fallback keeps
|
||||
# this function compatible with tuple/list rows in tests or future refactors.
|
||||
if isinstance(row, dict):
|
||||
return row.get(key, default)
|
||||
try:
|
||||
return row[index]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def history(profile_id: int, range_name: str = "7d") -> dict[str, Any]:
|
||||
cutoff = _range_to_cutoff(range_name)
|
||||
bucket = _bucket_for(range_name)
|
||||
cutoff_s = cutoff.isoformat(timespec="seconds")
|
||||
bucket_name = "minute" if range_name in {"15m", "1h", "3h"} else ("hour" if range_name in {"6h", "24h"} else "day")
|
||||
with connect() as conn:
|
||||
raw = conn.execute(
|
||||
"""
|
||||
SELECT down_rate, up_rate, total_down, total_up, created_at
|
||||
FROM traffic_history
|
||||
WHERE profile_id=? AND created_at >= ?
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(int(profile_id), cutoff_s),
|
||||
).fetchall()
|
||||
|
||||
rows_by_bucket: dict[str, dict[str, Any]] = {}
|
||||
prev_down = prev_up = None
|
||||
for r in raw:
|
||||
created = str(_row_value(r, "created_at", 4, ""))
|
||||
try:
|
||||
dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
continue
|
||||
b = dt.strftime(bucket)
|
||||
item = rows_by_bucket.setdefault(b, {"bucket": b, "avg_down_rate": 0, "avg_up_rate": 0, "downloaded": 0, "uploaded": 0, "samples": 0})
|
||||
down_rate = int(_row_value(r, "down_rate", 0, 0) or 0)
|
||||
up_rate = int(_row_value(r, "up_rate", 1, 0) or 0)
|
||||
total_down = int(_row_value(r, "total_down", 2, 0) or 0)
|
||||
total_up = int(_row_value(r, "total_up", 3, 0) or 0)
|
||||
item["avg_down_rate"] += down_rate
|
||||
item["avg_up_rate"] += up_rate
|
||||
item["samples"] += 1
|
||||
if prev_down is not None and total_down >= prev_down:
|
||||
item["downloaded"] += total_down - prev_down
|
||||
if prev_up is not None and total_up >= prev_up:
|
||||
item["uploaded"] += total_up - prev_up
|
||||
prev_down, prev_up = total_down, total_up
|
||||
|
||||
rows = []
|
||||
for item in rows_by_bucket.values():
|
||||
samples = max(1, int(item["samples"] or 1))
|
||||
item["avg_down_rate"] = round(item["avg_down_rate"] / samples)
|
||||
item["avg_up_rate"] = round(item["avg_up_rate"] / samples)
|
||||
rows.append(item)
|
||||
rows.sort(key=lambda x: x["bucket"])
|
||||
return {"range": range_name, "bucket": bucket_name, "retention_days": TRAFFIC_HISTORY_RETENTION_DAYS, "rows": rows}
|
||||
256
pytorrent/services/websocket.py
Normal file
256
pytorrent/services/websocket.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
import psutil
|
||||
from flask_socketio import emit, join_room, leave_room, disconnect
|
||||
from .preferences import active_profile, get_profile
|
||||
from .torrent_cache import torrent_cache
|
||||
from .torrent_summary import cached_summary
|
||||
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner
|
||||
|
||||
|
||||
def _profile_room(profile_id: int) -> str:
|
||||
return f"profile:{int(profile_id)}"
|
||||
|
||||
|
||||
def _poller_profiles() -> list[dict]:
|
||||
# Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms.
|
||||
if not auth.enabled():
|
||||
profile = active_profile()
|
||||
return [profile] if profile else []
|
||||
from ..db import connect
|
||||
with connect() as conn:
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
||||
|
||||
|
||||
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
target = _profile_room(profile_id) if auth.enabled() else None
|
||||
socketio.emit(event, payload, to=target) if target else socketio.emit(event, payload)
|
||||
|
||||
|
||||
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
emit_profile_event(socketio, event, payload, profile_id)
|
||||
|
||||
|
||||
|
||||
|
||||
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
state = poller_control.state_for(profile_id)
|
||||
try:
|
||||
try:
|
||||
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
result = smart_queue.check(profile, force=False)
|
||||
if result.get("enabled"):
|
||||
_emit_profile(socketio, "smart_queue_update", result, profile_id)
|
||||
if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"):
|
||||
queue_diff = torrent_cache.refresh(profile)
|
||||
if queue_diff.get("ok"):
|
||||
payload = {**queue_diff, "summary": cached_summary(profile_id, torrent_cache.snapshot(profile_id), force=True)}
|
||||
_emit_profile(socketio, "torrent_patch", payload, profile_id)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
auto_result = automation_rules.check(profile, force=False)
|
||||
if auto_result.get("applied"):
|
||||
_emit_profile(socketio, "automation_update", auto_result, profile_id)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
plan_result = download_planner.enforce(profile, force=False)
|
||||
if plan_result.get("enabled") and not plan_result.get("skipped"):
|
||||
_emit_profile(socketio, "download_plan_update", plan_result, profile_id)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "download_plan_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
finally:
|
||||
state.slow_task_running = False
|
||||
|
||||
|
||||
def _is_active_rows(rows: list[dict]) -> bool:
|
||||
for row in rows or []:
|
||||
try:
|
||||
if int(row.get("state") or 0) and (int(row.get("down_rate") or 0) > 0 or int(row.get("up_rate") or 0) > 0):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict:
|
||||
# Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats.
|
||||
down_rate = sum(int(row.get("down_rate") or 0) for row in rows or [])
|
||||
up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
|
||||
return {
|
||||
"profile_id": int(profile_id),
|
||||
"down_rate": down_rate,
|
||||
"up_rate": up_rate,
|
||||
"down_rate_h": rtorrent.human_rate(down_rate),
|
||||
"up_rate_h": rtorrent.human_rate(up_rate),
|
||||
"speed_peaks": speed_peaks.record(profile_id, down_rate, up_rate),
|
||||
}
|
||||
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
|
||||
|
||||
def register_socketio_handlers(socketio):
|
||||
|
||||
def poller():
|
||||
while True:
|
||||
loop_started = time.monotonic()
|
||||
next_sleep = poller_control.MIN_POLL_INTERVAL_SECONDS
|
||||
for profile in _poller_profiles():
|
||||
if not profile:
|
||||
continue
|
||||
pid = int(profile["id"])
|
||||
settings = poller_control.get_settings(pid)
|
||||
state = poller_control.state_for(pid)
|
||||
now = time.monotonic()
|
||||
next_sleep = min(next_sleep, poller_control.effective_fast_interval(settings, state))
|
||||
if not poller_control.should_fast_poll(now, settings, state):
|
||||
continue
|
||||
|
||||
tick_started = time.monotonic()
|
||||
changed = False
|
||||
ok = True
|
||||
error = ""
|
||||
active = False
|
||||
emitted_payload_size = 0
|
||||
rtorrent_call_count = 0
|
||||
skipped_emissions = 0
|
||||
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
|
||||
|
||||
try:
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rtorrent_call_count += 1
|
||||
state.last_fast_at = now
|
||||
ok = bool(diff.get("ok"))
|
||||
error = str(diff.get("error") or "")
|
||||
rows = torrent_cache.snapshot(pid)
|
||||
active = _is_active_rows(rows)
|
||||
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else None
|
||||
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
|
||||
changed = True
|
||||
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
|
||||
emitted_payload_size += len(json.dumps(payload, default=str))
|
||||
_emit_profile(socketio, "torrent_patch", payload, pid)
|
||||
elif not diff.get("ok"):
|
||||
_emit_profile(socketio, "rtorrent_error", diff, pid)
|
||||
else:
|
||||
# Note: Speeds and peak records may change even when no torrent rows need repainting.
|
||||
if speed_status:
|
||||
payload = {"ok": True, "profile_id": pid, "added": [], "updated": [], "removed": [], "speed_status": speed_status}
|
||||
emitted_payload_size += len(json.dumps(payload, default=str))
|
||||
_emit_profile(socketio, "torrent_patch", payload, pid)
|
||||
else:
|
||||
skipped_emissions += 1
|
||||
|
||||
if poller_control.should_system_poll(now, settings, state):
|
||||
state.last_system_at = now
|
||||
status = rtorrent.system_status(profile, rows)
|
||||
rtorrent_call_count += 1
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
except Exception as exc:
|
||||
status["usage_source"] = "rtorrent-remote"
|
||||
status["usage_available"] = False
|
||||
status["usage_error"] = str(exc)
|
||||
else:
|
||||
status["cpu"] = psutil.cpu_percent(interval=None)
|
||||
status["ram"] = psutil.virtual_memory().percent
|
||||
status["usage_source"] = "local"
|
||||
status["usage_available"] = True
|
||||
status["profile_id"] = pid
|
||||
traffic_history.record(pid, status.get("down_rate", 0), status.get("up_rate", 0), status.get("total_down", 0), status.get("total_up", 0))
|
||||
status["speed_peaks"] = (speed_status or _speed_status_from_rows(pid, rows))["speed_peaks"]
|
||||
status["poller"] = poller_control.snapshot(pid)
|
||||
emitted_payload_size += len(json.dumps(status, default=str))
|
||||
_emit_profile(socketio, "system_stats", status, pid)
|
||||
|
||||
if poller_control.should_disk_poll(now, settings, state):
|
||||
state.last_disk_at = now
|
||||
|
||||
if poller_control.should_tracker_poll(now, settings, state):
|
||||
state.last_tracker_at = now
|
||||
|
||||
if poller_control.should_slow_poll(now, settings, state) or poller_control.should_queue_poll(now, settings, state):
|
||||
state.last_slow_at = now
|
||||
state.last_queue_at = now
|
||||
if state.slow_task_running:
|
||||
skipped_emissions += 1
|
||||
else:
|
||||
state.slow_task_running = True
|
||||
socketio.start_background_task(_run_slow_profile_tasks, socketio, dict(profile), pid)
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
error = str(exc)
|
||||
_emit_profile(socketio, "rtorrent_error", {"profile_id": pid, "error": error}, pid)
|
||||
|
||||
runtime = poller_control.mark_tick(state, tick_started, active=active, ok=ok, error=error, emitted_payload_size=emitted_payload_size, rtorrent_call_count=rtorrent_call_count, skipped_emissions=skipped_emissions, settings=settings)
|
||||
heartbeat.update({"ok": ok, "error": error, "active": active, "poller": runtime})
|
||||
if poller_control.should_heartbeat(time.monotonic(), settings, state, changed):
|
||||
state.last_heartbeat_at = time.monotonic()
|
||||
_emit_profile(socketio, "heartbeat", heartbeat, pid)
|
||||
|
||||
elapsed = time.monotonic() - loop_started
|
||||
socketio.sleep(max(poller_control.MIN_POLL_INTERVAL_SECONDS, min(10.0, next_sleep - elapsed)))
|
||||
|
||||
def ensure_poller_started():
|
||||
global _started
|
||||
with _start_lock:
|
||||
if not _started:
|
||||
# The poller starts with the app, so Smart Queue, planner and automations work without an open UI.
|
||||
socketio.start_background_task(poller)
|
||||
_started = True
|
||||
|
||||
ensure_poller_started()
|
||||
|
||||
@socketio.on("connect")
|
||||
def handle_connect():
|
||||
ensure_poller_started()
|
||||
if auth.enabled() and not auth.current_user_id():
|
||||
disconnect()
|
||||
return False
|
||||
profile = active_profile()
|
||||
if profile:
|
||||
join_room(_profile_room(profile["id"]))
|
||||
emit("connected", {"ok": True, "profile": profile})
|
||||
if not profile:
|
||||
emit("profile_required", {"ok": True, "profiles": []})
|
||||
return
|
||||
rows = torrent_cache.snapshot(profile["id"])
|
||||
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)})
|
||||
emit("poller_settings", {"settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
|
||||
emit("download_plan_update", {"settings": download_planner.get_settings(int(profile["id"]))})
|
||||
|
||||
@socketio.on("select_profile")
|
||||
def handle_select_profile(data):
|
||||
if auth.enabled() and not auth.current_user_id():
|
||||
disconnect()
|
||||
return
|
||||
old_profile = active_profile()
|
||||
if old_profile:
|
||||
leave_room(_profile_room(old_profile["id"]))
|
||||
profile_id = int((data or {}).get("profile_id") or 0)
|
||||
if not profile_id:
|
||||
emit("profile_required", {"ok": True, "profiles": []})
|
||||
return
|
||||
profile = get_profile(profile_id)
|
||||
if not profile:
|
||||
emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"})
|
||||
return
|
||||
join_room(_profile_room(profile_id))
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")})
|
||||
emit("poller_settings", {"settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
|
||||
emit("download_plan_update", {"settings": download_planner.get_settings(profile_id)})
|
||||
569
pytorrent/services/workers.py
Normal file
569
pytorrent/services/workers.py
Normal file
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from . import rtorrent, auth, disk_guard
|
||||
from .preferences import get_profile
|
||||
from ..config import WORKERS
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
|
||||
LIGHT_ACTIONS = {"start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "reannounce", "set_limits"}
|
||||
WATCHDOG_INTERVAL_SECONDS = 30
|
||||
|
||||
_heavy_executor = ThreadPoolExecutor(max_workers=WORKERS, thread_name_prefix="pytorrent-heavy-job")
|
||||
_light_executor = ThreadPoolExecutor(max_workers=max(4, min(WORKERS, 16)), thread_name_prefix="pytorrent-light-job")
|
||||
_socketio = None
|
||||
_heavy_semaphores: dict[int, tuple[int, threading.Semaphore]] = {}
|
||||
_light_semaphores: dict[int, tuple[int, threading.Semaphore]] = {}
|
||||
_exclusive_locks: dict[int, threading.Lock] = {}
|
||||
_active_runners: set[str] = set()
|
||||
_sem_lock = threading.Lock()
|
||||
_runner_lock = threading.Lock()
|
||||
_watchdog_started = False
|
||||
_watchdog_lock = threading.Lock()
|
||||
|
||||
|
||||
def set_socketio(socketio):
|
||||
global _socketio
|
||||
_socketio = socketio
|
||||
|
||||
|
||||
def _emit(name: str, payload: dict):
|
||||
if not _socketio:
|
||||
return
|
||||
profile_id = payload.get("profile_id")
|
||||
if auth.enabled() and profile_id:
|
||||
# Note: Job/socket events are sent only to clients joined to the affected profile room.
|
||||
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
|
||||
else:
|
||||
_socketio.emit(name, payload)
|
||||
|
||||
|
||||
def _bounded_int(value, default: int, minimum: int = 1) -> int:
|
||||
try:
|
||||
parsed = int(value if value is not None else default)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return max(minimum, parsed)
|
||||
|
||||
|
||||
def _is_light_action(action_name: str) -> bool:
|
||||
return str(action_name or "") in LIGHT_ACTIONS
|
||||
|
||||
|
||||
def _profile_heavy_limit(profile: dict) -> int:
|
||||
return _bounded_int(profile.get("max_parallel_jobs"), 5)
|
||||
|
||||
|
||||
def _profile_light_limit(profile: dict) -> int:
|
||||
return _bounded_int(profile.get("light_parallel_jobs"), 4)
|
||||
|
||||
|
||||
def _get_sem(profile: dict, light: bool = False) -> threading.Semaphore:
|
||||
profile_id = int(profile["id"])
|
||||
limit = _profile_light_limit(profile) if light else _profile_heavy_limit(profile)
|
||||
registry = _light_semaphores if light else _heavy_semaphores
|
||||
with _sem_lock:
|
||||
current = registry.get(profile_id)
|
||||
if not current or current[0] != limit:
|
||||
registry[profile_id] = (limit, threading.Semaphore(limit))
|
||||
return registry[profile_id][1]
|
||||
|
||||
|
||||
def _get_exclusive_lock(profile_id: int) -> threading.Lock:
|
||||
with _sem_lock:
|
||||
if profile_id not in _exclusive_locks:
|
||||
_exclusive_locks[profile_id] = threading.Lock()
|
||||
return _exclusive_locks[profile_id]
|
||||
|
||||
|
||||
def _job_row(job_id: str):
|
||||
with connect() as conn:
|
||||
return conn.execute("SELECT rowid AS _rowid, * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
|
||||
|
||||
def _job_payload(row) -> dict:
|
||||
try:
|
||||
return json.loads((row or {}).get("payload_json") or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _is_ordered_job(row) -> bool:
|
||||
payload = _job_payload(row)
|
||||
action = str((row or {}).get("action") or "")
|
||||
# Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work.
|
||||
return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
|
||||
|
||||
|
||||
def _is_priority_job(row) -> bool:
|
||||
payload = _job_payload(row)
|
||||
return bool(payload.get('priority_job') or payload.get('force_job')) or str((row or {}).get('action') or '') == 'set_limits'
|
||||
|
||||
|
||||
def _is_light_job(row) -> bool:
|
||||
return _is_light_action(str((row or {}).get("action") or ""))
|
||||
|
||||
|
||||
def _has_prior_ordered_jobs(profile_id: int, rowid: int) -> bool:
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT rowid AS _rowid, action, payload_json
|
||||
FROM jobs
|
||||
WHERE profile_id=?
|
||||
AND rowid<?
|
||||
AND status IN ('pending', 'running')
|
||||
ORDER BY rowid
|
||||
""",
|
||||
(profile_id, rowid),
|
||||
).fetchall()
|
||||
return any(_is_ordered_job(row) and not _is_priority_job(row) for row in rows)
|
||||
|
||||
|
||||
def _wait_for_prior_ordered_jobs(job_id: str, profile_id: int, rowid: int) -> bool:
|
||||
while _has_prior_ordered_jobs(profile_id, rowid):
|
||||
fresh = _job_row(job_id)
|
||||
if not fresh or fresh["status"] == "cancelled":
|
||||
return False
|
||||
if _is_priority_job(fresh):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
|
||||
|
||||
def _set_job(job_id: str, status: str, error: str = "", result: dict | None = None, started: bool = False, finished: bool = False):
|
||||
now = utcnow()
|
||||
fields = ["status=?", "error=?", "updated_at=?"]
|
||||
values: list = [status, error, now]
|
||||
if result is not None:
|
||||
fields.append("result_json=?")
|
||||
values.append(json.dumps(result))
|
||||
if started:
|
||||
fields.append("started_at=?")
|
||||
values.append(now)
|
||||
if finished:
|
||||
fields.append("finished_at=?")
|
||||
values.append(now)
|
||||
values.append(job_id)
|
||||
with connect() as conn:
|
||||
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=?", values)
|
||||
|
||||
|
||||
def _job_state(row) -> dict:
|
||||
try:
|
||||
return json.loads((row or {}).get("state_json") or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _checkpoint_job(job_id: str, state: dict, progress_current: int | None = None, progress_total: int | None = None) -> None:
|
||||
now = utcnow()
|
||||
fields = ["state_json=?", "heartbeat_at=?", "updated_at=?"]
|
||||
values: list = [json.dumps(state), now, now]
|
||||
if progress_current is not None:
|
||||
fields.append("progress_current=?")
|
||||
values.append(int(progress_current))
|
||||
if progress_total is not None:
|
||||
fields.append("progress_total=?")
|
||||
values.append(int(progress_total))
|
||||
values.append(job_id)
|
||||
with connect() as conn:
|
||||
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=? AND status='running'", values)
|
||||
|
||||
|
||||
def _submit_job(job_id: str, action_name: str | None = None):
|
||||
if action_name is None:
|
||||
row = _job_row(job_id)
|
||||
action_name = str((row or {}).get("action") or "")
|
||||
executor = _light_executor if _is_light_action(str(action_name or "")) else _heavy_executor
|
||||
executor.submit(_run, job_id)
|
||||
|
||||
|
||||
def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | None = None, max_attempts: int = 2, force: bool = False) -> str:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
job_id = uuid.uuid4().hex
|
||||
if force:
|
||||
payload = dict(payload or {})
|
||||
# Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation.
|
||||
payload['force_job'] = True
|
||||
payload['priority_job'] = True
|
||||
now = utcnow()
|
||||
progress_total = len((payload or {}).get("hashes") or [])
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now),
|
||||
)
|
||||
_emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
|
||||
_submit_job(job_id, action_name)
|
||||
return job_id
|
||||
|
||||
|
||||
def _job_event_meta(payload: dict) -> dict:
|
||||
ctx = payload.get("job_context") or {}
|
||||
source = str(ctx.get("source") or payload.get("source") or "user")
|
||||
meta = {"source": source}
|
||||
if source == "automation":
|
||||
# Note: Socket operation toasts use this flag so automation notifications respect user preferences.
|
||||
meta["automation"] = True
|
||||
meta["source_label"] = str(ctx.get("rule_name") or "automation")
|
||||
if ctx.get("rule_id") is not None:
|
||||
meta["rule_id"] = ctx.get("rule_id")
|
||||
return meta
|
||||
|
||||
|
||||
def _execute(profile: dict, action_name: str, payload: dict):
|
||||
if action_name == "smart_queue_check":
|
||||
from . import smart_queue
|
||||
return smart_queue.check(profile, user_id=auth.current_user_id() or default_user_id(), force=True)
|
||||
if action_name == "add_magnet":
|
||||
if bool(payload.get("start", True)):
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
return rtorrent.add_magnet(profile, payload["uri"], bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""))
|
||||
if action_name == "add_torrent_raw":
|
||||
import base64
|
||||
raw = base64.b64decode(payload["data_b64"])
|
||||
if bool(payload.get("start", True)):
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
return rtorrent.add_torrent_raw(profile, raw, bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""), payload.get("file_priorities") or None)
|
||||
if action_name == "set_limits":
|
||||
return rtorrent.set_limits(profile, payload.get("down"), payload.get("up"))
|
||||
hashes = payload.get("hashes") or []
|
||||
if action_name in {"start", "resume", "unpause"}:
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
state = payload.get("__resume_state") or {}
|
||||
|
||||
def checkpoint(next_state: dict, current: int, total: int):
|
||||
job_id = payload.get("__job_id")
|
||||
if job_id:
|
||||
_checkpoint_job(str(job_id), next_state, current, total)
|
||||
|
||||
return rtorrent.action(profile, hashes, action_name, payload, checkpoint=checkpoint, resume_state=state)
|
||||
|
||||
|
||||
def _claim_runner(job_id: str) -> bool:
|
||||
with _runner_lock:
|
||||
if job_id in _active_runners:
|
||||
return False
|
||||
_active_runners.add(job_id)
|
||||
return True
|
||||
|
||||
|
||||
def _release_runner(job_id: str) -> None:
|
||||
with _runner_lock:
|
||||
_active_runners.discard(job_id)
|
||||
|
||||
|
||||
def _mark_running(job_id: str, attempts: int) -> bool:
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE jobs SET status='running', attempts=?, started_at=COALESCE(started_at, ?), updated_at=? WHERE id=? AND status='pending'",
|
||||
(attempts, now, now, job_id),
|
||||
)
|
||||
return int(cur.rowcount or 0) == 1
|
||||
|
||||
|
||||
def _run(job_id: str):
|
||||
if not _claim_runner(job_id):
|
||||
return
|
||||
sem = None
|
||||
ordered_lock = None
|
||||
try:
|
||||
job = _job_row(job_id)
|
||||
if not job or job["status"] == "cancelled":
|
||||
return
|
||||
profile = get_profile(int(job["profile_id"]), int(job["user_id"]))
|
||||
if not profile:
|
||||
_set_job(job_id, "failed", "rTorrent profile does not exist", finished=True)
|
||||
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"})
|
||||
return
|
||||
profile_id = int(profile["id"])
|
||||
if _is_ordered_job(job) and not _is_priority_job(job):
|
||||
if not _wait_for_prior_ordered_jobs(job_id, profile_id, int(job["_rowid"])):
|
||||
return
|
||||
ordered_lock = _get_exclusive_lock(profile_id)
|
||||
ordered_lock.acquire()
|
||||
sem = _get_sem(profile, light=_is_light_job(job))
|
||||
sem.acquire()
|
||||
job = _job_row(job_id)
|
||||
if not job or job["status"] == "cancelled":
|
||||
return
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
payload["__job_id"] = job_id
|
||||
payload["__resume_state"] = _job_state(job)
|
||||
attempts = int(job.get("attempts") or 0) + 1
|
||||
if not _mark_running(job_id, attempts):
|
||||
return
|
||||
event_meta = _job_event_meta(payload)
|
||||
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
||||
result = _execute(profile, job["action"], payload)
|
||||
fresh = _job_row(job_id)
|
||||
# Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state.
|
||||
if fresh and fresh["status"] != "running":
|
||||
return
|
||||
_set_job(job_id, "done", result=result, finished=True)
|
||||
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
||||
except Exception as exc:
|
||||
fresh = _job_row(job_id) or {}
|
||||
attempts = int(fresh.get("attempts") or 1)
|
||||
max_attempts = int(fresh.get("max_attempts") or 2)
|
||||
# Note: Emergency cancel keeps an exception from a cancelled job from moving it back to retry or failed.
|
||||
if fresh and fresh.get("status") != "running":
|
||||
return
|
||||
status = "pending" if attempts < max_attempts else "failed"
|
||||
_set_job(job_id, status, str(exc), finished=(status == "failed"))
|
||||
_emit("operation_failed", {"job_id": job_id, "action": job.get("action"), "profile_id": job.get("profile_id"), "hashes": payload.get("hashes") or [], "error": str(exc), **_job_event_meta(payload)})
|
||||
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts})
|
||||
if status == "pending":
|
||||
_submit_job(job_id, job.get("action"))
|
||||
finally:
|
||||
if sem:
|
||||
sem.release()
|
||||
if ordered_lock:
|
||||
ordered_lock.release()
|
||||
_release_runner(job_id)
|
||||
|
||||
|
||||
|
||||
def _parse_ts(value: str | None) -> float | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
from datetime import datetime
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _job_timeout_seconds(profile: dict, row) -> int:
|
||||
key = "light_job_timeout_seconds" if _is_light_job(row) else "heavy_job_timeout_seconds"
|
||||
default = 300 if _is_light_job(row) else 7200
|
||||
return _bounded_int(profile.get(key), default, 30)
|
||||
|
||||
|
||||
def _pending_timeout_seconds(profile: dict) -> int:
|
||||
return _bounded_int(profile.get("pending_job_timeout_seconds"), 900, 60)
|
||||
|
||||
|
||||
def _timeout_running_jobs() -> None:
|
||||
now_ts = time.time()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,user_id,profile_id,action,started_at FROM jobs WHERE status='running'").fetchall()
|
||||
for row in rows:
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if not profile:
|
||||
continue
|
||||
started_ts = _parse_ts(row.get("started_at"))
|
||||
if started_ts is None or now_ts - started_ts < _job_timeout_seconds(profile, row):
|
||||
continue
|
||||
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
|
||||
_set_job(row["id"], "failed", message, finished=True)
|
||||
_emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"})
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message})
|
||||
|
||||
|
||||
def _resubmit_interrupted_running_jobs() -> None:
|
||||
now_ts = time.time()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,user_id,profile_id,action,heartbeat_at,updated_at FROM jobs WHERE status='running'").fetchall()
|
||||
for row in rows:
|
||||
with _runner_lock:
|
||||
active = row["id"] in _active_runners
|
||||
if active:
|
||||
continue
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if not profile:
|
||||
continue
|
||||
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at"))
|
||||
# Note: After process restart there is no in-memory runner for this job.
|
||||
# A short grace avoids stealing work from another still-alive Gunicorn worker.
|
||||
if last_seen_ts is not None and now_ts - last_seen_ts < 90:
|
||||
continue
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE jobs SET status='pending', error=?, updated_at=? WHERE id=? AND status='running'",
|
||||
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
|
||||
)
|
||||
if int(cur.rowcount or 0):
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
|
||||
|
||||
def _resubmit_stale_pending_jobs() -> None:
|
||||
now_ts = time.time()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,user_id,profile_id,action,updated_at FROM jobs WHERE status='pending'").fetchall()
|
||||
for row in rows:
|
||||
with _runner_lock:
|
||||
active = row["id"] in _active_runners
|
||||
if active:
|
||||
continue
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if not profile:
|
||||
continue
|
||||
updated_ts = _parse_ts(row.get("updated_at"))
|
||||
if updated_ts is None or now_ts - updated_ts < _pending_timeout_seconds(profile):
|
||||
continue
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"]))
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
|
||||
|
||||
def _watchdog_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
_resubmit_interrupted_running_jobs()
|
||||
_timeout_running_jobs()
|
||||
_resubmit_stale_pending_jobs()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(WATCHDOG_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def start_watchdog() -> None:
|
||||
global _watchdog_started
|
||||
with _watchdog_lock:
|
||||
if _watchdog_started:
|
||||
return
|
||||
_watchdog_started = True
|
||||
thread = threading.Thread(target=_watchdog_loop, name="pytorrent-job-watchdog", daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _safe_json(value, fallback):
|
||||
try:
|
||||
return json.loads(value or "")
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _job_summary(row: dict, payload: dict, result: dict) -> str:
|
||||
ctx = payload.get("job_context") or {}
|
||||
count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
parts = []
|
||||
if ctx.get("bulk_label"):
|
||||
# Note: Shows which generated bulk part is being displayed in the job queue.
|
||||
parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
|
||||
if count:
|
||||
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)")
|
||||
if ctx.get("target_path"):
|
||||
parts.append(f"target: {ctx.get('target_path')}")
|
||||
if ctx.get("remove_data"):
|
||||
parts.append("remove data")
|
||||
if ctx.get("move_data"):
|
||||
parts.append("move data")
|
||||
if result.get("count") is not None:
|
||||
parts.append(f"done: {result.get('count')}")
|
||||
if result.get("errors"):
|
||||
parts.append(f"errors: {len(result.get('errors') or [])}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _public_job(row) -> dict:
|
||||
d = dict(row)
|
||||
payload = _safe_json(d.get("payload_json"), {})
|
||||
result = _safe_json(d.get("result_json"), {})
|
||||
ctx = payload.get("job_context") or {}
|
||||
d["payload"] = payload
|
||||
state = _safe_json(d.get("state_json"), {})
|
||||
d["result"] = result
|
||||
d["state"] = state
|
||||
d["progress_current"] = int(d.get("progress_current") or len(state.get("completed_hashes") or []))
|
||||
d["progress_total"] = int(d.get("progress_total") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
d["hash_count"] = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
d["is_bulk"] = bool(ctx.get("bulk") or d["hash_count"] > 1)
|
||||
d["summary"] = _job_summary(d, payload, result)
|
||||
d["source"] = str(ctx.get("source") or "user")
|
||||
d["source_label"] = str(ctx.get("rule_name") or ctx.get("source") or "user")
|
||||
d["is_forced"] = bool(payload.get("force_job") or payload.get("priority_job"))
|
||||
items = ctx.get("items") or []
|
||||
if d["is_bulk"]:
|
||||
d["items_preview"] = ""
|
||||
else:
|
||||
d["items_preview"] = ", ".join([str((x or {}).get("name") or (x or {}).get("hash") or "") for x in items[:1] if x])
|
||||
return d
|
||||
|
||||
|
||||
def _job_scope_sql(writable: bool = False) -> tuple[str, tuple]:
|
||||
visible = auth.writable_profile_ids() if writable else auth.visible_profile_ids()
|
||||
if visible is None:
|
||||
return "", ()
|
||||
if not visible:
|
||||
return " WHERE 1=0", ()
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
return f" WHERE profile_id IN ({placeholders})", tuple(visible)
|
||||
|
||||
|
||||
def list_jobs(limit: int = 200, offset: int = 0):
|
||||
limit = max(1, min(int(limit or 50), 500))
|
||||
offset = max(0, int(offset or 0))
|
||||
where, params = _job_scope_sql()
|
||||
with connect() as conn:
|
||||
rows = conn.execute(f"SELECT * FROM jobs{where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
total = conn.execute(f"SELECT COUNT(*) AS n FROM jobs{where}", params).fetchone()["n"]
|
||||
return {"rows": [_public_job(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row["status"] not in {"pending", "running"}:
|
||||
return False
|
||||
# Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup.
|
||||
_set_job(job_id, "cancelled", finished=True)
|
||||
_emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "cancelled"})
|
||||
return True
|
||||
|
||||
|
||||
def clear_jobs() -> int:
|
||||
where, params = _job_scope_sql(writable=True)
|
||||
status_clause = "status NOT IN ('pending', 'running')"
|
||||
sql = f"DELETE FROM jobs{where} AND {status_clause}" if where else f"DELETE FROM jobs WHERE {status_clause}"
|
||||
with connect() as conn:
|
||||
cur = conn.execute(sql, params)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def emergency_clear_jobs() -> int:
|
||||
# Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list.
|
||||
now = utcnow()
|
||||
where, params = _job_scope_sql(writable=True)
|
||||
status_clause = "status IN ('pending', 'running')"
|
||||
update_sql = f"UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=?{where} AND {status_clause}" if where else "UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=? WHERE status IN ('pending', 'running')"
|
||||
with connect() as conn:
|
||||
conn.execute(update_sql, (now, now, *params) if where else (now, now))
|
||||
cur = conn.execute(f"DELETE FROM jobs{where}", params) if where else conn.execute("DELETE FROM jobs")
|
||||
deleted = int(cur.rowcount or 0)
|
||||
_emit("job_update", {"status": "cleared", "emergency": True})
|
||||
return deleted
|
||||
|
||||
|
||||
def force_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row['status'] != 'pending':
|
||||
return False
|
||||
payload = _job_payload(row)
|
||||
payload['force_job'] = True
|
||||
payload['priority_job'] = True
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET payload_json=?, updated_at=? WHERE id=?", (json.dumps(payload), utcnow(), job_id))
|
||||
_emit('job_update', {'id': job_id, 'profile_id': row.get('profile_id'), 'status': 'pending', 'forced': True})
|
||||
_submit_job(job_id, row.get('action'))
|
||||
return True
|
||||
|
||||
def retry_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row["status"] not in {"failed", "cancelled"}:
|
||||
return False
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET status='pending', error='', finished_at=NULL, state_json=NULL, progress_current=0, heartbeat_at=NULL, updated_at=? WHERE id=?", (utcnow(), job_id))
|
||||
_emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "pending"})
|
||||
_submit_job(job_id, row.get("action"))
|
||||
return True
|
||||
Reference in New Issue
Block a user