first commit

This commit is contained in:
root
2026-05-19 13:43:37 +00:00
commit 9dcd0abd7d
107 changed files with 33622 additions and 0 deletions

156
pytorrent/__init__.py Normal file
View File

@@ -0,0 +1,156 @@
from __future__ import annotations
from pathlib import Path
from flask import Flask, jsonify, render_template, request, url_for
from flask_socketio import SocketIO
from werkzeug.middleware.proxy_fix import ProxyFix
from .config import (
SECRET_KEY,
SESSION_COOKIE_SECURE,
PROXY_FIX_ENABLE,
PROXY_FIX_X_FOR,
PROXY_FIX_X_PROTO,
PROXY_FIX_X_HOST,
PROXY_FIX_X_PORT,
PROXY_FIX_X_PREFIX,
SOCKETIO_CORS_ALLOWED_ORIGINS,
)
from .db import init_db
from .services.frontend_assets import asset_path, bootstrap_css_path, validate_offline_assets
from .utils import file_md5
socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading")
_static_md5_cache: dict[tuple, str] = {}
def _wants_json_response() -> bool:
"""Return true for API/error clients that should not receive an HTML page."""
best = request.accept_mimetypes.best_match(["application/json", "text/html"])
return request.path.startswith("/api/") or best == "application/json"
def register_error_pages(app: Flask) -> None:
@app.errorhandler(404)
def not_found(error):
if _wants_json_response():
return jsonify({"ok": False, "error": "Not found"}), 404
return render_template(
"error.html",
code=404,
title="Page not found",
message="The requested pyTorrent view does not exist or is not available.",
icon="fa-compass-drafting",
), 404
@app.errorhandler(500)
def server_error(error):
if _wants_json_response():
return jsonify({"ok": False, "error": "Internal server error"}), 500
return render_template(
"error.html",
code=500,
title="Application error",
message="pyTorrent hit an internal error while handling this request.",
icon="fa-bug",
), 500
def create_app() -> Flask:
validate_offline_assets()
app = Flask(__name__)
from .logging_config import configure_logging
configure_logging(app)
if PROXY_FIX_ENABLE:
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=PROXY_FIX_X_FOR,
x_proto=PROXY_FIX_X_PROTO,
x_host=PROXY_FIX_X_HOST,
x_port=PROXY_FIX_X_PORT,
x_prefix=PROXY_FIX_X_PREFIX,
)
app.secret_key = SECRET_KEY
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=SESSION_COOKIE_SECURE,
)
@app.context_processor
def static_helpers():
def static_url(filename: str) -> str:
path = Path(app.static_folder or "") / filename
try:
stat = path.stat()
key = (filename, stat.st_mtime_ns, stat.st_size)
version = _static_md5_cache.get(key)
if not version:
_static_md5_cache.clear()
version = file_md5(path)
_static_md5_cache[key] = version
return url_for("static", filename=filename, v=version)
except OSError:
return url_for("static", filename=filename)
def frontend_asset_url(key: str) -> str:
path = asset_path(key)
return path if path.startswith("http") else static_url(path)
def bootstrap_theme_url(theme: str | None = None) -> str:
path = bootstrap_css_path(theme)
return path if path.startswith("http") else static_url(path)
return {
"static_url": static_url,
"frontend_asset_url": frontend_asset_url,
"bootstrap_theme_url": bootstrap_theme_url,
}
@app.after_request
def cache_headers(response):
static_file = request.path.startswith("/static/")
tracker_icon = request.path.startswith("/static/tracker_favicons/")
favicon = request.path in ("/favicon.ico", "/favicon.svg")
openapi_spec = request.path == "/api/openapi.json"
if static_file and not tracker_icon:
response.headers["Cache-Control"] = "no-cache, must-revalidate"
elif favicon:
response.headers["Cache-Control"] = "public, max-age=7899999, immutable"
elif openapi_spec:
response.headers["Cache-Control"] = "private, no-cache, must-revalidate"
else:
response.headers["Cache-Control"] = "private, no-store"
return response
from .routes.main import bp as main_bp
from .routes.api import bp as api_bp
from .routes.planner import bp as planner_api_bp
app.register_blueprint(main_bp)
app.register_blueprint(api_bp)
app.register_blueprint(planner_api_bp)
register_error_pages(app)
init_db()
from .services.speed_peaks import load_cache
load_cache()
from .services.auth import install_guards
install_guards(app)
socketio.init_app(app)
from .services.workers import set_socketio, start_watchdog
set_socketio(socketio)
start_watchdog()
from .services.websocket import register_socketio_handlers
register_socketio_handlers(socketio)
from .services.startup_config import schedule_startup_config_apply
schedule_startup_config_apply(socketio)
from .services.rss import start_scheduler as start_rss_scheduler
from .services.ratio_rules import start_scheduler as start_ratio_scheduler
from .services.download_planner import start_scheduler as start_download_planner_scheduler
from .services.backup import start_scheduler as start_backup_scheduler
start_rss_scheduler(socketio)
start_ratio_scheduler(socketio)
start_download_planner_scheduler(socketio)
start_backup_scheduler()
return app

155
pytorrent/cli.py Normal file
View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import argparse
import getpass
import sys
import json
from .db import connect, init_db, utcnow
from .services.auth import password_hash
from .services import tracker_cache
def reset_password(username: str, password: str) -> bool:
"""Note: Reset the selected user password hash without changing role or permissions."""
username = (username or "").strip()
if not username:
raise ValueError("Username is required")
if password is None or password == "":
raise ValueError("Password cannot be empty")
init_db()
now = utcnow()
hashed = password_hash(password)
with connect() as conn:
row = conn.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()
if not row:
return False
conn.execute(
"UPDATE users SET password_hash=?, updated_at=? WHERE username=?",
(hashed, now, username),
)
return True
def revoke_api_token_cli(identifier: str, username: str = "") -> int:
"""Note: Revoke an API token by numeric id or visible token prefix without starting the web UI."""
token = str(identifier or "").strip()
if not token:
raise ValueError("Token id or prefix is required")
init_db()
now = utcnow()
params: list = []
where = ""
if token.isdigit():
where = "t.id=?"
params.append(int(token))
else:
where = "t.token_prefix=?"
params.append(token)
if username:
where += " AND u.username=?"
params.append(str(username).strip())
with connect() as conn:
row = conn.execute(
f"SELECT t.id FROM api_tokens t JOIN users u ON u.id=t.user_id WHERE {where} AND t.revoked_at IS NULL",
tuple(params),
).fetchone()
if not row:
return 0
conn.execute("UPDATE api_tokens SET revoked_at=?, updated_at=? WHERE id=?", (now, now, int(row["id"])))
return 1
def fetch_tracker_favicon(domain: str, refresh: bool = True, debug: bool = False) -> str:
"""Note: Download or refresh one tracker favicon from CLI without starting the web server."""
clean = tracker_cache.tracker_domain(domain)
if not clean:
raise ValueError("Tracker domain is required")
init_db()
path, mime = tracker_cache.favicon_path(clean, enabled=True, force=refresh)
row = tracker_cache.favicon_cache_row(clean)
if not path:
detail = (row or {}).get("error") if row else "favicon not found"
if debug and row:
raise RuntimeError(f"{detail or 'favicon not found'}; cache={json.dumps(dict(row), default=str)}")
raise RuntimeError(str(detail or "favicon not found"))
if debug and row:
return f"{path} ({mime or 'unknown'}) cache={json.dumps(dict(row), default=str)}"
return f"{path} ({mime or 'unknown'})"
def _password_from_args(args: argparse.Namespace) -> str:
"""Note: Allow the password to be passed as an argument or entered securely in interactive mode."""
if args.password is not None:
return args.password
first = getpass.getpass("New password: ")
second = getpass.getpass("Repeat password: ")
if first != second:
raise ValueError("Passwords do not match")
return first
def build_parser() -> argparse.ArgumentParser:
"""Note: Define simple administrative commands launched with python -m pytorrent.cli."""
parser = argparse.ArgumentParser(description="pyTorrent CLI")
sub = parser.add_subparsers(dest="command", required=True)
reset = sub.add_parser("reset-password", help="Reset password for an existing user")
reset.add_argument("username", help="User login")
reset.add_argument("password", nargs="?", help="New password; omit to type it interactively")
reset.set_defaults(func=_cmd_reset_password)
token = sub.add_parser("revoke-api-token", help="Revoke an API token by id or visible prefix")
token.add_argument("identifier", help="Token id or token_prefix shown in the Users tab")
token.add_argument("--user", default="", help="Optional username filter for safety")
token.set_defaults(func=_cmd_revoke_api_token)
icon = sub.add_parser("tracker-favicon", help="Download or refresh a tracker favicon cache file")
icon.add_argument("domain", help="Tracker domain, e.g. t.pte.nu")
icon.add_argument("--no-refresh", action="store_true", help="Use fresh cache when available")
icon.add_argument("--debug", action="store_true", help="Print cache diagnostics on success or failure")
icon.set_defaults(func=_cmd_tracker_favicon)
return parser
def _cmd_reset_password(args: argparse.Namespace) -> int:
"""Note: Run the password reset and return a readable terminal status."""
password = _password_from_args(args)
if reset_password(args.username, password):
print(f"Password reset for user: {args.username}")
return 0
print(f"User not found: {args.username}", file=sys.stderr)
return 1
def _cmd_revoke_api_token(args: argparse.Namespace) -> int:
"""Note: Revoke API tokens safely from CLI when the web UI is unavailable."""
count = revoke_api_token_cli(args.identifier, username=args.user or "")
if count:
print(f"API token revoked: {args.identifier}")
return 0
print(f"Active API token not found: {args.identifier}", file=sys.stderr)
return 1
def _cmd_tracker_favicon(args: argparse.Namespace) -> int:
"""Note: Run favicon discovery from CLI and print the saved file path."""
print(fetch_tracker_favicon(args.domain, refresh=not args.no_refresh, debug=bool(args.debug)))
return 0
def main(argv: list[str] | None = None) -> int:
"""Note: Main CLI entrypoint with error handling and without starting the web app."""
parser = build_parser()
args = parser.parse_args(argv)
try:
return int(args.func(args) or 0)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

85
pytorrent/config.py Normal file
View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import os
import secrets
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
def _env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
_SECRET_KEY_ENV = os.getenv("PYTORRENT_SECRET_KEY")
SECRET_KEY = _SECRET_KEY_ENV or "dev-change-me"
DB_PATH = Path(os.getenv("PYTORRENT_DB_PATH", str(BASE_DIR / "data" / "pytorrent.sqlite3")))
if not DB_PATH.is_absolute():
DB_PATH = BASE_DIR / DB_PATH
HOST = os.getenv("PYTORRENT_HOST", "0.0.0.0")
PORT = int(os.getenv("PYTORRENT_PORT", "8090"))
DEBUG = _env_bool("PYTORRENT_DEBUG", False)
# Note: Offline mode forces local JS/CSS and disables the CDN dependency.
USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False)
# Note: Optional authentication remains disabled unless explicitly enabled in .env.
AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False)
if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"):
# Note: Auth mode cannot use Flask's development secret; persist a local random session key instead.
_secret_file = BASE_DIR / "data" / ".session_secret"
_secret_file.parent.mkdir(parents=True, exist_ok=True)
if _secret_file.exists():
SECRET_KEY = _secret_file.read_text(encoding="utf-8").strip()
if not SECRET_KEY or SECRET_KEY == "dev-change-me":
SECRET_KEY = secrets.token_urlsafe(48)
_secret_file.write_text(SECRET_KEY, encoding="utf-8")
SESSION_COOKIE_SECURE = _env_bool("PYTORRENT_SESSION_COOKIE_SECURE", False)
# Note: Keep Werkzeug opt-in only for explicit local/dev use, never by default in services.
ALLOW_UNSAFE_WERKZEUG = _env_bool("PYTORRENT_ALLOW_UNSAFE_WERKZEUG", DEBUG)
POLL_INTERVAL = float(os.getenv("PYTORRENT_POLL_INTERVAL", "0.5"))
MIN_POLL_INTERVAL_SECONDS = float(os.getenv("MIN_POLL_INTERVAL_SECONDS", "0.5"))
WORKERS = int(os.getenv("PYTORRENT_WORKERS", "16"))
GEOIP_DB = Path(os.getenv("PYTORRENT_GEOIP_DB", str(BASE_DIR / "data" / "GeoLite2-City.mmdb")))
if not GEOIP_DB.is_absolute():
GEOIP_DB = BASE_DIR / GEOIP_DB
def _env_int(name: str, default: int, minimum: int = 0) -> int:
try:
return max(minimum, int(os.getenv(name, str(default))))
except (TypeError, ValueError):
return default
PYTORRENT_TMP_DIR = Path(os.getenv("PYTORRENT_TMP_DIR", "/tmp"))
if not PYTORRENT_TMP_DIR.is_absolute():
PYTORRENT_TMP_DIR = BASE_DIR / PYTORRENT_TMP_DIR
REMOTE_READ_CHUNK_BYTES = _env_int("PYTORRENT_REMOTE_READ_CHUNK_BYTES", 1048576, 65536)
PROXY_FIX_ENABLE = _env_bool("PYTORRENT_PROXY_FIX_ENABLE", False)
PROXY_FIX_X_FOR = _env_int("PYTORRENT_PROXY_FIX_X_FOR", 1, 0)
PROXY_FIX_X_PROTO = _env_int("PYTORRENT_PROXY_FIX_X_PROTO", 1, 0)
PROXY_FIX_X_HOST = _env_int("PYTORRENT_PROXY_FIX_X_HOST", 1, 0)
PROXY_FIX_X_PORT = _env_int("PYTORRENT_PROXY_FIX_X_PORT", 1, 0)
PROXY_FIX_X_PREFIX = _env_int("PYTORRENT_PROXY_FIX_X_PREFIX", 1, 0)
_SOCKETIO_CORS = os.getenv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS", "").strip()
SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() for item in _SOCKETIO_CORS.split(",") if item.strip()]
TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1)
JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)
SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1)
LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 1, 1)
LOG_RETENTION_HOURS = _env_int("PYTORRENT_LOG_RETENTION_HOURS", 24, 1)
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
if not LOG_DIR.is_absolute():
LOG_DIR = BASE_DIR / LOG_DIR
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped")
SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled")

654
pytorrent/db.py Normal file
View File

@@ -0,0 +1,654 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from .config import DB_PATH
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT,
role TEXT DEFAULT 'user',
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS user_profile_permissions (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL DEFAULT 0,
access_level TEXT NOT NULL DEFAULT 'ro',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id),
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_active ON api_tokens(user_id, revoked_at);
CREATE INDEX IF NOT EXISTS idx_api_tokens_prefix ON api_tokens(token_prefix);
CREATE INDEX IF NOT EXISTS idx_api_tokens_active_user ON api_tokens(revoked_at, user_id);
CREATE INDEX IF NOT EXISTS idx_user_profile_permissions_user ON user_profile_permissions(user_id, profile_id);
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
theme TEXT DEFAULT 'dark',
bootstrap_theme TEXT DEFAULT 'default',
font_family TEXT DEFAULT 'default',
active_rtorrent_id INTEGER,
table_columns_json TEXT,
keyboard_json TEXT,
mobile_mode INTEGER DEFAULT 0,
peers_refresh_seconds INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0,
footer_items_json TEXT,
title_speed_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
automation_toasts_enabled INTEGER DEFAULT 1,
smart_queue_toasts_enabled INTEGER DEFAULT 1,
disk_monitor_paths_json TEXT,
disk_monitor_mode TEXT DEFAULT 'default',
disk_monitor_selected_path TEXT,
disk_monitor_stop_enabled INTEGER DEFAULT 0,
disk_monitor_stop_threshold INTEGER DEFAULT 98,
interface_scale INTEGER DEFAULT 100,
detail_panel_height INTEGER DEFAULT 255,
torrent_sort_json TEXT,
active_filter TEXT DEFAULT 'all',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
CREATE TABLE IF NOT EXISTS rtorrent_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
scgi_url TEXT NOT NULL,
is_default INTEGER DEFAULT 0,
timeout_seconds INTEGER DEFAULT 5,
max_parallel_jobs INTEGER DEFAULT 5,
light_parallel_jobs INTEGER DEFAULT 4,
light_job_timeout_seconds INTEGER DEFAULT 300,
heavy_job_timeout_seconds INTEGER DEFAULT 7200,
pending_job_timeout_seconds INTEGER DEFAULT 900,
is_remote INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
profile_id INTEGER,
action TEXT NOT NULL,
payload_json TEXT,
status TEXT NOT NULL,
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 2,
error TEXT,
result_json TEXT,
state_json TEXT,
progress_current INTEGER DEFAULT 0,
progress_total INTEGER DEFAULT 0,
heartbeat_at TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_profile_status ON jobs(profile_id, status, created_at);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at);
CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
paths_json TEXT,
mode TEXT DEFAULT 'default',
selected_path TEXT,
stop_enabled INTEGER DEFAULT 0,
stop_threshold INTEGER DEFAULT 98,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id),
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
);
CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL,
color TEXT DEFAULT '#64748b',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, profile_id, name)
);
CREATE TABLE IF NOT EXISTS ratio_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL,
min_ratio REAL DEFAULT 1.0,
max_ratio REAL DEFAULT 2.0,
seed_time_minutes INTEGER DEFAULT 0,
min_seed_time_minutes INTEGER DEFAULT 0,
ignore_private INTEGER DEFAULT 1,
ignore_active_upload INTEGER DEFAULT 1,
active_upload_min_bytes INTEGER DEFAULT 1024,
move_path TEXT,
set_label TEXT,
action TEXT DEFAULT 'stop',
enabled INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, profile_id, name)
);
CREATE TABLE IF NOT EXISTS rss_feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
interval_minutes INTEGER DEFAULT 30,
last_error TEXT,
last_checked_at TEXT,
next_check_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rss_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL,
pattern TEXT NOT NULL,
exclude_pattern TEXT,
min_size_mb INTEGER DEFAULT 0,
max_size_mb INTEGER DEFAULT 0,
category TEXT,
quality TEXT,
season INTEGER,
episode INTEGER,
save_path TEXT,
label TEXT,
start INTEGER DEFAULT 1,
enabled INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at);
CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled);
CREATE TABLE IF NOT EXISTS rss_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
feed_id INTEGER,
rule_id INTEGER,
title TEXT,
link TEXT,
status TEXT NOT NULL,
message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added');
CREATE TABLE IF NOT EXISTS ratio_assignments (
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
group_id INTEGER,
group_name TEXT,
applied_at TEXT,
last_status TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY(profile_id, torrent_hash)
);
CREATE TABLE IF NOT EXISTS ratio_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
group_id INTEGER,
group_name TEXT,
torrent_hash TEXT NOT NULL,
torrent_name TEXT,
action TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT,
details_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id);
CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status);
CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled);
CREATE TABLE IF NOT EXISTS app_backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS smart_queue_settings (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
enabled INTEGER DEFAULT 0,
max_active_downloads INTEGER DEFAULT 5,
stalled_seconds INTEGER DEFAULT 300,
min_speed_bytes INTEGER DEFAULT 1024,
min_seeds INTEGER DEFAULT 1,
min_peers INTEGER DEFAULT 0,
ignore_seed_peer INTEGER DEFAULT 0,
ignore_speed INTEGER DEFAULT 0,
manage_stopped INTEGER DEFAULT 0,
cooldown_minutes INTEGER DEFAULT 10,
last_run_at TEXT,
refill_enabled INTEGER DEFAULT 1,
refill_interval_minutes INTEGER DEFAULT 0,
last_refill_at TEXT,
stop_batch_size INTEGER DEFAULT 50,
start_grace_seconds INTEGER DEFAULT 900,
protect_active_below_cap INTEGER DEFAULT 1,
auto_stop_idle INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id)
);
CREATE TABLE IF NOT EXISTS smart_queue_stalled (
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
first_stalled_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
timer_key TEXT DEFAULT '',
PRIMARY KEY(profile_id, torrent_hash)
);
CREATE TABLE IF NOT EXISTS smart_queue_start_grace (
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
started_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(profile_id, torrent_hash)
);
CREATE TABLE IF NOT EXISTS smart_queue_exclusions (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
reason TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id, torrent_hash)
);
CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
event TEXT NOT NULL,
paused_count INTEGER DEFAULT 0,
resumed_count INTEGER DEFAULT 0,
checked_count INTEGER DEFAULT 0,
details_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_auto_labels (
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
previous_label TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(profile_id, torrent_hash)
);
CREATE TABLE IF NOT EXISTS traffic_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
down_rate INTEGER DEFAULT 0,
up_rate INTEGER DEFAULT 0,
total_down INTEGER DEFAULT 0,
total_up INTEGER DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at);
CREATE TABLE IF NOT EXISTS transfer_speed_peaks (
profile_id INTEGER PRIMARY KEY,
session_started_at TEXT NOT NULL,
session_down_peak INTEGER DEFAULT 0,
session_up_peak INTEGER DEFAULT 0,
session_down_peak_at TEXT,
session_up_peak_at TEXT,
all_time_down_peak INTEGER DEFAULT 0,
all_time_up_peak INTEGER DEFAULT 0,
all_time_down_peak_at TEXT,
all_time_up_peak_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS automation_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
conditions_json TEXT NOT NULL,
effects_json TEXT NOT NULL,
cooldown_minutes INTEGER DEFAULT 60,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_automation_rules_profile_enabled ON automation_rules(profile_id, enabled);
CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled);
CREATE TABLE IF NOT EXISTS automation_rule_state (
rule_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
condition_since_at TEXT,
last_matched_at TEXT,
last_applied_at TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY(rule_id, profile_id, torrent_hash)
);
CREATE TABLE IF NOT EXISTS automation_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
rule_id INTEGER,
torrent_hash TEXT,
torrent_name TEXT,
rule_name TEXT,
actions_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS rtorrent_config_overrides (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT,
baseline_value TEXT,
apply_on_start INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id, key)
);
CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS download_plan_settings (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
settings_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id)
);
CREATE TABLE IF NOT EXISTS download_plan_paused (
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(profile_id, torrent_hash)
);
CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at);
CREATE TABLE IF NOT EXISTS torrent_stats_cache (
profile_id INTEGER PRIMARY KEY,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_epoch REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tracker_summary_cache (
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
trackers_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_epoch REAL DEFAULT 0,
PRIMARY KEY(profile_id, torrent_hash)
);
CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch);
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
domain TEXT PRIMARY KEY,
source_url TEXT,
file_path TEXT,
mime_type TEXT,
updated_at TEXT NOT NULL,
updated_epoch REAL DEFAULT 0,
error TEXT
);
"""
MIGRATIONS = [
"ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT",
"ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'",
"ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1",
"ALTER TABLE users ADD COLUMN updated_at TEXT",
"ALTER TABLE user_preferences ADD COLUMN mobile_mode INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN peers_refresh_seconds INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN port_check_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN active_filter TEXT DEFAULT 'all'",
"ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5",
"ALTER TABLE rtorrent_profiles ADD COLUMN light_parallel_jobs INTEGER DEFAULT 4",
"ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300",
"ALTER TABLE rtorrent_profiles ADD COLUMN heavy_job_timeout_seconds INTEGER DEFAULT 7200",
"ALTER TABLE rtorrent_profiles ADD COLUMN pending_job_timeout_seconds INTEGER DEFAULT 900",
"ALTER TABLE rtorrent_profiles ADD COLUMN is_remote INTEGER DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN attempts INTEGER DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN max_attempts INTEGER DEFAULT 2",
"ALTER TABLE jobs ADD COLUMN result_json TEXT",
"ALTER TABLE jobs ADD COLUMN state_json TEXT",
"ALTER TABLE jobs ADD COLUMN progress_current INTEGER DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN progress_total INTEGER DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN heartbeat_at TEXT",
"ALTER TABLE jobs ADD COLUMN started_at TEXT",
"ALTER TABLE jobs ADD COLUMN finished_at TEXT",
"CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
"ALTER TABLE automation_rules ADD COLUMN cooldown_minutes INTEGER DEFAULT 60",
"ALTER TABLE rtorrent_config_overrides ADD COLUMN apply_on_start INTEGER DEFAULT 0",
"ALTER TABLE rtorrent_config_overrides ADD COLUMN baseline_value TEXT",
"ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0",
"ALTER TABLE smart_queue_settings ADD COLUMN manage_stopped INTEGER DEFAULT 0",
"ALTER TABLE smart_queue_settings ADD COLUMN min_peers INTEGER DEFAULT 0",
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_seed_peer INTEGER DEFAULT 0",
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_speed INTEGER DEFAULT 0",
"ALTER TABLE smart_queue_stalled ADD COLUMN timer_key TEXT DEFAULT ''",
"CREATE TABLE IF NOT EXISTS tracker_summary_cache (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, trackers_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, PRIMARY KEY(profile_id, torrent_hash))",
"CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch)",
"CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)",
"ALTER TABLE user_preferences ADD COLUMN automation_toasts_enabled INTEGER DEFAULT 1",
"ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_paths_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_mode TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_selected_path TEXT",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_threshold INTEGER DEFAULT 98",
"ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10",
"ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT",
"ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1",
"ALTER TABLE smart_queue_settings ADD COLUMN refill_interval_minutes INTEGER DEFAULT 0",
"ALTER TABLE smart_queue_settings ADD COLUMN last_refill_at TEXT",
"ALTER TABLE smart_queue_settings ADD COLUMN stop_batch_size INTEGER DEFAULT 50",
"ALTER TABLE smart_queue_settings ADD COLUMN start_grace_seconds INTEGER DEFAULT 900",
"ALTER TABLE smart_queue_settings ADD COLUMN protect_active_below_cap INTEGER DEFAULT 1",
"ALTER TABLE smart_queue_settings ADD COLUMN auto_stop_idle INTEGER DEFAULT 0",
"CREATE TABLE IF NOT EXISTS smart_queue_start_grace (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
"ALTER TABLE rss_feeds ADD COLUMN interval_minutes INTEGER DEFAULT 30",
"ALTER TABLE rss_feeds ADD COLUMN next_check_at TEXT",
"ALTER TABLE rss_rules ADD COLUMN exclude_pattern TEXT",
"ALTER TABLE rss_rules ADD COLUMN min_size_mb INTEGER DEFAULT 0",
"ALTER TABLE rss_rules ADD COLUMN max_size_mb INTEGER DEFAULT 0",
"ALTER TABLE rss_rules ADD COLUMN category TEXT",
"ALTER TABLE rss_rules ADD COLUMN quality TEXT",
"ALTER TABLE rss_rules ADD COLUMN season INTEGER",
"ALTER TABLE rss_rules ADD COLUMN episode INTEGER",
"ALTER TABLE ratio_groups ADD COLUMN min_seed_time_minutes INTEGER DEFAULT 0",
"ALTER TABLE ratio_groups ADD COLUMN ignore_private INTEGER DEFAULT 1",
"ALTER TABLE ratio_groups ADD COLUMN ignore_active_upload INTEGER DEFAULT 1",
"ALTER TABLE ratio_groups ADD COLUMN active_upload_min_bytes INTEGER DEFAULT 1024",
"ALTER TABLE ratio_groups ADD COLUMN move_path TEXT",
"ALTER TABLE ratio_groups ADD COLUMN set_label TEXT",
"ALTER TABLE automation_history ADD COLUMN torrent_name TEXT",
"ALTER TABLE automation_history ADD COLUMN rule_name TEXT",
"ALTER TABLE automation_history ADD COLUMN actions_json TEXT",
"ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT",
"CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')",
"CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
"CREATE TABLE IF NOT EXISTS ratio_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, group_id INTEGER, group_name TEXT, torrent_hash TEXT NOT NULL, torrent_name TEXT, action TEXT NOT NULL, status TEXT NOT NULL, reason TEXT, details_json TEXT, created_at TEXT NOT NULL)",
"CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(profile_id, created_at)",
"CREATE TABLE IF NOT EXISTS app_backups (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS disk_monitor_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, paths_json TEXT, mode TEXT DEFAULT 'default', selected_path TEXT, stop_enabled INTEGER DEFAULT 0, stop_threshold INTEGER DEFAULT 98, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))",
"CREATE TABLE IF NOT EXISTS download_plan_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
"CREATE TABLE IF NOT EXISTS download_plan_paused (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
"CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)",
"CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)",
"CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status)",
"CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id)",
"CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
]
POST_MIGRATION_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_api_tokens_active_user ON api_tokens(revoked_at, user_id)",
"CREATE INDEX IF NOT EXISTS idx_user_profile_permissions_user ON user_profile_permissions(user_id, profile_id)",
"CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
]
def utcnow() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def dict_factory(cursor, row):
return {col[0]: row[idx] for idx, col in enumerate(cursor.description)}
@contextmanager
def connect():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = dict_factory
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 30000")
conn.execute("PRAGMA synchronous = NORMAL")
try:
yield conn
conn.commit()
finally:
conn.close()
def init_db():
with connect() as conn:
try:
conn.execute("PRAGMA journal_mode = WAL")
except sqlite3.OperationalError:
pass
conn.executescript(SCHEMA)
for sql in MIGRATIONS:
try:
conn.execute(sql)
except sqlite3.OperationalError:
pass
for sql in POST_MIGRATION_INDEXES:
try:
conn.execute(sql)
except sqlite3.OperationalError:
pass
now = utcnow()
conn.execute(
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",
(now, now),
)
conn.execute("UPDATE users SET role=COALESCE(role, 'admin'), is_active=COALESCE(is_active, 1), updated_at=COALESCE(updated_at, ?) WHERE id=1", (now,))
pref = conn.execute("SELECT id FROM user_preferences WHERE user_id=1").fetchone()
if not pref:
conn.execute(
"INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(1, 'dark', ?, ?)",
(now, now),
)
try:
from .services.auth import ensure_admin_user
ensure_admin_user()
except Exception:
pass
def default_user_id() -> int:
return 1

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import logging
import time
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
from typing import Any
from flask import Flask, g, request
from .config import LOG_DIR, LOG_RETENTION_HOURS
_CONFIGURED = False
def _make_handler(path: Path, level: int) -> TimedRotatingFileHandler:
"""Create an hourly rotating log handler with retention configured in hours."""
path.parent.mkdir(parents=True, exist_ok=True)
handler = TimedRotatingFileHandler(
path,
when="H",
interval=1,
backupCount=max(1, int(LOG_RETENTION_HOURS)),
encoding="utf-8",
utc=False,
)
handler.setLevel(level)
handler.suffix = "%Y%m%d%H"
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s"))
return handler
def configure_logging(app: Flask | None = None) -> None:
"""Route pyTorrent app, error and access logs to the configured data log directory."""
global _CONFIGURED
LOG_DIR.mkdir(parents=True, exist_ok=True)
if not _CONFIGURED:
app_handler = _make_handler(LOG_DIR / "app.log", logging.INFO)
error_handler = _make_handler(LOG_DIR / "error.log", logging.WARNING)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(app_handler)
root.addHandler(error_handler)
for name in ("pytorrent", "werkzeug", "gunicorn.error"):
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.propagate = True
_CONFIGURED = True
if app is not None:
app.logger.setLevel(logging.INFO)
if not getattr(app, "_pytorrent_access_logging", False):
access_logger = logging.getLogger("pytorrent.access")
access_logger.setLevel(logging.INFO)
access_logger.propagate = False
access_logger.addHandler(_make_handler(LOG_DIR / "access.log", logging.INFO))
@app.before_request
def _mark_access_start() -> None:
g._access_started_at = time.perf_counter()
@app.after_request
def _write_access_log(response):
duration_ms = int((time.perf_counter() - getattr(g, "_access_started_at", time.perf_counter())) * 1000)
# Note: Application access logging is rotated hourly, unlike raw gunicorn stdout logs.
access_logger.info(
'%s "%s %s" %s %s %sms "%s"',
request.headers.get("X-Forwarded-For", request.remote_addr or "-"),
request.method,
request.full_path.rstrip("?"),
response.status_code,
response.calculate_content_length() or 0,
duration_ms,
request.headers.get("User-Agent", "-"),
)
return response
@app.teardown_request
def _log_unhandled_error(error: BaseException | None) -> None:
if error is not None:
app.logger.error("Unhandled request error", exc_info=(type(error), error, error.__traceback__))
app._pytorrent_access_logging = True # type: ignore[attr-defined]

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

489
pytorrent/services/auth.py Normal file
View 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

View 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}

View 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()

View 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')})"
)

View 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)

View 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}"
)

View 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": ""}

View 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})

View 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)

View 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()

View 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
View 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()

View 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.

View 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 *

View 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"}
]

View 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('__')]

View 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"}
]

View 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"}
]

View 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"}
]

View File

@@ -0,0 +1,4 @@
from __future__ import annotations
# Note: Backward-compatible internal alias for modules created during refactor.
from .client import *

View 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"}
]

View 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"}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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)))

View 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)

View 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()

View 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,
}

View 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,
}

View 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

View 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)

View 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

View 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}

View 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)})

View 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

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="14" y="20" width="36" height="30" rx="8" fill="#f8fafc" stroke="#0f172a" stroke-width="4"></rect>
<rect x="22" y="30" width="6" height="6" rx="3" fill="#0f172a"></rect>
<rect x="36" y="30" width="6" height="6" rx="3" fill="#0f172a"></rect>
<path d="M25 42h14" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
<path d="M32 20V10" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
<circle cx="32" cy="8" r="4" fill="#0f172a"></circle>
<path d="M14 34H8M56 34h-6" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 647 B

View File

@@ -0,0 +1 @@
export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?`<span class=\"fi fi-${esc(code)}\"></span> <span>${esc(code.toUpperCase())}</span>`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `<table class=\"table table-sm detail-table${cls}\"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }\n function responsiveTable(headers,rows,extraClass=''){ return `<div class=\"responsive-table-wrap\">${table(headers,rows,extraClass)}</div>`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toast('Download started','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toast('No torrents selected','warning');\n if(list.length===1) return downloadResponse(`/api/torrents/${encodeURIComponent(list[0])}/torrent-file`,{},`${list[0]}.torrent`,'Preparing .torrent...').catch(e=>toast(e.message,'danger'));\n return downloadResponse('/api/torrents/torrent-files.zip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({hashes:list})},'pytorrent-torrents.zip','Preparing torrent ZIP...').catch(e=>toast(e.message,'danger'));\n }\n";

View File

@@ -0,0 +1,44 @@
import { stateSource } from './state.js';
import { torrentsSource } from './torrents.js';
import { apiSource } from './api.js';
import { createTorrentSource } from './createTorrent.js';
import { torrentDetailsSource } from './torrentDetails.js';
import { modalsSource } from './modals.js';
import { rssSource } from './rss.js';
import { smartQueueSource } from './smartQueue.js';
import { plannerSource } from './planner.js';
import { pollerSource } from './poller.js';
import { dashboardSource } from './dashboard.js';
import { chartsSource } from './charts.js';
import { bootstrapSource } from './bootstrap.js';
export const moduleSources = [
stateSource,
torrentsSource,
apiSource,
createTorrentSource,
torrentDetailsSource,
modalsSource,
rssSource,
smartQueueSource,
plannerSource,
dashboardSource,
pollerSource,
chartsSource,
bootstrapSource,
];
export function buildRuntimeSource(){
return `(() => {\n${moduleSources.join('\n')}\n})();\n`;
}
export function startApp(){
const runtimeSource = buildRuntimeSource();
// Keep the original shared lexical scope while loading the source from smaller ES modules.
// `io` is passed explicitly so Socket.IO remains available inside the generated runtime.
return Function('io', runtimeSource)(window.io);
}
if(typeof window !== 'undefined' && !window.PYTORRENT_DISABLE_AUTOSTART){
startApp();
}

1
pytorrent/static/js/bootstrap.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export const createTorrentSource = " function isCreateTorrentTabActive(){\n return $('createTorrentPane')?.classList.contains('active');\n }\n function syncAddAndCreateActions(){\n const createActive = isCreateTorrentTabActive();\n $('addBtn')?.classList.toggle('d-none', !!createActive);\n $('createTorrentBtn')?.classList.toggle('d-none', !createActive);\n }\n function createTorrentPayload(){\n const fd = new FormData();\n fd.append('source_path', $('createSourcePath')?.value || '');\n fd.append('trackers', $('createTrackers')?.value || '');\n fd.append('comment', $('createComment')?.value || '');\n fd.append('source', $('createSourceName')?.value || '');\n fd.append('piece_size_kib', $('createPieceSize')?.value || '256');\n fd.append('private', $('createPrivate')?.checked ? '1' : '0');\n fd.append('share', $('createShare')?.checked ? '1' : '0');\n fd.append('label', $('createLabel')?.value || '');\n return fd;\n }\n function downloadCreatedTorrent(blob,name){\n const obj = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = obj;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(()=>URL.revokeObjectURL(obj), 1000);\n }\n async function createTorrentFromModal(){\n const btn = $('createTorrentBtn');\n const info = $('createTorrentInfo');\n buttonBusy(btn, true);\n setBusy(true, 'Creating torrent...');\n if(info) info.textContent = 'Creating .torrent file...';\n try{\n const res = await fetch('/api/torrents/create', {method: 'POST', body: createTorrentPayload()});\n if(!res.ok){\n const j = await res.json().catch(()=>({}));\n throw new Error(j.error || `Create failed (${res.status})`);\n }\n const name = filenameFromResponse(res, 'created.torrent');\n const message = res.headers.get('X-PyTorrent-Create-Message') || 'Torrent created';\n const blob = await res.blob();\n downloadCreatedTorrent(blob, name);\n if(info) info.textContent = message;\n toast(message, 'success');\n }catch(e){\n if(info) info.textContent = e.message;\n toast(e.message, 'danger');\n }finally{\n setBusy(false);\n buttonBusy(btn, false);\n }\n }\n $('addModal')?.addEventListener('shown.bs.modal', syncAddAndCreateActions);\n document.querySelectorAll('#addModal [data-bs-toggle=\"pill\"]').forEach(tab => tab.addEventListener('shown.bs.tab', syncAddAndCreateActions));\n $('createTorrentBtn')?.addEventListener('click', createTorrentFromModal);\n";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export const rssSource = " async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[]; if($('rssManager')) $('rssManager').innerHTML=`<h6>Feeds</h6>${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),`<button class=\"btn btn-xs btn-outline-primary rss-edit-feed\" data-feed='${esc(JSON.stringify(f))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-feed\" data-id=\"${esc(f.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">Rules</h6>${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),`<button class=\"btn btn-xs btn-outline-primary rss-edit-rule\" data-rule='${esc(JSON.stringify(r))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-rule\" data-id=\"${esc(r.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">RSS log</h6>${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`; }\n \n\n function fillBackupSettings(settings={}){\n if($('backupAutoEnabled')) $('backupAutoEnabled').checked=!!settings.enabled;\n if($('backupAutoInterval')) $('backupAutoInterval').value=settings.interval_hours||24;\n if($('backupRetentionDays')) $('backupRetentionDays').value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '<div class=\"backup-preview-empty\">No saved rows in this table.</div>';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`<details class=\"backup-preview-table-details\"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n return `<div class=\"surface-section backup-preview-card\"><div class=\"section-title\"><i class=\"fa-solid fa-eye\"></i> Backup preview</div><div class=\"small text-muted mb-2\">Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n const rows=j.backups||[];\n fillBackupSettings(j.auto||{});\n if($('backupManager')) $('backupManager').innerHTML=responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`<div class=\"table-action-group backup-actions\"><button class=\"btn btn-xs btn-outline-info backup-preview-btn\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-eye\"></i> Preview</button><a class=\"btn btn-xs btn-outline-secondary\" href=\"/api/backup/${esc(b.id)}/download\"><i class=\"fa-solid fa-download\"></i> Download</a><button class=\"btn btn-xs btn-outline-warning backup-restore\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-rotate-left\"></i> Restore</button><button class=\"btn btn-xs btn-outline-danger backup-delete\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button></div>`]),'backup-table');\n }\n\n";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4181
pytorrent/static/styles.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
../../data/tracker_favicons

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pyTorrent {{ code }}</title>
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
<link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
<link href="{{ bootstrap_theme_url('default') }}" rel="stylesheet">
<link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet">
<link href="{{ static_url('styles.css') }}" rel="stylesheet">
</head>
<body class="error-page">
<main class="error-card" role="alert">
<div class="error-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div>
<div class="error-icon" aria-hidden="true"><i class="fa-solid {{ icon }}"></i></div>
<p class="error-code">{{ code }}</p>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
<div class="error-actions">
<a class="btn btn-primary" href="{{ url_for('main.index') }}"><i class="fa-solid fa-house"></i> Back to dashboard</a>
<a class="btn btn-outline-secondary" href="{{ url_for('main.docs') }}"><i class="fa-solid fa-book"></i> API docs</a>
</div>
</main>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pyTorrent login</title>
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
<link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
<link href="{{ bootstrap_theme_url('default') }}" rel="stylesheet">
<link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet">
<link href="{{ static_url('styles.css') }}" rel="stylesheet">
</head>
<body class="auth-page">
<main class="initial-loader-card auth-card">
<div class="initial-loader-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div>
<div class="auth-lock" aria-hidden="true"><i class="fa-solid fa-lock"></i></div>
<h1 class="initial-loader-title">Sign in</h1>
<p class="initial-loader-text">Authentication is enabled for this pyTorrent instance.</p>
{% if error %}<div class="alert alert-danger auth-alert">{{ error }}</div>{% endif %}
<form class="auth-form" method="post">
<label class="form-label" for="username">User</label>
<input id="username" class="form-control" name="username" autocomplete="username" autofocus>
<label class="form-label" for="password">Password</label>
<input id="password" class="form-control" name="password" type="password" autocomplete="current-password">
<button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
</form>
</main>
</body>
</html>

21
pytorrent/utils.py Normal file
View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import hashlib
from pathlib import Path
def human_size(num: int | float | None, suffix: str = "B") -> str:
value = float(num or 0)
for unit in ["", "K", "M", "G", "T", "P"]:
if abs(value) < 1024.0:
return f"{value:3.1f} {unit}{suffix}" if unit else f"{int(value)} {suffix}"
value /= 1024.0
return f"{value:.1f} E{suffix}"
def human_rate(num: int | float | None) -> str:
return f"{human_size(num)}/s"
def file_md5(path: Path) -> str:
return hashlib.md5(path.read_bytes()).hexdigest()[:12]