first commit

This commit is contained in:
gru
2026-05-03 12:35:57 +01:00
commit 4fac1716a6
31 changed files with 5949 additions and 0 deletions

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
PYTORRENT_SECRET_KEY=change-me
PYTORRENT_DB_PATH=data/pytorrent.sqlite3
PYTORRENT_HOST=0.0.0.0
PYTORRENT_PORT=8090
PYTORRENT_DEBUG=0
PYTORRENT_POLL_INTERVAL=1.0
PYTORRENT_WORKERS=16
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
# Retention / Smart Queue
PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90
PYTORRENT_JOBS_RETENTION_DAYS=30
PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS=30
PYTORRENT_LOG_RETENTION_DAYS=30
PYTORRENT_SMART_QUEUE_LABEL="Smart Queue Paused"

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Python
__pycache__/
*.py[cod]
*.pyo
# Virtualenv
venv/
.env
.venv
# App data
*.log
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Tests / cache
.pytest_cache/
.mypy_cache/
# Build
dist/
build/
*.egg-info/
storage/*
*.zip
*.sqlite3-shm
*.sqlite3
data/*
logs/*
todo.txt

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# pyTorrent
Monopage web UI dla rTorrent inspirowany workflow ruTorrent.
## Funkcje
- Flask + Flask-SocketIO.
- SQLite na preferencje, profile SCGI, motyw Bootstrapa i font UI.
- Dowolna liczba profili rTorrent per user.
- Profile można dodawać i edytować z UI; flaga zdalnej lokalizacji ukrywa CPU/RAM hosta aplikacji, żeby nie mylić ich z zasobami zdalnego rTorrenta; publiczny IP dla port check jest dalej sprawdzany zdalnie, jeśli rTorrent to obsługuje.
- Przełączanie aktywnego rTorrent z UI.
- Live lista torrentów przez WebSocket.
- Cache aplikacyjny i wysyłanie patchy bez przeładowywania całej tabeli.
- Operacje usera wykonywane w ThreadPoolExecutor.
- Akcje `move` i `remove` są wykonywane per profil w kolejności zlecenia, więc późniejsze usunięcie poczeka na wcześniejsze przenoszenia.
- Log jobsów pokazuje krótką datę i godzinę w tabeli oraz pełny timestamp w tooltipie.
- Masowe start/pause/stop/resume/recheck/remove/move.
- Move obsługuje `move_data=true`, który fizycznie przenosi dane po stronie rTorrent w tle i odpytuje plik statusu, dzięki czemu długie `mv` nie kończy się timeoutem SCGI; jeśli cel już istnieje, jest nadpisywany (`force`), a timeouty z `mkdir`/startu/pollingu move nie przerywają operacji. Potem aktualizuje katalog torrenta, a `recheck` domyślnie włącza się przy fizycznym przenoszeniu.
- Modal dodawania wielu magnetów.
- Dolny status bar: CPU, RAM, wersja rTorrent, prędkości, limity, total DL/UP oraz status portu, gdy port check jest włączony.
- Prawoklik na torrentach.
- Skróty klawiaturowe.
- Szczegóły: General, Files, Peers, Trackers, Log.
- Smart Queue pokazuje domyślnie 10 ostatnich operacji; można rozwinąć historię do 100 wpisów.
- GeoIP peerów z MaxMind GeoLite2-City.mmdb, z cache IP.
- Cache-busting statyków przez MD5 i nagłówki cache.
- Preferencje wyglądu: domyślny Bootstrap albo Bootswatch: Flatly, Litera, Lumen, Minty, Sketchy, Solar, Spacelab, United, Zephyr.
- Preferencje fontu: domyślny font motywu, Adwaita Mono oraz dodatkowe pasujące fonty.
## Uruchomienie
```bash
./install.sh
. venv/bin/activate
python app.py
```
Domyślnie: `http://127.0.0.1:8090`.
## Profil SCGI
Przykład:
```txt
scgi://127.0.0.1:5000/RPC2
```
Po stronie rTorrent:
```txt
network.scgi.open_port = 127.0.0.1:5000
```
## GeoIP
Instalator pobiera bazę GeoLite2-City jednorazowo do:
```txt
data/GeoLite2-City.mmdb
```
Można też uruchomić ręcznie:
```bash
./scripts/download_geoip.sh
```
Skrypt używa głównego źródła `https://git.io/GeoLite2-City.mmdb`, a przy błędzie fallbacku `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb`. Katalog `data` ma uprawnienia `755`, a plik bazy `644`.
## API docs
Dokumentacja OpenAPI jest dostępna pod `/docs`. Endpoint `/api/profiles` obsługuje `max_parallel_jobs` z domyślną wartością `5` oraz `is_remote`; `PUT /api/profiles/{profile_id}` edytuje istniejący profil. Endpoint `/api/preferences` obsługuje m.in. `theme`, `bootstrap_theme`, `font_family`, `table_columns_json`, `peers_refresh_seconds` i `port_check_enabled`. Endpoint `/api/port-check` zwraca status portu wraz z `checked_at`; dla zdalnego profilu publiczny IP jest pobierany przez rTorrent z fallbackami `ifconfig.co`, `ifconfig.me` i `ipapi.linuxiarz.pl`, jeśli dana konfiguracja rTorrenta wspiera zdalne polecenia, a metoda `POST` wymusza ponowny check z pominięciem cache. Endpoint `/api/system/status` dla zdalnego profilu zwraca `usage_available=false` i nie odczytuje CPU/RAM.
`/api/openapi.json` zawiera reusable schemas dla głównych odpowiedzi API, w tym `TorrentListResponse`, `TorrentSummary`, `TorrentFilterSummary`, `CleanupSummary` i `AppStatus`. `GET /api/torrents` dokumentuje teraz pole `summary` używane przez sidebar filters.

7
app.py Normal file
View File

@@ -0,0 +1,7 @@
from pytorrent import create_app, socketio
from pytorrent.config import HOST, PORT, DEBUG
app = create_app()
if __name__ == "__main__":
socketio.run(app, host=HOST, port=PORT, debug=DEBUG, allow_unsafe_werkzeug=True)

29
deploy/pytorrent.service Normal file
View File

@@ -0,0 +1,29 @@
# useradd --system --home /opt/pyTorrent --shell /usr/sbin/nologin pytorrent
# chown -R pytorrent:pytorrent /opt/pyTorrent
[Unit]
Description=pyTorrent Web UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
#User=root
#Group=root
User=pytorrent
Group=pytorrent
WorkingDirectory=/opt/pyTorrent
Environment="PYTHONUNBUFFERED=1"
EnvironmentFile=/opt/pyTorrent/.env
ExecStart=/opt/pyTorrent/venv/bin/python /opt/pyTorrent/app.py
Restart=always
RestartSec=3
KillSignal=SIGINT
TimeoutStopSec=20
# opcjonalnie
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

12
install.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
python3 -m venv venv
. venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
cp -n .env.example .env || true
mkdir -p data
chmod 755 data
./scripts/download_geoip.sh data/GeoLite2-City.mmdb
python -c "from pytorrent.db import init_db; init_db(); print(\"SQLite initialized\")"
echo "Run: . venv/bin/activate && python app.py"

70
make_zip.py Normal file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import os
import sys
import zipfile
import subprocess
from pathlib import Path
def run_git_command(args, repo_path: Path) -> bytes:
result = subprocess.run(
["git", *args],
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout
def get_files_to_archive(repo_path: Path) -> list[str]:
output = run_git_command(
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
repo_path,
)
files = output.decode("utf-8", errors="surrogateescape").split("\0")
return [f for f in files if f]
def make_zip(repo_path: Path, output_zip: Path) -> None:
files = get_files_to_archive(repo_path)
output_zip = output_zip.resolve()
if output_zip.exists():
output_zip.unlink()
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for rel_path in files:
abs_path = repo_path / rel_path
if not abs_path.exists():
continue
if abs_path.resolve() == output_zip:
continue
zf.write(abs_path, arcname=rel_path)
print(f"Utworzono archiwum: {output_zip}")
print(f"Dodano plików: {len(files)}")
def main():
repo_path = Path.cwd()
if len(sys.argv) > 1:
output_zip = Path(sys.argv[1])
else:
output_zip = repo_path / f"{repo_path.name}.zip"
try:
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
except subprocess.CalledProcessError:
print("Błąd: ten katalog nie jest repozytorium Git.", file=sys.stderr)
sys.exit(1)
make_zip(repo_path, output_zip)
if __name__ == "__main__":
main()

60
pytorrent/__init__.py Normal file
View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from pathlib import Path
from flask import Flask, request, url_for
from flask_socketio import SocketIO
from .config import SECRET_KEY
from .db import init_db
from .utils import file_md5
socketio = SocketIO(cors_allowed_origins="*", ping_timeout=30, async_mode="threading")
_static_md5_cache: dict[tuple, str] = {}
def create_app() -> Flask:
app = Flask(__name__)
app.secret_key = SECRET_KEY
@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)
return {"static_url": static_url}
@app.after_request
def cache_headers(response):
response.headers.pop('Content-Disposition', None)
if request.endpoint == "static":
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
from .routes.main import bp as main_bp
from .routes.api import bp as api_bp
app.register_blueprint(main_bp)
app.register_blueprint(api_bp)
init_db()
socketio.init_app(app)
from .services.workers import set_socketio
set_socketio(socketio)
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)
return app

36
pytorrent/config.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
SECRET_KEY = os.getenv("PYTORRENT_SECRET_KEY", "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 = os.getenv("PYTORRENT_DEBUG", "0") == "1"
POLL_INTERVAL = float(os.getenv("PYTORRENT_POLL_INTERVAL", "1.0"))
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
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", 30, 1)
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Paused")

301
pytorrent/db.py Normal file
View File

@@ -0,0 +1,301 @@
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,
created_at TEXT NOT NULL
);
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,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(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,
is_remote INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
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,
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 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,
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,
last_error TEXT,
last_checked_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,
save_path TEXT,
label TEXT,
start INTEGER DEFAULT 1,
enabled INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_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,
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,
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 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 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 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 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 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
);
"""
MIGRATIONS = [
"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 rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5",
"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 started_at TEXT",
"ALTER TABLE jobs ADD COLUMN finished_at TEXT",
"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",
]
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 journal_mode = WAL")
try:
yield conn
conn.commit()
finally:
conn.close()
def init_db():
with connect() as conn:
conn.executescript(SCHEMA)
for sql in MIGRATIONS:
try:
conn.execute(sql)
except sqlite3.OperationalError:
pass
now = utcnow()
conn.execute(
"INSERT OR IGNORE INTO users(id, username, password_hash, created_at) VALUES(1, 'default', NULL, ?)",
(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),
)
def default_user_id() -> int:
return 1

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

@@ -0,0 +1,848 @@
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 xml.etree.ElementTree as ET
from flask import Blueprint, jsonify, request
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS
from ..db import default_user_id, connect, utcnow
from ..services import preferences, rtorrent
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, clear_jobs
from ..services.geoip import lookup_ip
bp = Blueprint("api", __name__, url_prefix="/api")
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()
def _incoming_port(profile: dict) -> int | None:
try:
value = str(rtorrent.client_for(profile).call("network.port_range") or "")
except Exception:
value = ""
match = re.search(r"(\d{2,5})", value)
if not match:
return None
port = int(match.group(1))
return port if 1 <= port <= 65535 else None
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 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 = _incoming_port(profile)
if not port:
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
cache_key = f"port_check:{profile['id']}:{port}"
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": port, "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(_yougetsignal_check(public_ip, port))
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(_local_port_fallback(public_ip, port))
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 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"),
"retention_days": {
"jobs": JOBS_RETENTION_DAYS,
"smart_queue_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
@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.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.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("/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>/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.post("/torrents/<torrent_hash>/peers/action")
def torrent_peer_action(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:
result = rtorrent.peer_action(profile, torrent_hash, int(data.get("peer_index")), str(data.get("action") or ""))
return ok({"result": result, "message": f"Peer {result['action']} via {result['method']}"})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@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", "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
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/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}))
for uploaded in request.files.getlist("files"):
data_b64 = base64.b64encode(uploaded.read()).decode("ascii")
job_ids.append(enqueue("add_torrent_raw", profile["id"], {"filename": uploaded.filename, "data_b64": data_b64, "start": start, "directory": directory, "label": label}))
return ok({"job_ids": job_ids})
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("/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})
@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)
if bool(profile.get("is_remote")):
status["usage_source"] = "remote-hidden"
status["usage_available"] = False
else:
status["cpu"] = psutil.cpu_percent(interval=None)
status["ram"] = psutil.virtual_memory().percent
status["usage_source"] = "local"
status["usage_available"] = True
return ok({"status": status})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@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:
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():
deleted = clear_jobs()
return ok({"deleted": deleted})
@bp.get("/cleanup/summary")
def cleanup_status():
return ok({"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/all")
def cleanup_all():
deleted_jobs = clear_jobs()
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)
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart}, "cleanup": cleanup_summary()})
@bp.post("/jobs/<job_id>/cancel")
def jobs_cancel(job_id: str):
if not cancel_job(job_id):
return jsonify({"ok": False, "error": "Only pending or failed jobs can be cancelled"}), 400
return ok()
@bp.post("/jobs/<job_id>/retry")
def jobs_retry(job_id: str):
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("/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()
return ok({"groups": rows})
@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 OR REPLACE INTO ratio_groups(user_id,profile_id,name,min_ratio,max_ratio,seed_time_minutes,action,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (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), data.get("action") or "stop", 1 if data.get("enabled", True) else 0, now, now))
return ratio_groups_list()
@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()
return ok({"feeds": feeds, "rules": rules})
@bp.post("/rss/feeds")
def rss_feed_save():
profile = preferences.active_profile()
data = request.get_json(silent=True) or {}
now = utcnow()
with connect() as conn:
conn.execute("INSERT INTO rss_feeds(user_id,profile_id,name,url,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "RSS", data.get("url") or "", 1, now, now))
return rss_list()
@bp.post("/rss/rules")
def rss_rule_save():
profile = preferences.active_profile()
data = request.get_json(silent=True) or {}
now = utcnow()
with connect() as conn:
conn.execute("INSERT INTO rss_rules(user_id,profile_id,name,pattern,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "Rule", data.get("pattern") or ".*", data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1, now, now))
return rss_list()
@bp.post("/rss/check")
def rss_check():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
queued = 0
with connect() as conn:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (default_user_id(), profile["id"])).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (default_user_id(), profile["id"])).fetchall()
for feed in feeds:
try:
raw = urllib.request.urlopen(feed["url"], timeout=10).read(2_000_000)
root = ET.fromstring(raw)
for item in root.findall('.//item')[:100]:
title = item.findtext('title') or ''
link = item.findtext('link') or ''
enc = item.find('enclosure')
if enc is not None and enc.get('url'):
link = enc.get('url') or link
for rule in rules:
if re.search(rule["pattern"], title, re.I) and link:
enqueue("add_magnet", profile["id"], {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or active_default_download_path(profile), "label": rule.get("label") or ""})
queued += 1
except Exception as exc:
with connect() as conn:
conn.execute("UPDATE rss_feeds SET last_error=?, last_checked_at=?, updated_at=? WHERE id=?", (str(exc), utcnow(), utcnow(), feed["id"]))
return ok({"queued": queued})
@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/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('/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})
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 {}
return ok({'settings': smart_queue.save_settings(profile['id'], payload)})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)})
@bp.post('/smart-queue/check')
def smart_queue_check():
from ..services import smart_queue
profile = preferences.active_profile()
if not profile:
return ok({'result': {'ok': False, 'error': 'No profile'}})
try:
return ok({'result': smart_queue.check(profile, force=True)})
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.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': []}})
@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.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/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:
return ok({'result': automation_rules.check(profile, force=True), 'history': automation_rules.list_history(profile['id'])})
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500

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

@@ -0,0 +1,281 @@
from __future__ import annotations
from flask import Blueprint, render_template, jsonify, Response
from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES, bootstrap_css_url
bp = Blueprint("main", __name__)
@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,
bootstrap_css_url=bootstrap_css_url((prefs or {}).get("bootstrap_theme")),
)
@bp.get("/docs")
def docs():
html = """<!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=\"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css\"></head><body><div id=\"swagger-ui\"></div><script src=\"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.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():
paths = {
"/api/profiles": {
"get": {"summary": "List rTorrent profiles", "responses": {"200": {"description": "Profiles"}}},
"post": {"summary": "Create rTorrent profile", "requestBody": {"required": True, "content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "scgi_url": {"type": "string"}, "timeout_seconds": {"type": "integer"}, "max_parallel_jobs": {"type": "integer", "default": 5, "description": "Maximum queued jobs that may run at once for this rTorrent. Move/remove jobs keep request order."}, "is_remote": {"type": "boolean", "description": "When true, CPU/RAM host usage is hidden; public IP checks try remote rTorrent commands when supported."}}}}}}, "responses": {"200": {"description": "Created"}}}
},
"/api/profiles/{profile_id}": {
"put": {"summary": "Update rTorrent profile", "parameters": [{"name": "profile_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"required": True, "content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "scgi_url": {"type": "string"}, "timeout_seconds": {"type": "integer"}, "max_parallel_jobs": {"type": "integer", "default": 5, "description": "Maximum queued jobs that may run at once for this rTorrent. Move/remove jobs keep request order."}, "is_remote": {"type": "boolean", "description": "When true, CPU/RAM host usage is hidden; public IP checks try remote rTorrent commands when supported."}}}}}}, "responses": {"200": {"description": "Updated"}}},
"delete": {"summary": "Delete rTorrent profile", "parameters": [{"name": "profile_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Deleted"}}}
},
"/api/profiles/{profile_id}/activate": {"post": {"summary": "Activate profile", "parameters": [{"name": "profile_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Activated"}}}},
"/api/preferences": {
"get": {"summary": "Get preferences", "responses": {"200": {"description": "Preferences including theme, bootstrap_theme and font_family"}}},
"post": {
"summary": "Save preferences",
"requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {
"theme": {"type": "string", "enum": ["light", "dark"]},
"bootstrap_theme": {"type": "string", "enum": list(BOOTSTRAP_THEMES.keys())},
"font_family": {"type": "string", "enum": list(FONT_FAMILIES.keys())},
"table_columns_json": {"type": "string"},
"peers_refresh_seconds": {"type": "integer", "enum": [0, 10, 15, 30, 60]},
"port_check_enabled": {"type": "boolean"},
}}}}},
"responses": {"200": {"description": "Saved"}},
},
},
"/api/torrents": {"get": {"summary": "Get cached torrent snapshot", "responses": {"200": {"description": "Torrent list"}}}},
"/api/torrents/{action_name}": {"post": {"summary": "Queue torrent action", "description": "For move, path is the target directory; move_data=true physically moves data on the rTorrent host using a detached shell move with status polling, force-overwrites an existing destination, tolerates rTorrent execute timeouts around mkdir/start/polling, handles retries after a partially completed move, avoids SCGI timeout on long mv operations, and recheck defaults to move_data. Move and remove jobs are ordered per profile, so a later remove waits for earlier move/remove jobs to finish.", "parameters": [{"name": "action_name", "in": "path", "required": True, "schema": {"type": "string", "enum": ["start", "pause", "stop", "resume", "recheck", "remove", "move", "set_label", "set_ratio_group"]}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"hashes": {"type": "array", "items": {"type": "string"}}, "path": {"type": "string", "description": "Target directory for move"}, "move_data": {"type": "boolean", "description": "Physically move data before setting torrent directory"}, "recheck": {"type": "boolean", "description": "Run hash check after physical move; defaults to move_data"}, "label": {"type": "string"}, "ratio_group": {"type": "string"}, "remove_data": {"type": "boolean"}}}}}}, "responses": {"200": {"description": "Job queued"}}}},
"/api/torrents/add": {"post": {"summary": "Add magnet links or torrent files", "requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"uris": {"type": "string"}, "directory": {"type": "string"}, "label": {"type": "string"}, "start": {"type": "boolean"}, "files": {"type": "array", "items": {"type": "string", "format": "binary"}}}}}, "application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "Jobs queued"}}}},
"/api/torrents/{torrent_hash}/files": {"get": {"summary": "Torrent files", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Files"}}}},
"/api/torrents/{torrent_hash}/peers": {"get": {"summary": "Torrent peers with GeoIP", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Peers"}}}},
"/api/torrents/{torrent_hash}/trackers": {"get": {"summary": "Torrent trackers", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Trackers"}}}},
"/api/speed/limits": {"post": {"summary": "Queue global speed limit change", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"down": {"type": "integer", "description": "Bytes per second, 0 unlimited"}, "up": {"type": "integer", "description": "Bytes per second, 0 unlimited"}}}}}}, "responses": {"200": {"description": "Job queued"}}}},
"/api/system/status": {"get": {"summary": "rTorrent/system status", "description": "For remote profiles CPU/RAM host usage is not returned and usage_available is false.", "responses": {"200": {"description": "Status"}}}},
"/api/port-check": {"get": {"summary": "Read cached incoming port check status", "responses": {"200": {"description": "Port check status including status, port, public_ip, source, cached, checked_at and checked_at_epoch"}}}, "post": {"summary": "Run incoming port check immediately, bypassing cache", "responses": {"200": {"description": "Fresh port check status including checked_at and checked_at_epoch"}}}},
"/api/jobs": {"get": {"summary": "List job queue history", "parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "default": 50}}, {"name": "offset", "in": "query", "schema": {"type": "integer", "default": 0}}], "responses": {"200": {"description": "Jobs"}}}},
"/api/jobs/clear": {"post": {"summary": "Clear finished job history", "description": "Deletes jobs that are not pending or running.", "responses": {"200": {"description": "Deleted count"}}}},
"/api/jobs/{job_id}/cancel": {"post": {"summary": "Cancel pending or failed job", "parameters": [{"name": "job_id", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Cancelled"}}}},
"/api/jobs/{job_id}/retry": {"post": {"summary": "Retry failed or cancelled job", "parameters": [{"name": "job_id", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Retried"}}}},
"/api/path/browse": {"get": {"summary": "Browse server directories", "parameters": [{"name": "path", "in": "query", "schema": {"type": "string"}}], "responses": {"200": {"description": "Directory listing"}}}},
"/api/labels": {"get": {"summary": "List labels", "responses": {"200": {"description": "Labels"}}}, "post": {"summary": "Create label", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "Labels"}}}},
"/api/ratio-groups": {"get": {"summary": "List ratio groups", "responses": {"200": {"description": "Ratio groups"}}}, "post": {"summary": "Create or update ratio group", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "Ratio groups"}}}},
"/api/rss": {"get": {"summary": "List RSS feeds and rules", "responses": {"200": {"description": "RSS config"}}}},
"/api/rss/feeds": {"post": {"summary": "Add RSS feed", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "RSS config"}}}},
"/api/rss/rules": {"post": {"summary": "Add RSS rule", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "RSS config"}}}},
"/api/rss/check": {"post": {"summary": "Manually check RSS feeds", "responses": {"200": {"description": "Queued matches"}}}},
"/api/smart-queue": {"get": {"summary": "Get Smart Queue settings, exceptions and history", "parameters": [{"name": "history_limit", "in": "query", "schema": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "description": "Number of Smart Queue history rows to return"}], "responses": {"200": {"description": "Smart Queue config with history and history_total"}}}, "post": {"summary": "Save Smart Queue settings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "max_active_downloads": {"type": "integer"}, "stalled_seconds": {"type": "integer"}, "min_speed_bytes": {"type": "integer"}, "min_seeds": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Saved"}}}},
"/api/smart-queue/check": {"post": {"summary": "Run Smart Queue immediately", "responses": {"200": {"description": "Smart Queue action result"}}}},
"/api/smart-queue/exclusion": {"post": {"summary": "Add or remove a torrent Smart Queue exception", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"hash": {"type": "string"}, "excluded": {"type": "boolean"}, "reason": {"type": "string"}}}}}}, "responses": {"200": {"description": "Exception list"}}}},
"/api/traffic/history": {"get": {"summary": "Transfer history for charts", "parameters": [{"name": "range", "in": "query", "schema": {"type": "string", "enum": ["15m", "1h", "3h", "6h", "24h", "7d", "30d", "90d"]}}], "responses": {"200": {"description": "Aggregated traffic history"}}}}
}
paths.update({
"/api/profiles/{profile_id}": {"delete": {"summary": "Delete rTorrent profile", "parameters": [{"name": "profile_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Deleted"}}}},
"/api/path/default": {"get": {"summary": "Read active rTorrent default download path", "responses": {"200": {"description": "Default path"}}}},
"/api/torrents/{torrent_hash}/files/priority": {"post": {"summary": "Set file priorities", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"files": {"type": "array", "items": {"type": "object", "properties": {"index": {"type": "integer"}, "priority": {"type": "integer", "enum": [0, 1, 2]}}}}}}}}}, "responses": {"200": {"description": "Updated priorities"}, "207": {"description": "Partial update"}}}},
"/api/torrents/{torrent_hash}/peers/action": {"post": {"summary": "Run peer action", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"peer_index": {"type": "integer"}, "action": {"type": "string", "enum": ["disconnect", "kick", "snub", "unsnub", "ban"]}}}}}}, "responses": {"200": {"description": "Peer action result"}}}},
"/api/labels/{label_id}": {"delete": {"summary": "Delete saved label", "parameters": [{"name": "label_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Labels"}}}},
"/api/rtorrent-config": {"get": {"summary": "Read supported rTorrent config fields", "responses": {"200": {"description": "Config fields"}}}, "post": {"summary": "Save supported rTorrent config fields", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"values": {"type": "object"}}}}}}, "responses": {"200": {"description": "Save result"}}}},
"/api/rtorrent-config/generate": {"post": {"summary": "Generate rTorrent config text from provided values", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"values": {"type": "object"}}}}}}, "responses": {"200": {"description": "Generated config text"}}}},
"/api/automations": {"get": {"summary": "List automation rules and history", "responses": {"200": {"description": "Rules and history"}}}, "post": {"summary": "Create or update automation rule", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "enabled": {"type": "boolean"}, "cooldown_minutes": {"type": "integer"}, "conditions": {"type": "array"}, "effects": {"type": "array"}}}}}}, "responses": {"200": {"description": "Rule saved"}}}},
"/api/automations/{rule_id}": {"delete": {"summary": "Delete automation rule", "parameters": [{"name": "rule_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Deleted"}}}},
"/api/automations/check": {"post": {"summary": "Run automation rules immediately", "responses": {"200": {"description": "Automation result"}}}}
})
components = {
"schemas": {
"ApiOk": {
"type": "object",
"properties": {"ok": {"type": "boolean"}},
"required": ["ok"],
},
"Profile": {
"type": "object",
"additionalProperties": True,
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"scgi_url": {"type": "string"},
"timeout_seconds": {"type": "integer"},
"max_parallel_jobs": {"type": "integer"},
},
},
"Torrent": {
"type": "object",
"additionalProperties": True,
"properties": {
"hash": {"type": "string"},
"name": {"type": "string"},
"path": {"type": "string"},
"status": {"type": "string"},
"size": {"type": "integer", "format": "int64"},
"completed_bytes": {"type": "integer", "format": "int64"},
"down_total": {"type": "integer", "format": "int64"},
"up_total": {"type": "integer", "format": "int64"},
"complete": {"type": "boolean"},
"state": {"type": "boolean"},
"paused": {"type": "boolean"},
"hashing": {"type": "integer"},
"message": {"type": "string"},
},
},
"TorrentFilterSummary": {
"type": "object",
"properties": {
"count": {"type": "integer", "description": "Number of torrents in this filter."},
"size": {"type": "integer", "format": "int64", "description": "Total torrent payload size in bytes."},
"disk_bytes": {"type": "integer", "format": "int64", "description": "Completed bytes reported by rTorrent; used as the displayed Data value."},
"completed_bytes": {"type": "integer", "format": "int64", "description": "Completed bytes reported by rTorrent."},
"remaining_bytes": {"type": "integer", "format": "int64", "description": "size - completed_bytes, never below zero."},
"progress_percent": {"type": "number", "format": "float", "description": "Completed percentage for this filter."},
"remaining_percent": {"type": "number", "format": "float", "description": "Remaining percentage for this filter."},
"down_total": {"type": "integer", "format": "int64", "deprecated": True, "description": "Backward compatibility field; not used by the filters UI."},
"up_total": {"type": "integer", "format": "int64", "deprecated": True, "description": "Backward compatibility field; not used by the filters UI."},
},
"required": ["count", "size", "disk_bytes", "completed_bytes", "remaining_bytes", "progress_percent", "remaining_percent"],
},
"TorrentSummaryFilters": {
"type": "object",
"properties": {
"all": {"$ref": "#/components/schemas/TorrentFilterSummary"},
"downloading": {"$ref": "#/components/schemas/TorrentFilterSummary"},
"seeding": {"$ref": "#/components/schemas/TorrentFilterSummary"},
"paused": {"$ref": "#/components/schemas/TorrentFilterSummary"},
"checking": {"$ref": "#/components/schemas/TorrentFilterSummary"},
"error": {"$ref": "#/components/schemas/TorrentFilterSummary"},
"stopped": {"$ref": "#/components/schemas/TorrentFilterSummary"},
},
"required": ["all", "downloading", "seeding", "paused", "checking", "error", "stopped"],
},
"TorrentSummary": {
"type": "object",
"properties": {
"filters": {"$ref": "#/components/schemas/TorrentSummaryFilters"},
"cache_ttl_seconds": {"type": "integer", "description": "Summary cache TTL in seconds."},
"generated_at_epoch": {"type": "number", "format": "double", "description": "Unix timestamp when summary was generated."},
"cached": {"type": "boolean", "description": "True when returned from cache."},
},
"required": ["filters", "cache_ttl_seconds", "generated_at_epoch", "cached"],
},
"TorrentListResponse": {
"allOf": [
{"$ref": "#/components/schemas/ApiOk"},
{"type": "object", "properties": {
"profile_id": {"type": "integer"},
"torrents": {"type": "array", "items": {"$ref": "#/components/schemas/Torrent"}},
"summary": {"$ref": "#/components/schemas/TorrentSummary"},
"error": {"type": "string", "nullable": True},
}, "required": ["torrents", "summary"]},
],
},
"CleanupSummary": {
"type": "object",
"properties": {
"jobs_total": {"type": "integer"},
"jobs_clearable": {"type": "integer"},
"smart_queue_history_total": {"type": "integer"},
"retention_days": {"type": "object", "properties": {"jobs": {"type": "integer"}, "smart_queue_history": {"type": "integer"}}},
"database": {"type": "object", "properties": {"path": {"type": "string"}, "size": {"type": "integer", "format": "int64"}, "size_h": {"type": "string"}, "error": {"type": "string"}}},
},
"required": ["jobs_total", "jobs_clearable", "smart_queue_history_total", "retention_days", "database"],
},
"CleanupResponse": {
"allOf": [
{"$ref": "#/components/schemas/ApiOk"},
{"type": "object", "properties": {"cleanup": {"$ref": "#/components/schemas/CleanupSummary"}, "deleted": {"oneOf": [{"type": "integer"}, {"type": "object"}]}}},
],
},
"PortCheckStatus": {
"type": "object",
"additionalProperties": True,
"properties": {
"status": {"type": "string", "enum": ["open", "closed", "unknown", "disabled", "error"]},
"enabled": {"type": "boolean"},
"port": {"type": "integer"},
"public_ip": {"type": "string"},
"source": {"type": "string"},
"cached": {"type": "boolean"},
"checked_at": {"type": "string", "format": "date-time"},
"checked_at_epoch": {"type": "number", "format": "double"},
"error": {"type": "string"},
},
},
"AppStatus": {
"type": "object",
"properties": {
"pytorrent": {"type": "object", "additionalProperties": True},
"cleanup": {"$ref": "#/components/schemas/CleanupSummary"},
"profile": {"$ref": "#/components/schemas/Profile"},
"scgi": {"type": "object", "nullable": True, "additionalProperties": True},
"port_check": {"$ref": "#/components/schemas/PortCheckStatus"},
"api_ms": {"type": "number", "format": "float"},
},
"required": ["pytorrent", "cleanup", "scgi", "port_check", "api_ms"],
},
"AppStatusResponse": {
"allOf": [
{"$ref": "#/components/schemas/ApiOk"},
{"type": "object", "properties": {"status": {"$ref": "#/components/schemas/AppStatus"}}, "required": ["status"]},
],
},
"JobQueuedResponse": {
"allOf": [
{"$ref": "#/components/schemas/ApiOk"},
{"type": "object", "properties": {"job_id": {"type": "string"}, "job_ids": {"type": "array", "items": {"type": "string"}}, "hash_count": {"type": "integer"}, "bulk": {"type": "boolean"}}},
],
},
"TrackerActionResponse": {
"allOf": [
{"$ref": "#/components/schemas/ApiOk"},
{"type": "object", "properties": {"result": {"type": "object", "additionalProperties": True}, "message": {"type": "string"}}},
],
},
}
}
def response_ref(schema_name: str, description: str = "OK") -> dict:
return {"description": description, "content": {"application/json": {"schema": {"$ref": f"#/components/schemas/{schema_name}"}}}}
paths["/api/torrents"]["get"]["responses"]["200"] = response_ref("TorrentListResponse", "Torrent list with cached filter summary")
paths["/api/torrents/{action_name}"]["post"]["responses"]["200"] = response_ref("JobQueuedResponse", "Job queued")
paths["/api/torrents/add"]["post"]["responses"]["200"] = response_ref("JobQueuedResponse", "Jobs queued")
paths.update({
"/api/torrents/{torrent_hash}/trackers/{action_name}": {
"post": {
"summary": "Run tracker action",
"parameters": [
{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}},
{"name": "action_name", "in": "path", "required": True, "schema": {"type": "string"}},
],
"requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}},
"responses": {"200": response_ref("TrackerActionResponse", "Tracker action result")},
}
},
"/api/app/status": {
"get": {"summary": "pyTorrent application status", "responses": {"200": response_ref("AppStatusResponse", "Application status")}}
},
"/api/cleanup/summary": {
"get": {"summary": "Cleanup summary", "responses": {"200": response_ref("CleanupResponse", "Cleanup summary")}}
},
"/api/cleanup/jobs": {
"post": {"summary": "Clear finished job history", "responses": {"200": response_ref("CleanupResponse", "Cleanup result")}}
},
"/api/cleanup/smart-queue": {
"post": {"summary": "Clear Smart Queue history", "responses": {"200": response_ref("CleanupResponse", "Cleanup result")}}
},
"/api/cleanup/all": {
"post": {"summary": "Clear all cleanup-supported history", "responses": {"200": response_ref("CleanupResponse", "Cleanup result")}}
},
})
return jsonify({"openapi": "3.0.3", "info": {"title": "pyTorrent API", "version": "0.2.0"}, "paths": paths, "components": components})

View File

@@ -0,0 +1,173 @@
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
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()
return [_rule_row(r) for r in rows]
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 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 _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 == '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 []:
ok = _condition_true(t, cond)
if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0:
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) -> bool:
cooldown = int(rule.get('cooldown_minutes') or 0)
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 _apply_effects(c: Any, profile: dict[str, Any], torrent: dict[str, Any], effects: list[dict[str, Any]]) -> list[dict[str, Any]]:
h = str(torrent.get('hash') or ''); labels = _label_names(torrent.get('label')); 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)
if path: c.call('d.directory.set', h, path); applied.append({'type': 'move', 'path': path})
elif typ == 'add_label':
label = str(eff.get('label') or '').strip()
if label and label not in labels: labels.append(label); c.call('d.custom1.set', h, _label_value(labels))
if label: applied.append({'type': 'add_label', 'label': label})
elif typ == 'remove_label':
label = str(eff.get('label') or '').strip(); labels = [x for x in labels if x != label]; c.call('d.custom1.set', h, _label_value(labels)); applied.append({'type': 'remove_label', 'label': label})
elif typ == 'set_labels':
value = _label_value(_label_names(eff.get('labels'))); c.call('d.custom1.set', h, value); labels = _label_names(value); applied.append({'type': 'set_labels', 'labels': value})
elif typ in {'pause', 'stop', 'start', 'resume', 'recheck'}:
method = {'pause':'d.pause','stop':'d.stop','start':'d.start','resume':'d.resume','recheck':'d.check_hash'}[typ]; c.call(method, h); applied.append({'type': typ})
return applied
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False) -> 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 force or int(r.get('enabled') or 0)]
if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'rules': 0}
torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; now = utcnow()
with connect() as conn:
for rule in rules:
for t in torrents:
h = str(t.get('hash') or '')
if not _conditions_match(conn, rule, profile_id, t): continue
if not force and not _cooldown_ok(conn, rule, profile_id, h): continue
try: actions = _apply_effects(c, profile, t, rule.get('effects') or [])
except Exception as exc: actions = [{'error': str(exc)}]
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))
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'], h, str(t.get('name') or ''), str(rule.get('name') or ''), json.dumps(actions), now))
applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': actions})
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied}

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,176 @@
from __future__ import annotations
from ..db import connect, utcnow, default_user_id
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",
}
def bootstrap_css_url(theme: str | None) -> str:
theme = theme if theme in BOOTSTRAP_THEMES else "default"
if theme == "default":
return "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
return f"https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/{theme}/bootstrap.min.css"
def list_profiles(user_id: int | None = None):
user_id = user_id or default_user_id()
with connect() as conn:
return conn.execute(
"SELECT * FROM rtorrent_profiles WHERE user_id=? ORDER BY is_default DESC, name COLLATE NOCASE",
(user_id,),
).fetchall()
def get_profile(profile_id: int, user_id: int | None = None):
user_id = user_id or default_user_id()
with connect() as conn:
return conn.execute(
"SELECT * FROM rtorrent_profiles WHERE id=? AND user_id=?",
(profile_id, user_id),
).fetchone()
def active_profile(user_id: int | None = None):
user_id = 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"):
row = conn.execute(
"SELECT * FROM rtorrent_profiles WHERE id=? AND user_id=?",
(pref["active_rtorrent_id"], user_id),
).fetchone()
if row:
return row
row = conn.execute(
"SELECT * FROM rtorrent_profiles WHERE user_id=? ORDER BY is_default DESC, id ASC LIMIT 1",
(user_id,),
).fetchone()
return row
def save_profile(data: dict, user_id: int | None = None):
user_id = 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(data.get("timeout_seconds") or 5)
max_parallel = int(data.get("max_parallel_jobs") or 5)
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 musi zaczynać się od 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,is_remote,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
(user_id, name, scgi_url, is_default, timeout, max_parallel, 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=? AND user_id=?", (profile_id, user_id)).fetchone()
def update_profile(profile_id: int, data: dict, user_id: int | None = None):
user_id = 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(data.get("timeout_seconds") or 5)
max_parallel = int(data.get("max_parallel_jobs") or 5)
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 musi zaczynać się od scgi://")
with connect() as conn:
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE id=? AND user_id=?", (profile_id, user_id)).fetchone()
if not row:
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=?, is_remote=?, updated_at=? WHERE id=? AND user_id=?",
(name, scgi_url, is_default, timeout, max_parallel, is_remote, now, profile_id, user_id),
)
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=? AND user_id=?", (profile_id, user_id)).fetchone()
def delete_profile(profile_id: int, user_id: int | None = None):
user_id = user_id or default_user_id()
with connect() as conn:
conn.execute("DELETE FROM rtorrent_profiles WHERE id=? AND user_id=?", (profile_id, user_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 default_user_id()
with connect() as conn:
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE id=? AND user_id=?", (profile_id, user_id)).fetchone()
if not row:
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 get_preferences(user_id: int | None = None):
user_id = user_id or default_user_id()
with connect() as conn:
return conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
def save_preferences(data: dict, user_id: int | None = None):
user_id = 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")
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))
return get_preferences(user_id)

View File

@@ -0,0 +1,47 @@
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),
"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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,312 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import json
import time
from ..config import SMART_QUEUE_LABEL
from ..db import connect, default_user_id, utcnow
from . import rtorrent
from .preferences import active_profile, get_profile
def _ts(value: str | None) -> float:
if not value:
return 0.0
try:
return datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp()
except Exception:
return 0.0
def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
return {
'user_id': user_id,
'profile_id': profile_id,
'enabled': 0,
'max_active_downloads': 5,
'stalled_seconds': 300,
'min_speed_bytes': 1024,
'min_seeds': 1,
'updated_at': utcnow(),
}
def get_settings(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 smart_queue_settings WHERE user_id=? AND profile_id=?',
(user_id, profile_id),
).fetchone()
return row or _default_settings(user_id, profile_id)
def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
current = get_settings(profile_id, user_id)
settings = {
'enabled': 1 if data.get('enabled', current.get('enabled')) else 0,
'max_active_downloads': max(1, int(data.get('max_active_downloads') or current.get('max_active_downloads') or 5)),
'stalled_seconds': max(30, int(data.get('stalled_seconds') or current.get('stalled_seconds') or 300)),
'min_speed_bytes': max(0, int(data.get('min_speed_bytes') or current.get('min_speed_bytes') or 0)),
'min_seeds': max(0, int(data.get('min_seeds') or current.get('min_seeds') or 0)),
}
now = utcnow()
with connect() as conn:
conn.execute(
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
enabled=excluded.enabled,
max_active_downloads=excluded.max_active_downloads,
stalled_seconds=excluded.stalled_seconds,
min_speed_bytes=excluded.min_speed_bytes,
min_seeds=excluded.min_seeds,
updated_at=excluded.updated_at''',
(user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], now),
)
return get_settings(profile_id, user_id)
def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
with connect() as conn:
return conn.execute(
'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC',
(user_id, profile_id),
).fetchall()
def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
now = utcnow()
with connect() as conn:
if excluded:
conn.execute(
'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)',
(user_id, profile_id, torrent_hash, reason, now),
)
else:
conn.execute(
'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?',
(user_id, profile_id, torrent_hash),
)
def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
paused = paused or []
resumed = resumed or []
details = details or {}
with connect() as conn:
conn.execute(
'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
(user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()),
)
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 smart_queue_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 count_history(profile_id: int, user_id: int | None = None) -> int:
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?',
(user_id, profile_id),
).fetchone()
return int((row or {}).get('count') or 0)
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]:
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)}
def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None:
now = utcnow()
with connect() as conn:
row = conn.execute(
'SELECT previous_label FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?',
(profile_id, torrent_hash),
).fetchone()
if row:
conn.execute(
'UPDATE smart_queue_auto_labels SET updated_at=? WHERE profile_id=? AND torrent_hash=?',
(now, profile_id, torrent_hash),
)
else:
conn.execute(
'INSERT INTO smart_queue_auto_labels(profile_id,torrent_hash,previous_label,created_at,updated_at) VALUES(?,?,?,?,?)',
(profile_id, torrent_hash, previous_label, now, now),
)
def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current_label: str | None = None) -> bool:
with connect() as conn:
row = conn.execute(
'SELECT previous_label FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?',
(profile_id, torrent_hash),
).fetchone()
if not row:
return False
previous = row.get('previous_label') or ''
try:
if current_label is None or current_label == SMART_QUEUE_LABEL:
client.call('d.custom1.set', torrent_hash, previous)
conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash))
return True
except Exception:
return False
def _set_smart_queue_label(client: Any, torrent_hash: str, attempts: int = 3) -> bool:
for attempt in range(max(1, attempts)):
try:
client.call('d.custom1.set', torrent_hash, SMART_QUEUE_LABEL)
return True
except Exception:
if attempt < attempts - 1:
time.sleep(0.05)
return False
def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) -> bool:
torrent_hash = str(torrent.get('hash') or '')
if not torrent_hash:
return False
previous = str(torrent.get('label') or '')
if previous != SMART_QUEUE_LABEL:
_remember_auto_label(profile_id, torrent_hash, previous)
return _set_smart_queue_label(client, torrent_hash)
def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str, Any]], keep_hashes: set[str]) -> list[str]:
by_hash = {str(t.get('hash') or ''): t for t in torrents}
restored: list[str] = []
with connect() as conn:
rows = conn.execute('SELECT torrent_hash FROM smart_queue_auto_labels WHERE profile_id=?', (profile_id,)).fetchall()
for row in rows:
h = str(row.get('torrent_hash') or '')
t = by_hash.get(h)
if not h or h in keep_hashes:
continue
if t is None or int(t.get('complete') or 0):
if _restore_auto_label(client, profile_id, h, None if t is None else str(t.get('label') or '')):
restored.append(h)
continue
is_paused_or_stopped = bool(t.get('paused')) or not int(t.get('active') or 0) or not int(t.get('state') or 0)
current_label = str(t.get('label') or '')
if is_paused_or_stopped:
if current_label != SMART_QUEUE_LABEL:
_set_smart_queue_label(client, h)
continue
if _restore_auto_label(client, profile_id, h, current_label):
restored.append(h)
return restored
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False) -> 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'])
settings = get_settings(profile_id, user_id)
if not force and not int(settings.get('enabled') or 0):
add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False}, user_id)
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'message': 'Smart Queue disabled'}
torrents = rtorrent.list_torrents(profile)
excluded = _excluded_hashes(profile_id, user_id)
downloading = [t for t in torrents if not int(t.get('complete') or 0) and int(t.get('state') or 0) and not t.get('paused') and t.get('hash') not in excluded]
stopped = [t for t in torrents if not int(t.get('complete') or 0) and (not int(t.get('state') or 0) or t.get('paused')) and t.get('hash') not in excluded]
min_speed = int(settings.get('min_speed_bytes') or 0)
min_seeds = int(settings.get('min_seeds') or 0)
stalled_seconds = int(settings.get('stalled_seconds') or 300)
now = utcnow()
now_ts = datetime.now(timezone.utc).timestamp()
stalled: list[dict[str, Any]] = []
with connect() as conn:
for t in downloading:
is_stalled = int(t.get('down_rate') or 0) <= min_speed and int(t.get('seeds') or 0) <= min_seeds
h = t.get('hash')
if not h:
continue
if is_stalled:
row = conn.execute('SELECT first_stalled_at FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)).fetchone()
if row:
conn.execute('UPDATE smart_queue_stalled SET updated_at=? WHERE profile_id=? AND torrent_hash=?', (now, profile_id, h))
first = row['first_stalled_at']
else:
first = now
conn.execute('INSERT OR REPLACE INTO smart_queue_stalled(profile_id,torrent_hash,first_stalled_at,updated_at) VALUES(?,?,?,?)', (profile_id, h, first, now))
if now_ts - _ts(first) >= stalled_seconds:
stalled.append(t)
else:
conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h))
# Candidates with visible sources are preferred. Do not touch excluded torrents.
candidates = sorted(
stopped,
key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)),
reverse=True,
)
max_active = max(1, int(settings.get('max_active_downloads') or 5))
stalled_hashes = {str(t.get('hash') or '') for t in stalled}
# Enforce the hard active-download cap first. The previous logic only limited
# newly resumed torrents, so already-active downloads could stay above the limit.
pause_rank = sorted(
downloading,
key=lambda t: (
0 if str(t.get('hash') or '') in stalled_hashes else 1,
int(t.get('down_rate') or 0),
int(t.get('seeds') or 0),
int(t.get('peers') or 0),
),
)
to_pause: list[dict[str, Any]] = pause_rank[:max(0, len(downloading) - max_active)]
pause_hashes = {str(t.get('hash') or '') for t in to_pause}
# When the cap is not exceeded, stalled downloads can still be rotated out
# one-for-one with better stopped candidates while staying within max_active.
if candidates:
replaceable_stalled = [t for t in stalled if str(t.get('hash') or '') not in pause_hashes]
for t in replaceable_stalled[:max(0, len(candidates) - len(to_pause))]:
to_pause.append(t)
pause_hashes.add(str(t.get('hash') or ''))
active_after_pause = max(0, len(downloading) - len(to_pause))
available_slots = max(0, max_active - active_after_pause)
to_resume = candidates[:available_slots]
c = rtorrent.client_for(profile)
paused: list[str] = []
resumed: list[str] = []
label_failed: list[str] = []
for t in to_pause:
try:
c.call('d.pause', t['hash'])
if not _mark_auto_paused(c, profile_id, t):
label_failed.append(t['hash'])
paused.append(t['hash'])
except Exception:
pass
for t in to_resume:
try:
_restore_auto_label(c, profile_id, t['hash'], str(t.get('label') or ''))
c.call('d.resume', t['hash'])
c.call('d.start', t['hash'])
resumed.append(t['hash'])
except Exception:
pass
restored = _cleanup_auto_labels(c, profile_id, torrents, set(paused))
add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'labels_restored': restored, 'labels_failed': label_failed, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after': active_after_pause + len(resumed)}, user_id)
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'labels_restored': restored, 'labels_failed': label_failed, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings}

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,55 @@
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", "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 refresh(self, profile: dict) -> dict:
profile_id = int(profile["id"])
try:
rows = rtorrent.list_torrents(profile)
fresh = {t["hash"]: t for t in rows}
with self._lock:
old = self._data.get(profile_id, {})
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}
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,130 @@
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 _matches(row: dict, summary_type: str) -> bool:
status = str(row.get("status") or "")
if summary_type == "all":
return True
if summary_type == "downloading":
return not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
if summary_type == "seeding":
return status != "Checking" and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
if summary_type == "paused":
return bool(row.get("paused")) or status == "Paused"
if summary_type == "checking":
return status == "Checking" or _number(row, "hashing") > 0
if summary_type == "error":
return _has_error(row)
if summary_type == "stopped":
return 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,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,84 @@
from __future__ import annotations
import psutil
from flask_socketio import emit
from ..config import POLL_INTERVAL
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
_started = False
def register_socketio_handlers(socketio):
global _started
def poller():
tick = 0
while True:
profile = active_profile()
if profile:
diff = torrent_cache.refresh(profile)
heartbeat = {"ok": bool(diff.get("ok")), "profile_id": profile["id"], "tick": tick, "error": diff.get("error", "")}
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
socketio.emit("torrent_patch", {**diff, "summary": cached_summary(profile["id"], torrent_cache.snapshot(profile["id"]), force=True)})
elif not diff.get("ok"):
socketio.emit("rtorrent_error", diff)
try:
status = rtorrent.system_status(profile)
if bool(profile.get("is_remote")):
status["usage_source"] = "remote-hidden"
status["usage_available"] = False
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"] = profile["id"]
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))
socketio.emit("system_stats", status)
heartbeat["ok"] = True
except Exception as exc:
heartbeat["ok"] = False
heartbeat["error"] = str(exc)
socketio.emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)})
if tick % max(1, int(30 / POLL_INTERVAL)) == 0:
try:
result = smart_queue.check(profile, force=False)
if result.get("enabled"):
socketio.emit("smart_queue_update", result)
except Exception as exc:
socketio.emit("smart_queue_update", {"ok": False, "error": str(exc)})
try:
auto_result = automation_rules.check(profile, force=False)
if auto_result.get("applied"):
socketio.emit("automation_update", auto_result)
except Exception as exc:
socketio.emit("automation_update", {"ok": False, "error": str(exc)})
socketio.emit("heartbeat", heartbeat)
tick += 1
socketio.sleep(POLL_INTERVAL)
@socketio.on("connect")
def handle_connect():
global _started
if not _started:
socketio.start_background_task(poller)
_started = True
profile = active_profile()
emit("connected", {"ok": True, "profile": profile})
if profile:
rows = torrent_cache.snapshot(profile["id"])
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows)})
@socketio.on("select_profile")
def handle_select_profile(data):
profile_id = int((data or {}).get("profile_id") or 0)
profile = get_profile(profile_id)
if not profile:
emit("rtorrent_error", {"error": "Profile does not exist"})
return
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), "error": diff.get("error", "")})

View File

@@ -0,0 +1,250 @@
from __future__ import annotations
import json
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from . import rtorrent
from .preferences import get_profile
from ..config import WORKERS
from ..db import connect, utcnow, default_user_id
_executor = ThreadPoolExecutor(max_workers=WORKERS, thread_name_prefix="pytorrent-job")
_socketio = None
_semaphores: dict[int, threading.Semaphore] = {}
_exclusive_locks: dict[int, threading.Lock] = {}
_sem_lock = threading.Lock()
def set_socketio(socketio):
global _socketio
_socketio = socketio
def _emit(name: str, payload: dict):
if _socketio:
_socketio.emit(name, payload)
def _get_sem(profile: dict) -> threading.Semaphore:
profile_id = int(profile["id"])
max_parallel = max(1, int(profile.get("max_parallel_jobs") or 3))
with _sem_lock:
if profile_id not in _semaphores:
_semaphores[profile_id] = threading.Semaphore(max_parallel)
return _semaphores[profile_id]
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 _is_ordered_action(action_name: str) -> bool:
return action_name in {"move", "remove"}
def _has_prior_ordered_jobs(profile_id: int, rowid: int) -> bool:
with connect() as conn:
row = conn.execute(
"""
SELECT 1
FROM jobs
WHERE profile_id=?
AND rowid<?
AND action IN ('move', 'remove')
AND status IN ('pending', 'running')
LIMIT 1
""",
(profile_id, rowid),
).fetchone()
return bool(row)
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
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 enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | None = None, max_attempts: int = 2) -> str:
user_id = user_id or default_user_id()
job_id = uuid.uuid4().hex
now = utcnow()
with connect() as conn:
conn.execute(
"INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
(job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, now, now),
)
_emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
_executor.submit(_run, job_id)
return job_id
def _execute(profile: dict, action_name: str, payload: dict):
if action_name == "add_magnet":
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"])
return rtorrent.add_torrent_raw(profile, raw, bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""))
if action_name == "set_limits":
return rtorrent.set_limits(profile, payload.get("down"), payload.get("up"))
hashes = payload.get("hashes") or []
return rtorrent.action(profile, hashes, action_name, payload)
def _run(job_id: str):
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, "status": "failed", "error": "profile not found"})
return
profile_id = int(profile["id"])
ordered_lock = None
if _is_ordered_action(str(job["action"])):
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)
sem.acquire()
try:
job = _job_row(job_id)
if not job or job["status"] == "cancelled":
return
payload = json.loads(job.get("payload_json") or "{}")
attempts = int(job.get("attempts") or 0) + 1
with connect() as conn:
conn.execute("UPDATE jobs SET status='running', attempts=?, started_at=COALESCE(started_at, ?), updated_at=? WHERE id=?", (attempts, utcnow(), utcnow(), job_id))
_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})
_emit("job_update", {"id": job_id, "status": "running", "attempts": attempts})
result = _execute(profile, job["action"], payload)
_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})
_emit("job_update", {"id": job_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)
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)})
_emit("job_update", {"id": job_id, "status": status, "error": str(exc), "attempts": attempts})
if status == "pending":
_executor.submit(_run, job_id)
finally:
sem.release()
if ordered_lock:
ordered_lock.release()
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 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
d["result"] = result
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)
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 list_jobs(limit: int = 200, offset: int = 0):
limit = max(1, min(int(limit or 50), 500))
offset = max(0, int(offset or 0))
with connect() as conn:
rows = conn.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, offset)).fetchall()
total = conn.execute("SELECT COUNT(*) AS n FROM jobs").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", "failed"}:
return False
_set_job(job_id, "cancelled", finished=True)
_emit("job_update", {"id": job_id, "status": "cancelled"})
return True
def clear_jobs() -> int:
with connect() as conn:
cur = conn.execute("DELETE FROM jobs WHERE status NOT IN ('pending', 'running')")
return int(cur.rowcount or 0)
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, updated_at=? WHERE id=?", (utcnow(), job_id))
_emit("job_update", {"id": job_id, "status": "pending"})
_executor.submit(_run, job_id)
return True

640
pytorrent/static/app.js Normal file
View File

@@ -0,0 +1,640 @@
(() => {
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>'"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[c]));
const ROW_HEIGHT = 34, OVERSCAN = 14;
const torrents = new Map();
let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = "all";
let sortState = {key: "name", dir: 1}, renderPending = false, renderVersion = 0, lastRenderSignature = "";
let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = "/";
const traffic = [], systemUsage = [];
const socket = io({transports:["polling"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000});
const COLUMN_DEFS = [["status","Status"],["size","Size"],["progress","Progress"],["down_rate","DL"],["up_rate","UL"],["seeds","Seeds"],["peers","Peers"],["ratio","Ratio"],["path","Path"],["label","Label"],["ratio_group","Ratio group"]];
let hiddenColumns = new Set((window.PYTORRENT?.tableColumns?.hidden || []));
let knownLabels = [];
let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false;
let peersRefreshTimer = null;
let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);
let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);
let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || "default";
let fontFamily = window.PYTORRENT?.fontFamily || "default";
let modalLabels = new Set(), defaultDownloadPath = null;
let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;
let torrentSummary = null;
let profileCache = new Map();
const activeOperations = new Map();
function toast(msg, type="secondary") { const h=$('toastHost'); if(!h) return; const el=document.createElement('div'); el.className=`toast-item text-bg-${type}`; el.innerHTML=esc(msg); h.appendChild(el); setTimeout(()=>el.remove(),3500); }
function setBusy(on){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; $('globalLoader')?.classList.toggle('d-none', pendingBusy===0); $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }
function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }
function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }
function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`<span class="spinner-border spinner-border-sm me-1"></span>Working...`:label.dataset.orig; }}
function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }
function loadingMarkup(label='Loading data...'){ return `<div class="loading-line loading-center"><span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span>${esc(label)}</span></div>`; }
function loadingTableRow(label='Loading torrents...'){ return `<tr><td colspan="13" class="empty loading-cell">${loadingMarkup(label)}</td></tr>`; }
function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }
function formatDate(value, mode='short'){
const parsed=parseDate(value);
if(!parsed) return String(value||'');
const opts=mode==='full'
? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}
: {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};
return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');
}
function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `<span class="date-compact" title="${esc(formatDate(value,'full'))}">${esc(formatDate(value))}</span>`; }
function compactCell(value, max=120){ const text=String(value||""); if(!text) return ""; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}${text.slice(-Math.floor(max*0.28))}` : text; return `<span class="text-compact" title="${esc(text)}">${esc(short)}</span>`; }
function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return `<div class="progress torrent-progress${done}${cls}" title="${esc(pct)}%"><div class="progress-bar" style="width:${pct}%;background:${bg}"></div><span>${esc(pct)}%</span></div>`; }
function progress(t){ return progressBar(t.progress); }
// Note: Displays status filter summaries calculated and cached by the backend API.
const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', stopped:'countStopped'};
function formatFilterBytes(value){ return fmtBytes(value).replace(/\.0 (?=GiB|TiB)/, ' '); }
function filterMetaLine(bucket){
if(!bucket || !Number(bucket.count||0)) return '';
const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);
return `Data ${formatFilterBytes(disk)}`;
}
function filterNeedsDownloadDetails(type, bucket){
if(!bucket || !Number(bucket.count||0)) return false;
if(type==='downloading') return true;
if(type!=='paused' && type!=='stopped') return false;
const size=Number(bucket.size||0);
const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);
const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));
const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));
return size > 0 && remaining > 0 && progress < 100;
}
function filterTooltipLine(bucket, type){
if(!bucket || !Number(bucket.count||0)) return '';
const size=Number(bucket.size||0);
const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);
const completed=Number(bucket.completed_bytes ?? disk);
const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));
const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));
const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));
const lines=[`Data: ${formatFilterBytes(disk)}`];
if(filterNeedsDownloadDetails(type, bucket)){
lines.push(`Total to download: ${formatFilterBytes(size)}`);
lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);
lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);
}
return lines.join('\n');
}
function setFilterSummary(type){
const el=$(FILTER_COUNT_IDS[type]);
if(!el) return;
const bucket=torrentSummary?.filters?.[type] || {count:0};
const meta=filterMetaLine(bucket, type);
const tooltip=filterTooltipLine(bucket, type);
el.innerHTML=`<span class="filter-count">${esc(bucket.count||0)}</span>${meta?`<span class="filter-meta">${esc(meta)}</span>`:''}`;
const button=el.closest('.filter');
if(button){
if(tooltip){
button.title=tooltip;
button.setAttribute('aria-label', `${button.dataset.filter || type}: ${tooltip.replace(/\n/g, ', ')}`);
} else {
button.removeAttribute('title');
button.removeAttribute('aria-label');
}
}
}
function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }
function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }
function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }
function torrentHasError(t){ return !!torrentWarning(t); }
function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && ![t.name,t.path,t.label,t.hash,t.ratio_group].join(' ').toLowerCase().includes(q)) return false; if(activeFilter==='downloading') return !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return t.status!=='Checking' && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return t.status==='Checking' || Number(t.hashing||0)>0; if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state; if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); return true; }
function compareRows(a,b){ const k=sortState.key; let av=a[k], bv=b[k]; if(typeof av==='string'||typeof bv==='string') return String(av||'').localeCompare(String(bv||''))*sortState.dir; return ((Number(av||0)>Number(bv||0))?1:(Number(av||0)<Number(bv||0)?-1:0))*sortState.dir; }
function sortIcon(key){ if(sortState.key!==key) return ''; return sortState.dir>0?" <i class='fa-solid fa-caret-up'></i>":" <i class='fa-solid fa-caret-down'></i>"; }
function updateSortHeaders(){ document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{ const base=th.dataset.baseText||th.textContent.trim(); th.dataset.baseText=base; th.innerHTML=`${esc(base)}${sortIcon(th.dataset.sort)}`; th.classList.toggle('sorted',sortState.key===th.dataset.sort); }); }
// Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.
function renderCounts(){
Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);
$('statSelected').textContent=selected.size;
}
function renderLabelFilters(){ const box=$('labelFilters'); if(!box) return; const counts=new Map(); [...torrents.values()].forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b)); if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))) activeFilter='all'; box.innerHTML=labels.length?`<div class="small text-muted px-2 mb-1">Labels</div>${labels.map(l=>`<button class="filter label-filter ${activeFilter==='label:'+l?'active':''}" data-filter="label:${esc(l)}"><span><i class="fa-solid fa-tag"></i> ${esc(l)}</span><span>${counts.get(l)}</span></button>`).join('')}`:''; box.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); }
function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }
function applyColumnVisibility(){ document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col))); }
function actionLabel(action){
const labels={start:'Starting',pause:'Pausing',stop:'Stopping',resume:'Resuming',recheck:'Checking',reannounce:'Reannouncing',remove:'Removing',move:'Moving',set_label:'Setting label',set_ratio_group:'Setting ratio'};
return labels[action] || `Working: ${action}`;
}
function actionIcon(action){
return ({start:'fa-play',pause:'fa-pause',stop:'fa-stop',resume:'fa-play',recheck:'fa-rotate',reannounce:'fa-bullhorn',remove:'fa-trash',move:'fa-folder-open',set_label:'fa-tag',set_ratio_group:'fa-scale-balanced'}[action]) || 'fa-gears';
}
function markTorrentOperation(hashes, action, jobId, state='queued'){
const label=actionLabel(action);
[...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));
scheduleRender(true);
}
function clearJobOperation(jobId, hashes=[]){
if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }
(hashes||[]).forEach(hash=>activeOperations.delete(hash));
scheduleRender(true);
}
function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }
function statusMeta(t){
const op=activeOperationFor(t);
if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};
const status=String(t.status||'').toLowerCase();
if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};
if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};
if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};
if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};
if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};
return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};
}
function statusBadge(t){ const m=statusMeta(t); return `<span class="badge status-badge ${m.cls}"><i class="fa-solid ${m.icon} me-1"></i>${esc(m.label || t.status)}</span>`; }
function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }
function torrentNameIcon(t){ const m=statusMeta(t); return `<i class="fa-solid ${m.icon} ${m.color}"></i>`; }
function renderRow(t){ const labels=labelNames(t.label).map(l=>`<span class="chip label-mini"><i class="fa-solid fa-tag"></i> ${esc(l)}</span>`).join(' '); const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\n'); return `<tr data-hash="${esc(t.hash)}" class="${classes}"><td data-col="select" class="sel"><input class="row-check" type="checkbox" ${selected.has(t.hash)?'checked':''}></td><td data-col="name" class="name" title="${esc(title)}">${warn?'<i class="fa-solid fa-triangle-exclamation torrent-warning-icon"></i> ':''}${torrentNameIcon(t)} ${esc(t.name)}</td><td data-col="status">${statusBadge(t)}</td><td data-col="size">${esc(t.size_h)}</td><td data-col="progress">${progress(t)}</td><td data-col="down_rate">${esc(t.down_rate_h)}</td><td data-col="up_rate">${esc(t.up_rate_h)}</td><td data-col="seeds">${esc(t.seeds)}</td><td data-col="peers">${esc(t.peers)}</td><td data-col="ratio">${esc(t.ratio)}</td><td data-col="path" class="path" title="${esc(t.path)}">${esc(t.path)}</td><td data-col="label">${labels||'<span class="text-muted">-</span>'}</td><td data-col="ratio_group">${esc(t.ratio_group||'')}</td></tr>`; }
function mobileFilterDefs(){ const arr=[...torrents.values()]; const f=torrentSummary?.filters||{}; const defs=[['all','All',f.all?.count??0],['downloading','Downloading',f.downloading?.count??0],['seeding','Seeding',f.seeding?.count??0],['paused','Paused',f.paused?.count??0],['checking','Checking',f.checking?.count??0],['error','With error',f.error?.count??0],['stopped','Stopped',f.stopped?.count??0]]; const counts=new Map(); arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label'])); return defs; }
function renderMobileFilters(){ const bar=$('mobileFilterBar'); if(!bar) return; const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); const someVisible=visibleRows.some(t=>selected.has(t.hash)); const opts=mobileFilterDefs().map(([key,label,count,type])=>`<option value="${esc(key)}" ${activeFilter===key?'selected':''}>${type==='label'?'Label: ':''}${esc(label)} (${count})</option>`).join(''); bar.innerHTML=`<div class="mobile-filter-actions"><button id="mobileSelectAll" class="btn btn-xs ${allVisible?'btn-primary':'btn-outline-primary'}" type="button"><i class="fa-solid fa-check-double"></i> ${allVisible?'Unselect all':'Select all'}</button><button id="mobileClearSelection" class="btn btn-xs btn-outline-secondary" type="button" ${someVisible?'':'disabled'}><i class="fa-solid fa-xmark"></i> Clear</button><span>${selected.size} selected</span></div><div class="mobile-filter-select-row"><label for="mobileFilterSelect"><i class="fa-solid fa-filter"></i> Filter</label><select id="mobileFilterSelect" class="form-select form-select-sm">${opts}</select></div>`; }
function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); return `<div class="mobile-card ${classes}" data-hash="${esc(t.hash)}" title="${esc(warn||op?.label||'')}"><div class="name">${warn?'<i class="fa-solid fa-triangle-exclamation torrent-warning-icon"></i> ':''}${torrentNameIcon(t)} ${esc(t.name)}</div><div class="small text-muted">${statusBadge(t)} · ${esc(t.progress)}% · Ratio ${esc(t.ratio)}</div><div class="small">DL ${esc(t.down_rate_h)} / UL ${esc(t.up_rate_h)}</div><div class="small text-truncate">${esc(t.path)}</div><div class="mobile-actions"><button class="btn btn-xs btn-outline-success" data-action="start"><i class="fa-solid fa-play"></i></button><button class="btn btn-xs btn-outline-warning" data-action="pause"><i class="fa-solid fa-pause"></i></button><button class="btn btn-xs btn-outline-secondary" data-action="stop"><i class="fa-solid fa-stop"></i></button></div><div class="mobile-progress">${progress(t)}</div></div>`; }).join('') || (hasTorrentSnapshot ? `<div class="empty">No torrents.</div>` : loadingMarkup('Loading torrents...')); }
function renderTable(){ updateBulkBar(); renderCounts(); renderLabelFilters(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?'<tr><td colspan="13" class="empty">No torrents for this filter.</td></tr>':loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/ROW_HEIGHT)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/ROW_HEIGHT)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*ROW_HEIGHT,bottom=Math.max(0,(visibleRows.length-end)*ROW_HEIGHT); body.innerHTML=(top?`<tr class="virtual-spacer"><td colspan="13" style="height:${top}px"></td></tr>`:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?`<tr class="virtual-spacer"><td colspan="13" style="height:${bottom}px"></td></tr>`:''); applyColumnVisibility(); }
function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }
function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }
function selectedHashes(){ return [...selected]; }
function updateBulkBar(){ const bar=$("bulkBar"); if(!bar) return; bar.classList.toggle("d-none", selected.size<=1); const c=$("bulkSelectedCount"); if(c) c.textContent=selected.size; }
function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }
async function post(url,data,method='POST'){ const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data||{})}); const json=await res.json(); if(!json.ok) throw new Error(json.error||'Operation failed'); return json; }
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); markTorrentOperation(hashes, action, j.job_id, 'queued'); 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); } toast(`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
function flag(iso){ const code=String(iso||'').toLowerCase(); return code?`<span class="fi fi-${esc(code)}"></span> <span>${esc(code.toUpperCase())}</span>`:'-'; }
function table(headers,rows){ return `<table class="table table-sm detail-table"><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>`; }
function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>`<span class="chip label-mini"><i class="fa-solid fa-tag"></i> ${esc(l)}</span>`).join(' '):''; $('detailPane').innerHTML=t?`<div class="general-grid"><div><b>Name</b><span>${esc(t.name)}</span></div><div><b>Hash</b><span>${esc(t.hash)}</span></div><div><b>Path</b><span>${esc(t.path)}</span></div><div><b>Size</b><span>${esc(t.size_h)}</span></div><div><b>Progress</b><span>${esc(t.progress)}%</span></div><div><b>Ratio</b><span>${esc(t.ratio)}</span></div><div><b>Downloaded</b><span>${esc(t.down_total_h)}</span></div><div><b>Uploaded</b><span>${esc(t.up_total_h)}</span></div><div><b>Labels</b><span>${labels||'<span class="text-muted">-</span>'}</span></div><div><b>Ratio group</b><span>${esc(t.ratio_group||'')}</span></div></div>`:'Select a torrent.'; }
const FILE_PRIORITY_LABELS = {0: "Skip", 1: "Normal", 2: "High"};
function priorityClass(priority){ priority=Number(priority||0); return priority===2?"text-bg-success":priority===0?"text-bg-secondary":"text-bg-primary"; }
function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return `<select class="form-select form-select-sm file-priority" data-index="${esc(f.index)}"><option value="0" ${p===0?"selected":""}>Skip</option><option value="1" ${p===1?"selected":""}>Normal</option><option value="2" ${p===2?"selected":""}>High</option></select>`; }
function renderFiles(files){
const pane=$('detailPane');
const rows=(files||[]).map(f=>`<tr data-file-index="${esc(f.index)}"><td class="sel"><input class="file-check" type="checkbox" data-index="${esc(f.index)}"></td><td class="path" title="${esc(f.path)}">${esc(f.path)}</td><td>${esc(f.size_h)}</td><td>${esc(f.progress??0)}%</td><td><span class="badge ${priorityClass(f.priority)}">${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}</span></td><td>${renderFilePrioritySelect(f)}</td></tr>`).join('');
pane.innerHTML=`<div class="files-toolbar"><div class="btn-group btn-group-sm"><button class="btn btn-outline-secondary file-priority-bulk" data-priority="0"><i class="fa-solid fa-ban"></i> Skip selected</button><button class="btn btn-outline-primary file-priority-bulk" data-priority="1"><i class="fa-solid fa-bars"></i> Normal selected</button><button class="btn btn-outline-success file-priority-bulk" data-priority="2"><i class="fa-solid fa-arrow-up"></i> High selected</button></div><span class="small text-muted">Changes are applied immediately in rTorrent.</span></div><table class="table table-sm detail-table file-priority-table"><thead><tr><th><input id="fileSelectAll" type="checkbox"></th><th>Path</th><th>Size</th><th>Done</th><th>Priority</th><th>Set</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty">No files.</td></tr>'}</tbody></table>`;
}
async function setFilePriorities(items){
if(!selectedHash || !items.length) return;
setBusy(true);
try{
const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});
const j=await res.json();
if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');
toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');
await loadDetails('files');
}catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }
}
function peerBadges(p){
const badges=[];
if(p.encrypted) badges.push('<span class="badge text-bg-success">enc</span>');
if(p.incoming) badges.push('<span class="badge text-bg-info">in</span>');
if(p.snubbed) badges.push('<span class="badge text-bg-warning">snub</span>');
if(p.banned) badges.push('<span class="badge text-bg-danger">ban</span>');
return badges.join(' ') || '<span class="text-muted">-</span>';
}
function renderPeers(peers){
const rows=(peers||[]).map(p=>[flag(p.country_iso),esc(p.ip),esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p),`<div class="peer-actions"><button class="btn btn-xs btn-outline-warning peer-action" data-peer-index="${esc(p.index)}" data-peer-action="disconnect" title="Kick peer"><i class="fa-solid fa-user-slash"></i><span>Kick</span></button><button class="btn btn-xs btn-outline-secondary peer-action" data-peer-index="${esc(p.index)}" data-peer-action="snub" title="Snub peer"><i class="fa-solid fa-volume-xmark"></i><span>Snub</span></button><button class="btn btn-xs btn-outline-primary peer-action" data-peer-index="${esc(p.index)}" data-peer-action="unsnub" title="Unsnub peer"><i class="fa-solid fa-volume-high"></i><span>Unsnub</span></button><button class="btn btn-xs btn-outline-danger peer-action" data-peer-index="${esc(p.index)}" data-peer-action="ban" title="Ban peer if supported"><i class="fa-solid fa-ban"></i><span>Ban</span></button></div>`]);
$('detailPane').innerHTML=table(['Flag','IP','Country','City','Client','%','DL','UL','Port','Flags','Actions'],rows);
}
async function peerAction(index, action){
if(!selectedHash) return;
setBusy(true);
try{
const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/peers/action`,{peer_index:Number(index),action});
toast(j.message || `Peer ${action} done`,'success');
await loadDetails('peers');
}catch(e){ toast(e.message,'danger'); }
finally{ setBusy(false); }
}
function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }
function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? "-"} / ${t.peers ?? "-"}` : "-"; }
function renderTrackers(trackers){
const pane=$('detailPane');
const rows=(trackers||[]).map(t=>{
const idx=esc(t.index), url=esc(t.url);
return [`<span class="text-muted">#${idx}</span>`, `<div class="tracker-url-view" data-tracker-index="${idx}"><span class="tracker-url-text">${url || '<span class="text-muted">-</span>'}</span></div><div class="tracker-url-edit d-none" data-tracker-index="${idx}"><input class="form-control form-control-sm tracker-url" data-tracker-index="${idx}" value="${url}"></div>`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `<div class="tracker-actions"><button class="btn btn-xs btn-outline-secondary tracker-edit-start" data-index="${idx}"><i class="fa-solid fa-pen"></i> Edit</button><button class="btn btn-xs btn-outline-primary tracker-edit-save d-none" data-index="${idx}"><i class="fa-solid fa-floppy-disk"></i> Save</button><button class="btn btn-xs btn-outline-secondary tracker-edit-cancel d-none" data-index="${idx}"><i class="fa-solid fa-xmark"></i> Cancel</button></div>`];
});
pane.innerHTML=`<div class="tracker-toolbar"><div class="input-group input-group-sm"><input id="trackerAddUrl" class="form-control" placeholder="https://tracker.example/announce"><button id="trackerAddBtn" class="btn btn-outline-primary"><i class="fa-solid fa-plus"></i> Add tracker</button></div><button id="trackerReannounceBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-bullhorn"></i> Reannounce</button></div>${table(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '<span class="text-muted">-</span>','<span class="text-muted">No trackers.</span>','','','','','' ]])}`;
}
function setTrackerEdit(index,on){ const sel=String(index); document.querySelector(`.tracker-url-view[data-tracker-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', on); document.querySelector(`.tracker-url-edit[data-tracker-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); document.querySelector(`.tracker-edit-start[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', on); document.querySelector(`.tracker-edit-save[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); document.querySelector(`.tracker-edit-cancel[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); }
async function trackerAction(action,payload={}){
if(!selectedHash) return toast('No torrent selected','warning');
setBusy(true);
try{
const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);
toast(j.message || `Tracker ${action} done`,'success');
await loadDetails('trackers');
}catch(e){toast(e.message,'danger');}
finally{setBusy(false);}
}
async function loadDetails(tab){ const t=torrents.get(selectedHash); if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers'); setupPeersRefresh(tab); if(!t)return; if(tab==='general') return renderGeneral(); if(tab==='log'){ $('detailPane').innerHTML=`<pre>${esc(t.message||'No logs')}</pre>`; return; } const pane=$('detailPane'); pane.innerHTML=`<div class="loading-line"><span class="spinner-border spinner-border-sm"></span> Loading ${esc(tab)}...</div>`; try{ const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`,{headers:{'Accept':'application/json'}}); const text=await res.text(); let json; try{ json=JSON.parse(text); }catch(parseErr){ throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`); } if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`); if(tab!==activeTab()) return; if(tab==='files') renderFiles(json.files||[]); if(tab==='peers') renderPeers(json.peers||[]); if(tab==='trackers') renderTrackers(json.trackers||[]); }catch(e){pane.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`;} }
function copyText(text){
text=String(text ?? '');
if(navigator.clipboard && window.isSecureContext){
return navigator.clipboard.writeText(text);
}
return new Promise((resolve,reject)=>{
const ta=document.createElement('textarea');
ta.value=text; ta.setAttribute('readonly','');
ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';
document.body.appendChild(ta); ta.focus(); ta.select();
try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }
catch(e){ reject(e); }
finally{ ta.remove(); }
});
}
function copySelected(field){
const t=torrents.get(selectedHash);
if(!t) return toast('No torrent selected','warning');
const value=String(t[field] ?? '');
if(!value) return toast(`No ${field} to copy`,'warning');
copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));
}
async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }
async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }
async function openPathPicker(target){ pathTarget=target; const def=await getDefaultDownloadPath(); const initial=def || ($(target)?.value||'/'); $('moveOptions')?.classList.toggle('d-none', target!=='move'); if($('moveDataPhysical')) $('moveDataPhysical').checked=true; if($('moveRecheck')) $('moveRecheck').checked=true; new bootstrap.Modal($('pathModal')).show(); browsePath(initial); }
async function browsePath(path){ $('pathList').innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading...'; try{ const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`); const j=await res.json(); if(!j.ok) throw new Error(j.error); $('pathCurrent').value=j.path; lastPathParent=j.parent; $('pathList').innerHTML=j.dirs.map(d=>`<div class="path-row" data-path="${esc(d.path)}"><i class="fa-solid fa-folder"></i><span>${esc(d.name)}</span></div>`).join('')||'<div class="p-3 text-muted">No directories.</div>'; }catch(e){$('pathList').innerHTML=`<div class="text-danger p-2">${esc(e.message)}</div>`;} }
$('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);}); $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent)); $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathSelectBtn')?.addEventListener('click',async()=>{const p=$('pathCurrent').value; if(pathTarget==='move'){ const hashes=selectedHashes(); const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)}); markTorrentOperation(hashes,'move',j.job_id,'queued'); toast($('moveDataPhysical')?.checked?'physical move queued':'move queued','success'); } else if($(pathTarget)) $(pathTarget).value=p; bootstrap.Modal.getInstance($('pathModal'))?.hide();}); document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));
function renderColumnManager(){ const box=$('columnManager'); if(!box) return; box.innerHTML=COLUMN_DEFS.map(([key,label])=>`<label class="column-card ${hiddenColumns.has(key)?'':'active'}"><input class="form-check-input column-toggle" type="checkbox" data-col-key="${esc(key)}" ${hiddenColumns.has(key)?'':'checked'}><span><i class="fa-solid fa-table-columns"></i> ${esc(label)}</span></label>`).join(''); }
$('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[...hiddenColumns]})}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); });
$('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns.clear(); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[]})}).catch(()=>{}); });
async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `<span class="badge text-bg-info">bulk</span><br><span class="text-muted">${esc(count)} torrent(s), details hidden</span>`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('<br>') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`<span class="badge text-bg-${r.status==='done'?'success':r.status==='failed'?'danger':r.status==='running'?'primary':r.status==='cancelled'?'secondary':'warning'}">${esc(r.status)}</span>`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),`<button class="btn btn-xs btn-outline-primary job-retry" data-id="${esc(r.id)}"><i class="fa-solid fa-rotate-left"></i> retry</button> <button class="btn btn-xs btn-outline-danger job-cancel" data-id="${esc(r.id)}"><i class="fa-solid fa-ban"></i> cancel</button>`])); renderJobsPager(); }
function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`<div class="d-flex align-items-center gap-2 flex-wrap"><button class="btn btn-sm btn-outline-secondary" id="jobsPrev" ${jobsPage<=0?'disabled':''}><i class="fa-solid fa-chevron-left"></i> Prev</button><span class="small text-muted">Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs</span><button class="btn btn-sm btn-outline-secondary" id="jobsNext" ${jobsPage>=pages-1?'disabled':''}>Next <i class="fa-solid fa-chevron-right"></i></button></div>`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }
$('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-cancel')) await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); loadJobs(); });
$('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toast(`Cleared ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });
async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`<div class="label-manager-row"><span class="chip"><i class="fa-solid fa-tag"></i> ${esc(l.name)}</span><button class="btn btn-xs btn-outline-danger delete-label" data-id="${esc(l.id)}" title="Delete label"><i class="fa-solid fa-trash"></i></button></div>`).join(''):'<span class="text-muted">No labels.</span>'; }
function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class="chip label-selected" data-label="${esc(l)}" title="Remove"><i class="fa-solid fa-tag"></i> ${esc(l)} <i class="fa-solid fa-xmark ms-1"></i></button>`).join('') || '<span class="text-muted small">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class="chip label-chip ${modalLabels.has(l.name)?'active':''}" data-label="${esc(l.name)}"><i class="fa-solid fa-tag"></i> ${esc(l.name)}</button>`).join('') || '<span class="text-muted small">No saved labels.</span>'; }
async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }
async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value="${esc(g.name)}">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=table(['Name','Min','Max','Seed min','Action','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes),esc(g.action),g.enabled?'yes':'no'])); }
$('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });
$('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });
$('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });
$('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });
$('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });
$('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });
$('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });
$('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value}); loadRatios(); });
async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[]; if($('rssManager')) $('rssManager').innerHTML=`<h6>Feeds</h6>${table(['Name','URL','Last error'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.last_error||'')]))}<h6 class="mt-3">Rules</h6>${table(['Name','Pattern','Path','Label'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.save_path),esc(r.label)]))}`; }
async function loadSmartQueue(){ if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); const historyLimit=smartHistoryExpanded?100:10; const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); if(!j.ok) return; const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; const totalHistory=Number(j.history_total ?? hist.length); if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartManager')) $('smartManager').innerHTML=ex.length?table(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),`<button class="btn btn-xs btn-outline-danger smart-unexclude" data-hash="${esc(x.torrent_hash)}"><i class="fa-solid fa-xmark"></i> remove exception</button>`])):'<div class="empty-mini"><i class="fa-solid fa-circle-info"></i> No Smart Queue exceptions. Select torrents and use <b>Exclude selected</b> to keep them outside the queue.</div>'; if($('smartHistory')) { const body=hist.length?table(['Time','Event','Checked','Paused','Resumed'],hist.map(h=>[dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(h.paused_count||0),esc(h.resumed_count||0)])):'<div class="empty-mini">No Smart Queue operations yet.</div>'; const canToggle=totalHistory>10; const toggle=canToggle?`<button id="smartHistoryToggle" class="btn btn-xs btn-outline-secondary mt-2">${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)})</button>`:''; $('smartHistory').innerHTML=`${body}${toggle}`; } }
async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value}); toast('Smart Queue saved','success'); await loadSmartQueue(); }
function normalizeRtConfigValue(value, type='text'){
const raw=String(value ?? '').trim();
if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';
if(type==='number'){
if(raw==='') return '0';
const normalized=Number(raw.replace(',', '.'));
return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;
}
return raw;
}
function rtConfigInputValue(input){
return normalizeRtConfigValue(input.value, input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text');
}
function rtConfigOriginalValue(input){
const key=input.dataset.key;
return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');
}
function collectRtConfigChanges(){
const values={};
document.querySelectorAll('.rt-config-input').forEach(input=>{
if(input.disabled) return;
const cur=rtConfigInputValue(input);
const orig=rtConfigOriginalValue(input);
if(cur!==orig) values[input.dataset.key]=cur;
});
return values;
}
function collectRtConfigClearKeys(){
const keys=[];
document.querySelectorAll('.rt-config-input').forEach(input=>{
if(input.disabled || input.dataset.saved!=='true') return;
const cur=rtConfigInputValue(input);
const orig=rtConfigOriginalValue(input);
if(cur===orig) keys.push(input.dataset.key);
});
return keys;
}
function updateRtConfigDirty(){
const changed=collectRtConfigChanges();
const clearKeys=collectRtConfigClearKeys();
document.querySelectorAll('.rt-config-input').forEach(input=>{
const row=input.closest('.rt-config-row');
if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));
});
const configChanges=Object.keys(changed).length;
const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;
const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);
if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';
if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;
if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;
}
async function loadRtConfig(){
const box=$('rtConfigManager');
if(!box)return;
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading config...';
try{
const j=await (await fetch('/api/rtorrent-config')).json();
if(!j.ok) throw new Error(j.error||'Config load failed');
const fields=j.config?.fields||[];
rtConfigOriginal=new Map();
rtConfigFieldTypes=new Map();
rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;
let lastGroup='';
const html=fields.map(f=>{
const group=f.group||'Other';
const head=group!==lastGroup?`<div class="rt-config-group">${esc(group)}</div>`:'';
lastGroup=group;
const disabled=(!f.ok||f.readonly)?'disabled':'';
const type=['bool','number'].includes(f.type)?f.type:'text';
const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);
const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);
rtConfigOriginal.set(f.key, originalValue);
rtConfigFieldTypes.set(f.key, type);
const note=f.ok?(f.readonly?' · read only':(f.saved?' · saved override · reference kept':'')):' · unavailable';
const valueNote=f.saved?`<small class="rt-config-value-note">Reference: ${esc(originalValue)} → saved: ${esc(displayValue)}</small>`:'';
const originalAttr=esc(originalValue);
const input=type==='bool'
? `<select class="form-select form-select-sm rt-config-input" data-key="${esc(f.key)}" data-type="bool" data-original="${originalAttr}" data-saved="${f.saved?'true':'false'}" ${disabled}><option value="0" ${displayValue==='0'?'selected':''}>Off</option><option value="1" ${displayValue==='1'?'selected':''}>On</option></select>`
: `<input class="form-control form-control-sm rt-config-input" data-key="${esc(f.key)}" data-type="${esc(type)}" data-original="${originalAttr}" data-saved="${f.saved?'true':'false'}" type="${type==='number'?'number':'text'}" value="${esc(displayValue)}" placeholder="${esc(f.placeholder||'')}" ${disabled}>`;
return `${head}<label class="rt-config-row ${f.ok?'':'disabled'} ${f.changed?'changed-live':''}"><span><b>${esc(f.label)}</b><small>${esc(f.key)}${note}</small>${valueNote}</span>${input}</label>`;
}).join('');
box.innerHTML=`<div class="rt-config-grid">${html}</div>`;
if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;
updateRtConfigDirty();
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
}
async function saveRtConfig(){
const values=collectRtConfigChanges();
const clear_keys=collectRtConfigClearKeys();
clear_keys.forEach(key=>{
const input=document.querySelector(`.rt-config-input[data-key="${CSS.escape(key)}"]`);
if(input) values[key]=rtConfigOriginalValue(input);
});
setBusy(true);
try{
const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});
toast(`rTorrent config saved (${j.result?.updated?.length||0})`,'success');
await loadRtConfig();
}catch(e){
toast(e.message,'danger');
} finally{
setBusy(false);
}
}
async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }
function bootstrapThemeUrl(theme){ return theme && theme !== "default" ? `https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/${encodeURIComponent(theme)}/bootstrap.min.css` : "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"; }
function applyBootstrapTheme(theme){ bootstrapTheme = theme || "default"; const link=$("bootstrapThemeStylesheet"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($("bootstrapThemeSelect")) $("bootstrapThemeSelect").value = bootstrapTheme; }
function applyFontFamily(font){ fontFamily = font || "default"; document.documentElement.dataset.appFont = fontFamily; if($("fontFamilySelect")) $("fontFamilySelect").value = fontFamily; }
async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } }
function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }
function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia("(max-width: 900px)").matches; document.body.classList.toggle("mobile-mode", auto || document.body.classList.contains("mobile-mode-manual")); scheduleRender(true); }
function automationCondition(){ const type=$('autoConditionType')?.value||'completed'; const cond={type}; if(type==='no_seeds'){cond.seeds=Number($('autoCondSeeds')?.value||0);cond.minutes=Number($('autoCondMinutes')?.value||0);} if(type==='ratio_gte')cond.ratio=Number($('autoCondRatio')?.value||1); if(type==='label_missing'||type==='label_has')cond.label=$('autoCondLabel')?.value||''; if(type==='status')cond.status=$('autoCondStatus')?.value||'Seeding'; if(type==='path_contains')cond.text=$('autoCondText')?.value||''; return cond; }
function automationEffect(){ const type=$('autoEffectType')?.value||'add_label'; const eff={type}; if(type==='move')eff.path=$('autoEffectPath')?.value||''; if(type==='add_label'||type==='remove_label')eff.label=$('autoEffectLabel')?.value||''; if(type==='set_labels')eff.labels=$('autoEffectLabels')?.value||''; return eff; }
function updateAutomationForm(){ const ct=$('autoConditionType')?.value||''; document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct))); const et=$('autoEffectType')?.value||''; document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et))); }
function ruleSummary(r){ const cs=(r.conditions||[]).map(c=>c.type==='no_seeds'?`no seeds <=${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed').join(' + '); const es=(r.effects||[]).map(e=>e.type==='move'?`move to ${e.path||'default path'}`:e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type).join(' + '); return `${cs}${es}`; }
async function loadAutomations(){ const j=await (await fetch('/api/automations')).json(); const rules=j.rules||[], hist=j.history||[]; if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>`<div class="automation-row"><div><b>${esc(r.name)}</b> ${r.enabled?'<span class="badge text-bg-success">on</span>':'<span class="badge text-bg-secondary">off</span>'}<div class="small text-muted">${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min</div></div><button class="btn btn-xs btn-outline-danger automation-delete" data-id="${esc(r.id)}"><i class="fa-solid fa-trash"></i></button></div>`).join(''):'<div class="empty-mini">No automation rules.</div>'; if($('automationHistory')) $('automationHistory').innerHTML=hist.length?table(['Time','Rule','Torrent','Actions'],hist.map(h=>[esc(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),esc(h.actions_json||'')])):'<div class="empty-mini">No automation history yet.</div>'; }
async function saveAutomation(){ const payload={name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions:[automationCondition()],effects:[automationEffect()]}; setBusy(true); try{ await post('/api/automations',payload); toast('Automation rule saved','success'); await loadAutomations(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
function cleanupCountCard(label, value, note=''){
return `<div class="cleanup-card"><b>${esc(label)}</b><span>${esc(value ?? 0)}</span>${note?`<small>${esc(note)}</small>`:''}</div>`;
}
function renderCleanup(data={}){
const box=$('cleanupManager'); if(!box) return;
const retention=data.retention_days||{};
const db=data.database||{};
const cards=[
cleanupCountCard('Job logs total', data.jobs_total, `retention ${retention.jobs||'-'} days`),
cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),
cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, `retention ${retention.smart_queue_history||'-'} days`),
cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')
];
box.innerHTML=`<div class="cleanup-grid">${cards.join('')}</div><div class="cleanup-actions mt-3"><button id="cleanupJobsBtn" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i> Clear job logs</button><button id="cleanupSmartQueueBtn" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i> Clear Smart Queue logs</button><button id="cleanupAllBtn" class="btn btn-sm btn-danger"><i class="fa-solid fa-broom"></i> Clear both</button><button id="cleanupRefreshBtn" class="btn btn-sm btn-outline-secondary"><i class="fa-solid fa-rotate"></i> Refresh</button></div><div class="tool-note mt-2">Job cleanup uses the existing job endpoint logic, so pending and running jobs are preserved.</div>`;
}
async function loadCleanup(){
const box=$('cleanupManager'); if(!box) return;
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading cleanup data...';
try{
const j=await (await fetch('/api/cleanup/summary')).json();
if(!j.ok) throw new Error(j.error||'Cleanup summary failed');
renderCleanup(j.cleanup||{});
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
}
async function runCleanupAction(endpoint, label){
if(!confirm(`${label}?`)) return;
setBusy(true);
try{
const j=await post(endpoint,{});
const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);
toast(`Cleanup done (${deleted})`,'success');
renderCleanup(j.cleanup||{});
if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }
if(endpoint.includes('/smart-queue')) loadSmartQueue().catch(()=>{});
}catch(e){ toast(e.message,'danger'); }
finally{ setBusy(false); }
}
function diagCard(label,value,extra=''){ return `<div class="diag-card ${extra}"><b>${esc(label)}</b><span>${esc(value ?? '-')}</span></div>`; }
function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }
function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }
function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }
function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const port=data.port?String(data.port):'-'; const label=withPort?`Port ${port} ${st}`:st; return `<span ${attrs}class="port-status ${portStatusClass(st)}"><i class="fa-solid ${portStatusIcon(st)}"></i> ${esc(label)}</span>`; }
function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }
function portCheckDetails(data={}){ const bits=[]; if(data.port) bits.push(`Port: ${data.port}`); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }
function renderPortCheck(data={}){
if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;
const details=portCheckDetails(data);
const title=details.join(' · ') || 'Port check disabled';
if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id="portCheckBadge" ');
if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' · ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';
if($('statusPortCheck')){
$('statusPortCheck').classList.toggle('d-none', !data.enabled);
$('statusPortCheck').title=title;
}
if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id="statusPortCheckBadge" ',true);
}
async function loadPreferences(){ if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); await loadPortCheck(false); }
async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }
async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }
async function loadAppStatus(){
const box=$('appStatusManager'); if(!box) return;
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading diagnostics...';
try{
const j=await (await fetch('/api/app/status')).json();
if(!j.ok) throw new Error(j.error||'Failed to load diagnostics');
const st=j.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};
const cards=[
diagCard('pyTorrent PID', py.pid), diagCard('pyTorrent uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),
diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Jobs total', py.jobs_total),
diagCard('Worker threads', py.worker_threads), diagCard('Python', py.python||'-'), diagCard('DB size', db.size_h||'-'),
diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`),
diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'),
diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')),
diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),
diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),
diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')
];
box.innerHTML=`<div class="diag-grid">${cards.join('')}</div>${scgi.error?`<div class="alert alert-danger mt-3 mb-0">${esc(scgi.error)}</div>`:''}`;
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
}
$('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',preferences:'toolPreferences',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>{const tool=b.dataset.tool||'rtorrents'; document.querySelectorAll('.tool-tab').forEach(x=>x.classList.remove('active')); b.classList.add('active'); showToolPanel(tool); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();})); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});
$('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();});
document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });
$('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});
$('smartExcludeSelectedBtn')?.addEventListener('click',()=>setSmartException(selectedHashes(),true,'manual'));
$('smartIncludeSelectedBtn')?.addEventListener('click',()=>setSmartException(selectedHashes(),false,'manual'));
$('smartHistory')?.addEventListener('click',e=>{ const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue(); });
document.addEventListener('change',e=>{ const sel=e.target.closest('#mobileFilterSelect'); if(!sel)return; activeFilter=sel.value; document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter===activeFilter)); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); });
function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }
document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.filter').forEach(x=>{ if(x.dataset.filter===mobileFilter.dataset.filter) x.classList.add('active'); }); activeFilter=mobileFilter.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=h; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });
document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });
document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const b=e.target.closest('.peer-action'); if(!b) return; peerAction(b.dataset.peerIndex,b.dataset.peerAction); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const editStart=e.target.closest('.tracker-edit-start'); if(editStart){ setTrackerEdit(editStart.dataset.index,true); return; } const cancel=e.target.closest('.tracker-edit-cancel'); if(cancel){ setTrackerEdit(cancel.dataset.index,false); return; } const save=e.target.closest('.tracker-edit-save'); if(save){ const input=document.querySelector(`.tracker-url[data-tracker-index="${CSS.escape(String(save.dataset.index))}"]`); trackerAction('edit',{index:Number(save.dataset.index),url:input?.value||''}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences);
document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s')runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });
$('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});
$('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));
$('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));
$('addBtn')?.addEventListener('click',async()=>{const btn=$('addBtn');buttonBusy(btn,true);setBusy(true);try{const fd=new FormData();fd.append('uris',$('magnetInput').value);fd.append('directory',$('addPath').value);fd.append('label',$('addLabel').value);fd.append('start',$('addStart').checked?'1':'0');[...($('torrentFiles')?.files||[])].forEach(f=>fd.append('files',f));const j=await (await fetch('/api/torrents/add',{method:'POST',body:fd})).json();if(!j.ok)throw new Error(j.error||'Add failed');$('magnetInput').value='';$('torrentFiles').value='';toast('Add queued','success');bootstrap.Modal.getInstance($('addModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('torrentFiles')?.addEventListener('change',()=>{$('torrentFilesInfo').textContent=$('torrentFiles').files.length?`Selected files: ${$('torrentFiles').files.length}`:'You can select multiple files at once.';});
const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;
const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;
function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }
function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }
function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }
function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }
function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }
document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));
document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));
['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));
$('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});
async function refreshProfiles(){ $('profileList').innerHTML='<span class="spinner-border spinner-border-sm me-2"></span>Loading profiles...'; const j=await (await fetch('/api/profiles')).json(); const active=j.active?.id; profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); $('profileList').innerHTML=(j.profiles||[]).map(p=>`<div class="profile-row ${p.id===active?'active':''}"><b>${esc(p.name)} ${p.id===active?"<span class='badge text-bg-primary ms-1'>active</span>":''} ${p.is_remote?"<span class='badge text-bg-secondary ms-1'>remote</span>":''}</b><span>${esc(p.scgi_url)} · jobs ${esc(p.max_parallel_jobs||5)}${p.is_remote?' · remote CPU/RAM/IP':''}</span><div class="profile-actions"><button class="btn btn-xs btn-outline-primary" data-use-profile="${p.id}"><i class="fa-solid fa-plug-circle-check"></i> use</button><button class="btn btn-xs btn-outline-secondary" data-edit-profile="${p.id}" title="Edit"><i class="fa-solid fa-pen-to-square"></i></button><button class="btn btn-xs btn-outline-danger" data-del-profile="${p.id}" title="Delete"><i class="fa-solid fa-trash"></i></button></div></div>`).join('')||'No profiles.'; }
function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add one rTorrent'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class="fa-solid fa-plus"></i> Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }
function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class="fa-solid fa-floppy-disk"></i> Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }
$('profileModal')?.addEventListener('show.bs.modal',refreshProfiles); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile;if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){setBusy(true);await post(`/api/profiles/${use}/activate`,{});setBusy(false);location.reload();}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload={name:$('profileName').value,scgi_url:$('profileUrl').value,timeout_seconds:$('profileTimeout').value,max_parallel_jobs:$('profileParallel').value,is_remote:$('profileRemote')?.checked};const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveBulkProfilesBtn')?.addEventListener('click',async()=>{const lines=($('bulkProfiles').value||'').split(/\n+/).map(x=>x.trim()).filter(Boolean);setBusy(true);try{for(const line of lines){const [name,scgi_url]=line.split('|').map(x=>x.trim());if(name&&scgi_url)await post('/api/profiles',{name,scgi_url,timeout_seconds:$('profileTimeout').value,max_parallel_jobs:$('profileParallel').value,is_remote:$('profileRemote')?.checked});}location.reload();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('profileSelect')?.addEventListener('change',async e=>{await post(`/api/profiles/${e.target.value}/activate`,{});const opt=e.target.selectedOptions?.[0];if($('activeProfileName') && opt) $('activeProfileName').textContent=opt.textContent || 'rTorrent';bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();defaultDownloadPath=null;applyDefaultDownloadPath(true).catch(()=>{});socket.emit('select_profile',{profile_id:e.target.value});hasTorrentSnapshot=false;torrents.clear();selected.clear();scheduleRender(true);}); $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();
function drawTraffic(down,up){ traffic.push({down:Number(down||0),up:Number(up||0)}); if(traffic.length>60)traffic.shift(); const c=$('trafficChart'); if(!c)return; const ctx=c.getContext('2d'),w=c.width,h=c.height; ctx.clearRect(0,0,w,h); const max=Math.max(1,...traffic.map(p=>Math.max(p.down,p.up))); ctx.beginPath(); traffic.forEach((p,i)=>{const x=i*(w/59),y=h-(p.down/max)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#38bdf8'; ctx.stroke(); ctx.beginPath(); traffic.forEach((p,i)=>{const x=i*(w/59),y=h-(p.up/max)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#f59e0b'; ctx.stroke(); }
function drawSystemUsage(cpu,ram){
const c=$('systemChart'); if(!c) return;
const cpuVal=Math.max(0,Math.min(100,Number(cpu||0)));
const ramVal=Math.max(0,Math.min(100,Number(ram||0)));
systemUsage.push({cpu:cpuVal,ram:ramVal}); if(systemUsage.length>60) systemUsage.shift();
const ctx=c.getContext('2d'), w=c.width, h=c.height; ctx.clearRect(0,0,w,h);
ctx.fillStyle='rgba(148,163,184,.18)'; ctx.fillRect(0,0,w,h);
ctx.beginPath(); systemUsage.forEach((p,i)=>{const x=i*(w/Math.max(1,systemUsage.length-1)), y=h-(p.cpu/100)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#a78bfa'; ctx.stroke();
ctx.beginPath(); systemUsage.forEach((p,i)=>{const x=i*(w/Math.max(1,systemUsage.length-1)), y=h-(p.ram/100)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#22c55e'; ctx.stroke();
c.title=`CPU ${cpuVal.toFixed(1)}% / RAM ${ramVal.toFixed(1)}%`;
}
function drawDiskUsage(disk){
const box=$('diskStatus'), label=$('statDisk'), c=$('diskChart');
if(!box||!label||!c)return;
const ctx=c.getContext('2d'), w=c.width, h=c.height;
ctx.clearRect(0,0,w,h);
const ok=disk&&disk.ok;
const pct=ok?Math.max(0,Math.min(100,Number(disk.percent||0))):0;
label.textContent=ok?`${pct.toFixed(pct%1?1:0)}%`:'-';
box.classList.toggle('disk-warn', !ok || pct>=90);
box.title=ok?`Disk ${disk.path||'default path'}
Used: ${disk.used_h||'-'} / ${disk.total_h||'-'}
Free: ${disk.free_h||'-'}${disk.fallback?`
Measured on: ${disk.source_path}`:''}`:`Disk usage unavailable${disk?.error?`
${disk.error}`:''}`;
ctx.fillStyle='rgba(148,163,184,.22)'; ctx.fillRect(0,5,w,14);
ctx.fillStyle=pct>=90?'#ef4444':pct>=75?'#f59e0b':'#22c55e'; ctx.fillRect(0,5,Math.round(w*pct/100),14);
ctx.strokeStyle='rgba(148,163,184,.55)'; ctx.strokeRect(.5,5.5,w-1,13);
}
async function loadTrafficHistory(range="7d"){
const info=$('trafficHistoryInfo');
const volume=$('trafficHistoryChart');
const speed=$('trafficSpeedChart');
if(info) info.textContent='Loading...';
try{
const res=await fetch(`/api/traffic/history?range=${encodeURIComponent(range)}`);
const j=await res.json();
if(!j.ok) throw new Error(j.error||'Failed to load history');
drawTrafficHistory(j.history||{rows:[]});
if(info){
const rows=(j.history&&j.history.rows)||[];
const bucket=(j.history&&j.history.bucket)||'bucket';
info.textContent=rows.length ? `${rows.length} ${bucket} bucket(s), retention ${j.history?.retention_days||90} days.` : 'No retained samples yet. Data is stored every minute while pyTorrent is running.';
}
}catch(e){
if(info) info.textContent=e.message;
[volume,speed].forEach(c=>{ if(c) c.getContext('2d').clearRect(0,0,c.width,c.height); });
}
}
function setupCanvas(canvas){
const rect=canvas.getBoundingClientRect();
const dpr=window.devicePixelRatio||1;
const cssW=Math.max(320, Math.floor(rect.width || canvas.parentElement?.clientWidth || 900));
const cssH=Math.max(320, Math.floor(rect.height || 420));
canvas.width=Math.floor(cssW*dpr); canvas.height=Math.floor(cssH*dpr);
const ctx=canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
return {ctx,w:cssW,h:cssH};
}
function drawAxes(ctx,w,h){ ctx.strokeStyle='rgba(148,163,184,.35)'; ctx.lineWidth=1; ctx.beginPath(); ctx.moveTo(42,12); ctx.lineTo(42,h-28); ctx.lineTo(w-12,h-28); ctx.stroke(); }
function fmtBytes(v){ v=Number(v||0); const u=['B','KiB','MiB','GiB','TiB']; let i=0; while(v>=1024&&i<u.length-1){v/=1024;i++;} return `${v.toFixed(i?1:0)} ${u[i]}`; }
function compactTransferText(value){ return String(value || '0 B').replace(/\s+(?=[KMGT]?i?B$)/, ''); }
function drawLine(ctx,pts,w,h,color){ ctx.strokeStyle=color; ctx.lineWidth=2; ctx.beginPath(); pts.forEach((p,i)=>{ const x=42+(i*Math.max(1,(w-58)/Math.max(1,pts.length-1))); const y=h-28-p; i?ctx.lineTo(x,y):ctx.moveTo(x,y); }); ctx.stroke(); }
function drawTrafficHistory(hist){
const rows=hist.rows||[];
const volume=$('trafficHistoryChart'), speed=$('trafficSpeedChart');
if(!volume||!speed) return;
const bodyColor=getComputedStyle(document.body).color;
const muted='rgba(148,163,184,.75)';
function legend(ctx, x, y, unit){
ctx.fillStyle=bodyColor; ctx.font='12px system-ui'; ctx.fillText(`Download / Upload (${unit})`, x, y);
ctx.fillStyle='#38bdf8'; ctx.fillRect(x, y+7, 10, 10); ctx.fillStyle=bodyColor; ctx.fillText('Download', x+14, y+17);
ctx.fillStyle='#f59e0b'; ctx.fillRect(x+92, y+7, 10, 10); ctx.fillStyle=bodyColor; ctx.fillText('Upload', x+106, y+17);
}
function yLabels(ctx, max, suffix, w, h){
ctx.fillStyle=muted; ctx.font='11px system-ui';
ctx.fillText(fmtBytes(max)+suffix, 6, 18);
ctx.fillText(fmtBytes(max/2)+suffix, 6, Math.round((h-28+12)/2));
ctx.fillText('0 '+suffix.trim(), 24, h-12);
}
function xLabels(ctx, values, w, h){
if(!values.length) return;
ctx.fillStyle=muted; ctx.font='11px system-ui';
const first=String(values[0]||''), last=String(values[values.length-1]||'');
ctx.fillText(first.slice(-10), 44, h-8);
const tw=ctx.measureText(last.slice(-10)).width;
ctx.fillText(last.slice(-10), Math.max(48, w-12-tw), h-8);
}
let c=setupCanvas(volume), ctx=c.ctx,w=c.w,h=c.h; ctx.clearRect(0,0,w,h); drawAxes(ctx,w,h);
if(!rows.length){
ctx.fillStyle=bodyColor; ctx.fillText('No history yet. Samples appear after pyTorrent records traffic.',52,36);
const sc=setupCanvas(speed); sc.ctx.clearRect(0,0,sc.w,sc.h); sc.ctx.fillStyle=bodyColor; sc.ctx.fillText('No speed samples yet.',52,36);
return;
}
const labels=rows.map(r=>r.bucket);
const maxVol=Math.max(1,...rows.map(r=>Math.max(Number(r.downloaded||0),Number(r.uploaded||0))));
const usable=w-58, bw=Math.max(2, Math.min(26, usable/rows.length-3));
rows.forEach((r,i)=>{
const x=44+i*(usable/rows.length); const dh=(Number(r.downloaded||0)/maxVol)*(h-60); const uh=(Number(r.uploaded||0)/maxVol)*(h-60);
ctx.fillStyle='#38bdf8'; ctx.fillRect(x,h-28-dh,bw/2,dh);
ctx.fillStyle='#f59e0b'; ctx.fillRect(x+bw/2,h-28-uh,bw/2,uh);
});
legend(ctx,52,16,'data'); yLabels(ctx,maxVol,'',w,h); xLabels(ctx,labels,w,h);
c=setupCanvas(speed); ctx=c.ctx; w=c.w; h=c.h; ctx.clearRect(0,0,w,h); drawAxes(ctx,w,h);
const maxSpeed=Math.max(1,...rows.map(r=>Math.max(Number(r.avg_down_rate||0),Number(r.avg_up_rate||0))));
const scale=h-60; const dl=rows.map(r=>Number(r.avg_down_rate||0)/maxSpeed*scale); const ul=rows.map(r=>Number(r.avg_up_rate||0)/maxSpeed*scale);
drawLine(ctx,dl,w,h,'#38bdf8'); drawLine(ctx,ul,w,h,'#f59e0b');
legend(ctx,52,16,'B/s'); yLabels(ctx,maxSpeed,'/s',w,h); xLabels(ctx,labels,w,h);
}
$('trafficModal')?.addEventListener("show.bs.modal",()=>loadTrafficHistory("7d"));
document.querySelectorAll(".traffic-range").forEach(b=>b.addEventListener("click",()=>{
document.querySelectorAll(".traffic-range").forEach(x=>{x.classList.remove("btn-primary");x.classList.add("btn-outline-secondary");});
b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary");
loadTrafficHistory(b.dataset.range||"7d");
}));
socket.on('connect',()=>{ $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);hideInitialLoader();}); socket.on('torrent_patch',patchRows); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled) toast(`Smart Queue: paused ${msg.paused?.length||0}, resumed ${msg.resumed?.length||0}`,'secondary'); }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{ const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined; $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);$('statRamBox')?.classList.toggle('d-none',!usageAvailable);$('systemChart')?.classList.toggle('d-none',!usageAvailable); if(usageAvailable){$('statCpu').textContent=s.cpu??'-';$('statRam').textContent=s.ram??'-';drawSystemUsage(s.cpu,s.ram);} $('statVersion').textContent=s.version||'-';$('statDl').textContent=s.down_rate_h||'0 B/s';$('statUl').textContent=s.up_rate_h||'0 B/s';if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};$('statDlLimit').textContent=s.down_limit_h||'∞';$('statUlLimit').textContent=s.up_limit_h||'∞';$('statTotalDl').textContent=compactTransferText(s.total_down_h);$('statTotalUl').textContent=compactTransferText(s.total_up_h);drawTraffic(s.down_rate,s.up_rate);drawDiskUsage(s.disk);});
updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); scheduleRender(true); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); applyDefaultDownloadPath(false).catch(()=>{});
})();

808
pytorrent/static/styles.css Normal file
View File

@@ -0,0 +1,808 @@
:root {
--app-font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
--topbar: 50px;
--statusbar: 34px;
--sidebar: 270px;
--torrent-progress-complete: #198754;
}
[data-bs-theme="dark"] {
--bs-body-bg: #05070a;
--bs-body-bg-rgb: 5,7,10;
--bs-body-color: #d6dde8;
--bs-secondary-bg: #0a0f16;
--bs-secondary-bg-rgb: 10,15,22;
--bs-tertiary-bg: #0e141d;
--bs-border-color: #1d2734;
--bs-secondary-color: #8d98aa;
--bs-primary-bg-subtle: #0d2238;
--bs-primary-text-emphasis: #9ecbff;
--torrent-progress-complete: #2f9e75;
}
html[data-app-font="adwaita-mono"] { --app-font-family: "Adwaita Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
html[data-app-font="inter"] { --app-font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
html[data-app-font="system-ui"] { --app-font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
html[data-app-font="source-sans-3"] { --app-font-family: "Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
html[data-app-font="jetbrains-mono"] { --app-font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
html, body { height: 100%; }
body {
overflow: hidden;
font-size: 13px;
padding: 8px;
background: #05070a;
font-family: var(--app-font-family);
}
.app-shell {
height: calc(100vh - 16px);
display: grid;
grid-template-rows: var(--topbar) 1fr var(--statusbar);
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 12px 45px rgba(0,0,0,.38);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
padding: .42rem .7rem;
min-height: var(--topbar);
background: var(--bs-secondary-bg);
}
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: .45rem; min-width: 0; }
.toolbar-left { flex: 0 1 auto; overflow: hidden; }
.toolbar-right { flex: 1 1 0; justify-content: flex-end; margin-left: auto; }
.brand { font-weight: 800; font-size: 1.05rem; letter-spacing: .2px; white-space: nowrap; line-height: 32px; }
.profile-picker-btn { max-width: 180px; }
.profile-picker-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.profile-select { width: 100%; }
.search { width: min(38vw, 420px); min-width: clamp(160px, 20vw, 220px); max-width: 420px; flex: 0 1 420px; }
.mobile-speed-stats { display: none; align-items: center; gap: .45rem; flex: 0 0 auto; color: var(--bs-secondary-color); font-size: .72rem; white-space: nowrap; }
.mobile-speed-stats b { color: var(--bs-body-color); font-weight: 700; }
.topbar .form-control, .topbar .form-select { height: 32px; line-height: 1.15; }
.topbar .btn { min-height: 28px; line-height: 1; }
#themeToggle, #mobileToggle { width: 32px; min-width: 32px; display: inline-flex; align-items: center; justify-content: center; }
.spinner-border-xs { width: .75rem; height: .75rem; border-width: .12em; vertical-align: -1px; }
.global-loader {
position: fixed;
right: 14px;
bottom: 44px;
z-index: 7000;
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .4rem .65rem;
border-radius: 999px;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
border: 1px solid var(--bs-border-color);
box-shadow: 0 8px 28px rgba(0,0,0,.35);
}
.initial-loader {
position: fixed;
inset: 0;
z-index: 9000;
display: grid;
place-items: center;
padding: 1rem;
background: radial-gradient(circle at 50% 35%, rgba(var(--bs-secondary-bg-rgb), .98), var(--bs-body-bg) 68%);
color: var(--bs-body-color);
transition: opacity .22s ease, visibility .22s ease;
}
.initial-loader.is-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.initial-loader-card {
width: min(92vw, 430px);
padding: 2rem;
border: 1px solid var(--bs-border-color);
border-radius: 18px;
background: rgba(var(--bs-secondary-bg-rgb), .88);
box-shadow: 0 24px 70px rgba(0,0,0,.48);
text-align: center;
}
.initial-loader-brand {
font-size: 1.35rem;
font-weight: 800;
letter-spacing: .2px;
}
.initial-loader-spinner {
margin: 1.4rem 0 1rem;
}
.initial-loader-title {
font-size: 1rem;
font-weight: 700;
}
.initial-loader-text {
margin-top: .35rem;
color: var(--bs-secondary-color);
}
.main-grid { min-height: 0; display: grid; grid-template-columns: var(--sidebar) 1fr; }
.sidebar { padding: .65rem; overflow: auto; background: rgba(var(--bs-secondary-bg-rgb), .9); }
/* Note: Sidebar filters are wider and use one structured block per class to avoid duplicate overrides. */
.filter {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: .15rem .55rem;
align-items: center;
margin-bottom: .2rem;
padding: .45rem .6rem;
border: 0;
border-radius: .55rem;
background: transparent;
color: var(--bs-body-color);
text-align: left;
}
.filter:hover,
.filter.active {
background: var(--bs-primary-bg-subtle);
color: var(--bs-primary-text-emphasis);
}
.filter > span:first-child {
min-width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter > span:last-child {
min-width: 0;
max-width: 12rem;
text-align: right;
}
.filter-count {
display: block;
font-weight: 700;
line-height: 1.1;
}
.filter-meta {
display: block;
margin-top: .05rem;
color: var(--bs-secondary-color);
font-size: .68rem;
font-weight: 400;
line-height: 1.15;
opacity: .72;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter.active .filter-meta,
.filter:hover .filter-meta {
color: var(--bs-primary-text-emphasis);
opacity: .78;
}
.shortcut { font-size: .78rem; color: var(--bs-secondary-color); padding: .15rem .5rem; }
.content { min-width: 0; min-height: 0; display: grid; grid-template-rows: 1fr 255px; }
.table-wrap { overflow: auto; contain: content; }
.torrent-table { margin: 0; white-space: nowrap; table-layout: auto; }
.torrent-table thead th { position: sticky; top: 0; z-index: 2; background: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); user-select: none; }
.torrent-table thead th[data-sort] { cursor: pointer; }
.torrent-table thead th[data-sort]:hover, .torrent-table thead th.sorted { color: var(--bs-primary-text-emphasis); }
.sort-icon { opacity: .85; }
.torrent-table tbody tr { cursor: default; height: 36px; }
.torrent-table tbody tr.selected td { background: var(--bs-primary-bg-subtle); }
.torrent-table .sel { width: 34px; text-align: center; }
.torrent-table .name { min-width: 280px; max-width: 520px; overflow: hidden; text-overflow: ellipsis; }
.torrent-table .path { max-width: 360px; overflow: hidden; text-overflow: ellipsis; color: var(--bs-secondary-color); }
.virtual-spacer td { padding: 0 !important; border: 0 !important; }
.empty { height: 120px; text-align: center; vertical-align: middle; color: var(--bs-secondary-color); }
.progress.thin { height: 7px; min-width: 130px; margin-bottom: 1px; background: rgba(255,255,255,.08); }
.details { min-height: 0; overflow: hidden; background: rgba(var(--bs-secondary-bg-rgb), .78); }
.detail-pane { height: 210px; overflow: auto; padding: .65rem; }
.loading-line { display: flex; align-items: center; gap: .5rem; color: var(--bs-secondary-color); padding: .75rem; }
.muted-pane { color: var(--bs-secondary-color); }
.detail-table { white-space: nowrap; }
.general-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .6rem; }
.general-grid div { border: 1px solid var(--bs-border-color); border-radius: .6rem; padding: .5rem; background: var(--bs-body-bg); min-width: 0; }
.general-grid b { display: block; color: var(--bs-secondary-color); font-size: .72rem; text-transform: uppercase; }
.general-grid span { overflow-wrap: anywhere; }
.statusbar { display: flex; align-items: center; gap: 1rem; padding: 0 .75rem; overflow-x: auto; background: var(--bs-tertiary-bg); color: var(--bs-secondary-color); white-space: nowrap; }
.statusbar b { color: var(--bs-body-color); }
.status-limit { border: 1px solid var(--bs-border-color); background: rgba(var(--bs-secondary-bg-rgb), .9); color: var(--bs-secondary-color); border-radius: .45rem; padding: .12rem .5rem; white-space: nowrap; }
.status-limit:hover { color: var(--bs-body-color); background: var(--bs-secondary-bg); }
.ctx-menu { display: none; position: absolute; z-index: 5000; min-width: 200px; padding: .35rem; border: 1px solid var(--bs-border-color); border-radius: .6rem; background: var(--bs-body-bg); }
.ctx-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: var(--bs-body-color); padding: .42rem .55rem; border-radius: .4rem; }
.ctx-menu button:hover { background: var(--bs-secondary-bg); }
.ctx-menu .danger { color: var(--bs-danger); }
.ctx-menu hr { margin: .25rem 0; border-color: var(--bs-border-color); }
.profile-row { display: grid; grid-template-columns: 1fr auto; gap: .25rem .5rem; align-items: center; padding: .45rem; border: 1px solid var(--bs-border-color); border-radius: .6rem; margin-bottom: .45rem; background: rgba(var(--bs-secondary-bg-rgb), .58); }
.profile-row span { grid-column: 1 / 2; color: var(--bs-secondary-color); overflow-wrap: anywhere; }
.profile-form-actions { display: inline-flex; gap: .35rem; flex-wrap: wrap; }
.profile-actions { display: inline-flex; gap: .35rem; }
.profile-row.active { border-color: var(--bs-primary); background: var(--bs-primary-bg-subtle); }
.flag-icon { border-radius: 2px; box-shadow: 0 0 0 1px rgba(255,255,255,.12); }
.flag-code { color: var(--bs-secondary-color); margin-left: .25rem; }
.peer-actions { display: flex; align-items: center; gap: .25rem; flex-wrap: nowrap; }
.peer-actions .btn { display: inline-flex; align-items: center; gap: .25rem; border-radius: .35rem !important; }
.modal-content { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: 14px; }
.modal-header, .modal-footer { background: rgba(var(--bs-secondary-bg-rgb), .82); border-color: var(--bs-border-color); }
.add-grid { display: grid; gap: .85rem; }
.magnet-box { min-height: 64px; resize: vertical; }
.upload-box, .surface-section { border: 1px solid var(--bs-border-color); background: rgba(var(--bs-secondary-bg-rgb), .5); border-radius: .75rem; padding: .75rem; }
.section-title { font-weight: 700; margin-bottom: .55rem; color: var(--bs-body-color); }
.preset-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .4rem; }
.toast-host { position: fixed; right: 14px; top: 70px; z-index: 8000; display: grid; gap: .4rem; }
.toast-item { padding: .45rem .65rem; border-radius: .55rem; box-shadow: 0 8px 25px rgba(0,0,0,.28); max-width: 360px; }
@media (max-width: 1100px) {
:root { --topbar: 88px; }
.topbar { align-items: flex-start; flex-wrap: wrap; }
.toolbar-left { flex: 1 1 100%; overflow: visible; flex-wrap: wrap; }
.toolbar-right { flex: 1 1 100%; justify-content: flex-end; }
.search { flex: 1 1 220px; width: auto; min-width: 160px; max-width: none; }
}
@media (max-width: 900px) {
:root { --sidebar: 0px; }
.sidebar { display: none; }
.general-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
:root { --topbar: 132px; }
.toolbar-right { width: 100%; justify-content: flex-start; flex-wrap: nowrap; gap: .35rem; }
.search { flex: 1 1 0; width: auto; min-width: 0; max-width: none; }
.preset-grid { grid-template-columns: 1fr 1fr; }
}
.preferences-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: .75rem;
}
.form-field { display: grid; gap: .3rem; }
.form-field > span { color: var(--bs-secondary-color); font-size: .78rem; font-weight: 700; text-transform: uppercase; }
@media (max-width: 640px) { .preferences-grid { grid-template-columns: 1fr; } }
/* Feature additions without changing the existing visual shell */
.date-compact {
white-space: nowrap;
}
.btn-xs {
--bs-btn-padding-y: .18rem;
--bs-btn-padding-x: .42rem;
--bs-btn-font-size: .78rem;
--bs-btn-border-radius: .35rem;
}
.nav-btn {
border-radius: .45rem !important;
margin: 0 !important;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .25rem;
}
.nav-btn + .nav-btn,
.torrent-action + .torrent-action { margin-left: .08rem !important; }
.path-list {
height: 360px;
overflow: auto;
border: 1px solid var(--bs-border-color);
border-radius: .6rem;
background: rgba(var(--bs-secondary-bg-rgb), .35);
}
.path-row {
display: flex;
align-items: center;
gap: .5rem;
padding: .42rem .6rem;
border-bottom: 1px solid var(--bs-border-color);
cursor: pointer;
}
.path-row:hover { background: var(--bs-primary-bg-subtle); color: var(--bs-primary-text-emphasis); }
.chips { display: flex; gap: .35rem; flex-wrap: wrap; }
.chip {
border: 1px solid var(--bs-border-color);
background: rgba(var(--bs-secondary-bg-rgb), .6);
color: var(--bs-body-color);
border-radius: 999px;
padding: .22rem .6rem;
font-size: .78rem;
}
.mobile-list { overflow: auto; padding: .55rem; background: var(--bs-body-bg); }
.mobile-card {
border: 1px solid var(--bs-border-color);
background: rgba(var(--bs-secondary-bg-rgb), .72);
border-radius: .75rem;
padding: .65rem;
margin-bottom: .55rem;
}
.mobile-card.selected { outline: 2px solid var(--bs-primary); }
.mobile-card .name { font-weight: 700; word-break: break-word; }
.mobile-actions { display: flex; gap: .35rem; margin-top: .45rem; }
#systemChart {
width: 140px;
height: 24px;
border: 1px solid var(--bs-border-color);
border-radius: .35rem;
background: rgba(var(--bs-secondary-bg-rgb), .85);
}
.badge-degraded { background: #f59e0b !important; color: #111 !important; }
body.mobile-mode .table-wrap { display: none !important; }
body.mobile-mode #mobileList { display: block !important; }
body.mobile-mode .content { grid-template-rows: 1fr 210px; }
body.mobile-mode .torrent-table { display: none; }
@media (max-width: 640px) {
.nav-btn span { display: none; }
}
/* Fixes: compact one-line progress cell and readable percent inside the bar. */
.torrent-table td:nth-child(5) { min-width: 92px; width: 110px; white-space: nowrap; }
.hidden-col{display:none!important}
.status-docs{margin-left:auto;color:inherit;text-decoration:none;font-weight:600;opacity:.9;white-space:nowrap}
.status-docs:hover{opacity:1;text-decoration:underline}
.column-check{padding:.35rem .5rem;border:1px solid var(--bs-border-color);border-radius:.5rem;background:var(--bs-body-bg)}
.label-filters .label-filter{font-size:.82rem;padding:.34rem .5rem;margin-bottom:.15rem}
.label-filters .label-filter i{opacity:.75;margin-right:.25rem}
.column-manager{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:.55rem}
.column-card{display:flex;align-items:center;gap:.5rem;padding:.55rem .65rem;border:1px solid var(--bs-border-color);border-radius:.7rem;background:rgba(var(--bs-secondary-bg-rgb),.45);cursor:pointer;user-select:none;transition:background .15s,border-color .15s,transform .15s}
.column-card:hover{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle)}
.column-card.active{border-color:rgba(var(--bs-primary-rgb),.55);background:var(--bs-primary-bg-subtle)}
.column-card input{margin:0}.column-card span{display:flex;gap:.45rem;align-items:center;font-weight:600}.column-card i{opacity:.72}
.path-row::before{content:'\f07b';font-family:'Font Awesome 6 Free';font-weight:900;color:var(--bs-warning)}
body.mobile-mode #mobileList{min-height:0;height:100%;overflow:auto;display:block!important}
body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{min-width:34px}
#toolSmart .form-label{font-size:.75rem;color:var(--bs-secondary-color);margin-bottom:.2rem}
.profile-form-grid{display:grid;grid-template-columns:1.1fr 2.1fr .55fr .75fr auto auto;gap:.5rem;align-items:center}
#toolSmart .btn{padding:.25rem .55rem;border-radius:.5rem;white-space:nowrap}
#toolSmart .row .d-flex{align-items:end;justify-content:flex-start}
#trafficHistoryChart{width:100%;height:420px;border:1px solid var(--bs-border-color);border-radius:.75rem;background:var(--bs-body-bg)}
@media (max-width: 992px){.profile-form-grid{grid-template-columns:1fr}.profile-form-grid .btn{width:100%}}
/* Requested fixes: stable charts, Smart Queue exceptions, label actions, mobile readability */
.history-grid{display:grid;grid-template-columns:1fr;gap:1rem}
.history-card{border:1px solid var(--bs-border-color);border-radius:.8rem;background:rgba(var(--bs-secondary-bg-rgb),.35);padding:.75rem;min-width:0;overflow:hidden}
.history-title{font-weight:700;font-size:.9rem;margin-bottom:.45rem;color:var(--bs-body-color)}
#trafficHistoryChart,#trafficSpeedChart{display:block;width:100%;height:420px;max-width:100%;border:0;border-radius:.55rem;background:var(--bs-body-bg)}
@media (min-width: 992px){.history-grid{grid-template-columns:1fr}}
.smart-actions{display:flex;align-items:center;gap:.45rem;flex-wrap:wrap}
.empty-mini{padding:.7rem .8rem;border:1px dashed var(--bs-border-color);border-radius:.7rem;color:var(--bs-secondary-color);background:rgba(var(--bs-secondary-bg-rgb),.35)}
.label-manager-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--bs-border-color);border-radius:.65rem;padding:.4rem .5rem;margin-bottom:.4rem;background:rgba(var(--bs-secondary-bg-rgb),.35)}
.tool-tab i{margin-right:.25rem;opacity:.82}
body.mobile-mode .content{display:grid!important;grid-template-rows:minmax(0,1fr)!important;min-height:0;overflow:hidden}
body.mobile-mode .details{display:none!important}
body.mobile-mode #mobileList{display:block!important;height:100%!important;min-height:220px;overflow:auto;position:relative;z-index:2;padding-bottom:1rem}
body.mobile-mode .main-grid{min-height:0;overflow:hidden}
@media (max-width:640px){.history-card{padding:.5rem}#trafficHistoryChart,#trafficSpeedChart{height:320px}.statusbar{font-size:.75rem;gap:.6rem}.mobile-list{padding:.45rem}.mobile-card{margin-bottom:.45rem}}
/* Requested fixes: clean progress, mobile auto list, pagers, rTorrent config, peers refresh */
.torrent-progress{height:16px;min-width:92px;position:relative;margin:0;overflow:hidden;background:rgba(var(--bs-secondary-bg-rgb),.8)!important}
.torrent-progress .progress-bar{min-width:0!important;position:relative;transition:width .25s ease,background-color .25s ease}
.torrent-progress>span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;line-height:1;color:var(--bs-body-color);text-shadow:none;white-space:nowrap;pointer-events:none}
.torrent-progress .progress-bar+span{color:var(--bs-body-color)}
body.mobile-mode #mobileList{display:block!important}
@media (max-width:700px){
body:not(.desktop-mode) .table-wrap{display:none!important}
body:not(.desktop-mode) #mobileList{display:block!important;min-height:260px;height:100%;overflow:auto}
body:not(.desktop-mode) .content{display:grid!important;grid-template-rows:minmax(0,1fr)!important;min-height:0;overflow:hidden}
body:not(.desktop-mode) .details{display:none!important}
}
.pager-row{display:flex;align-items:center;justify-content:flex-end;gap:.5rem}
.peers-refresh{display:flex;align-items:center;gap:.5rem;justify-content:flex-end;padding:.35rem .75rem;border-bottom:1px solid var(--bs-border-color);background:rgba(var(--bs-secondary-bg-rgb),.35)}
.peers-refresh select{width:auto;min-width:96px}
/* Mobile list: force visible on narrow screens even without manual toggle. */
@media (max-width: 900px) {
body:not(.modal-open) .table-wrap { display: none !important; }
body:not(.modal-open) #mobileList { display: block !important; height: 100% !important; min-height: 260px; overflow: auto; }
body:not(.modal-open) .content { display: grid !important; grid-template-rows: minmax(0,1fr) !important; min-height: 0; overflow: hidden; }
body:not(.modal-open) .details { display: none !important; }
}
.torrent-paused td{opacity:.82}
.torrent-paused .name{font-style:italic}
/* Mobile blank-view fix: sidebar disappears at 900px, so the mobile list must also be forced from 900px down. */
@media (max-width: 900px) {
.main-grid {
display: grid !important;
grid-template-columns: minmax(0, 1fr) !important;
min-height: 0 !important;
height: 100% !important;
overflow: hidden !important;
}
.sidebar { display: none !important; }
.content {
display: grid !important;
grid-template-rows: minmax(0, 1fr) !important;
min-height: 0 !important;
height: 100% !important;
overflow: hidden !important;
}
.table-wrap { display: none !important; }
#mobileList {
display: block !important;
height: 100% !important;
min-height: 0 !important;
overflow: auto !important;
position: relative !important;
z-index: 10 !important;
background: var(--bs-body-bg) !important;
padding: .55rem !important;
}
.details { display: none !important; }
.toolbar-right { width: 100% !important; min-width: 0 !important; flex-wrap: nowrap !important; gap: .35rem !important; }
.search { min-width: 0 !important; width: auto !important; flex: 1 1 0 !important; max-width: none !important; }
.mobile-speed-stats { display: inline-flex; }
}
@media (max-width: 640px) {
.toolbar-right { flex-wrap: nowrap !important; gap: .3rem !important; }
.search { min-width: 0 !important; width: auto !important; flex: 1 1 0 !important; max-width: none !important; }
.mobile-speed-stats { gap: .25rem; font-size: .66rem; }
}
.files-toolbar{display:flex;gap:.75rem;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:.5rem}
.file-priority-table .path{max-width:520px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-priority-table .file-priority{min-width:110px}
@media (max-width:900px){.files-toolbar{align-items:stretch}.files-toolbar .btn-group{display:grid;grid-template-columns:1fr;width:100%}.file-priority-table{font-size:.82rem}.file-priority-table .path{max-width:180px}}
.bulk-bar {
height: 38px;
display: flex;
align-items: center;
gap: .35rem;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
padding: .35rem .55rem;
border-bottom: 1px solid var(--bs-border-color);
background: rgba(var(--bs-secondary-bg-rgb), .95);
z-index: 4;
}
.bulk-bar.d-none { display: none !important; }
.bulk-bar span { color: var(--bs-secondary-color); margin-right: .3rem; white-space: nowrap; }
.bulk-bar .btn { white-space: nowrap; flex: 0 0 auto; }
.move-options {
border: 1px solid var(--bs-border-color);
border-radius: .6rem;
padding: .75rem;
background: var(--bs-tertiary-bg);
}
/* Stable main layout: bulk actions overlay the list area, details stay pinned at the bottom. */
.content {
position: relative;
grid-template-rows: minmax(0, 1fr) 255px !important;
}
#bulkBar { grid-row: 1; grid-column: 1; align-self: start; }
#tableWrap, #mobileList { grid-row: 1; grid-column: 1; min-height: 0; }
.details { grid-row: 2; grid-column: 1; min-height: 0; }
.bulk-bar:not(.d-none) + .table-wrap { padding-top: 38px; }
@media (max-width: 900px) {
.bulk-bar { gap: .3rem; }
}
.label-mini{font-size:.72rem;padding:.12rem .38rem;margin-right:.15rem}
.label-chip.active{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle);color:var(--bs-primary-text-emphasis)}
.label-selected{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle);color:var(--bs-primary-text-emphasis)}
.automation-form-grid { display:grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap:.5rem; align-items:center; }
.automation-row { display:flex; justify-content:space-between; gap:.75rem; align-items:center; padding:.55rem .65rem; border:1px solid var(--bs-border-color); border-radius:.6rem; margin-bottom:.45rem; background:var(--bs-body-bg); }
@media (max-width: 900px){ .automation-form-grid { grid-template-columns: 1fr; } }
.disk-status{display:inline-flex;align-items:center;gap:.35rem;min-width:110px}
.disk-status canvas{border-radius:999px;background:rgba(var(--bs-secondary-bg-rgb),.65)}
.disk-status.disk-warn b{color:var(--bs-warning)!important}
.system-chart{width:96px;height:24px;border-radius:.35rem;background:rgba(var(--bs-secondary-bg-rgb),.45)}
.torrent-progress.is-complete>span{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.35)}
.peer-progress{min-width:86px;width:96px}
.loading-center{justify-content:center;min-height:80px}
.loading-cell{padding:0!important}
.mobile-list .loading-center{min-height:160px}
/* Torrent warning and mobile controls */
.torrent-warning td { background: rgba(245, 158, 11, .075) !important; }
.torrent-warning:hover td { background: rgba(245, 158, 11, .11) !important; }
.torrent-warning.selected td { background: color-mix(in srgb, var(--bs-primary-bg-subtle) 82%, rgba(245, 158, 11, .16)) !important; }
.mobile-card.torrent-warning { background: rgba(245, 158, 11, .075); }
.mobile-card.torrent-warning.selected { background: color-mix(in srgb, var(--bs-primary-bg-subtle) 82%, rgba(245, 158, 11, .16)); }
.torrent-warning-icon { color: var(--bs-warning); margin-right: .2rem; }
.mobile-filter-bar {
display: none;
grid-row: 1;
grid-column: 1;
align-self: start;
position: sticky;
top: 0;
z-index: 12;
padding: .45rem .55rem;
border-bottom: 1px solid var(--bs-border-color);
background: rgba(var(--bs-body-bg-rgb), .96);
}
.mobile-filter-actions,
.mobile-filter-select-row {
display: flex;
align-items: center;
gap: .35rem;
}
.mobile-filter-actions { margin-bottom: .4rem; }
.mobile-filter-actions span { color: var(--bs-secondary-color); font-size: .78rem; white-space: nowrap; }
.mobile-filter-select-row label {
color: var(--bs-secondary-color);
font-size: .78rem;
white-space: nowrap;
}
.mobile-filter-select-row select {
min-width: 0;
flex: 1 1 auto;
}
body.mobile-mode .mobile-filter-bar { display: block !important; }
body.mobile-mode #mobileList { padding-top: 5.2rem !important; }
@media (max-width: 900px) {
#mobileFilterBar { display: block !important; }
#mobileList { padding-top: 5.2rem !important; }
.topbar .badge {
width: .72rem;
height: .72rem;
min-width: .72rem;
padding: 0 !important;
border-radius: 999px;
overflow: hidden;
color: transparent !important;
text-indent: -999px;
box-shadow: 0 0 0 1px rgba(255,255,255,.22);
}
.topbar .badge .spinner-border { display: none; }
}
/* rTorrent config */
.rt-config-grid {
display: grid;
gap: .6rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.rt-config-group {
grid-column: 1 / -1;
padding: .45rem .2rem .1rem;
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-primary-text-emphasis);
font-weight: 800;
}
.rt-config-note {
margin-bottom: .75rem;
}
.rt-config-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .75rem;
margin-bottom: .75rem;
}
.rt-config-row {
display: grid;
grid-template-columns: 1fr minmax(120px, 190px);
align-items: center;
gap: .6rem;
padding: .6rem;
border: 1px solid var(--bs-border-color);
border-radius: .7rem;
background: rgba(var(--bs-secondary-bg-rgb), .35);
}
.rt-config-row b {
font-size: .88rem;
}
.rt-config-row small {
display: block;
overflow-wrap: anywhere;
color: var(--bs-secondary-color);
font-size: .72rem;
}
.rt-config-row.disabled {
opacity: .58;
}
.rt-config-row.changed,
.rt-config-row.changed-live {
border-color: var(--bs-danger);
box-shadow: 0 0 0 .12rem rgba(220, 53, 69, .2);
}
.rt-config-value-note {
margin-top: .15rem;
}
.rt-config-output {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .82rem;
}
/* Tracker management */
.tracker-toolbar,
.tracker-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .45rem;
}
.tracker-toolbar {
justify-content: space-between;
margin-bottom: .55rem;
}
.tracker-url {
min-width: 240px;
max-width: 520px;
}
.tracker-message {
max-width: 360px;
white-space: normal;
word-break: break-word;
}
.tracker-url-text {
word-break: break-all;
}
/* Cleanup and app diagnostics */
.tool-note {
color: var(--bs-secondary-color);
font-size: .82rem;
}
.cleanup-grid,
.diag-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: .6rem;
}
.cleanup-card,
.diag-card {
padding: .65rem;
border: 1px solid var(--bs-border-color);
border-radius: .7rem;
background: rgba(var(--bs-secondary-bg-rgb), .35);
}
.cleanup-card b,
.diag-card b {
display: block;
margin-bottom: .2rem;
color: var(--bs-secondary-color);
font-size: .78rem;
}
.cleanup-card span,
.diag-card span {
font-weight: 700;
}
.cleanup-card small {
display: block;
margin-top: .2rem;
overflow-wrap: anywhere;
color: var(--bs-secondary-color);
}
.cleanup-actions {
display: flex;
flex-wrap: wrap;
gap: .5rem;
}
.diag-error {
border-color: rgba(var(--bs-danger-rgb), .45);
background: rgba(var(--bs-danger-rgb), .08);
}
.port-status {
display: inline-flex;
align-items: center;
gap: .3rem;
padding: .12rem .4rem;
border-radius: .45rem;
}
.port-ok {
background: rgba(34, 197, 94, .14);
color: var(--bs-success);
}
.port-bad {
background: rgba(239, 68, 68, .14);
color: var(--bs-danger);
}
.port-secondary {
background: rgba(148, 163, 184, .12);
color: var(--bs-secondary-color);
}
.limit-slider-panel {
padding: .65rem;
border: 1px solid var(--bs-border-color);
border-radius: .7rem;
background: rgba(var(--bs-secondary-bg-rgb), .32);
}
.limit-slider-row + .limit-slider-row {
margin-top: .65rem;
}
.limit-slider-row .form-label {
display: flex;
justify-content: space-between;
gap: .75rem;
margin-bottom: .25rem;
}
@media (max-width: 640px) {
#mobileToggle {
display: none !important;
}
.tracker-url {
min-width: 160px;
max-width: 230px;
}
.tracker-message {
max-width: 220px;
}
}
.text-compact {
display: inline-block;
max-width: 32rem;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
/* Operation status, mobile progress and separated preferences */
.torrent-operating td {
background: rgba(13, 202, 240, .085) !important;
}
.torrent-operating:hover td {
background: rgba(13, 202, 240, .13) !important;
}
.torrent-operating.selected td {
background: color-mix(in srgb, var(--bs-primary-bg-subtle) 78%, rgba(13, 202, 240, .18)) !important;
}
.mobile-card.torrent-operating {
background: rgba(13, 202, 240, .085);
border-color: rgba(13, 202, 240, .45);
}
.mobile-card.torrent-operating.selected {
background: color-mix(in srgb, var(--bs-primary-bg-subtle) 78%, rgba(13, 202, 240, .18));
}
.operation-status-badge {
color: #062c33;
}
.mobile-progress {
margin-top: .45rem;
}
.mobile-progress .torrent-progress {
width: 100%;
min-width: 0;
}
.preferences-sections {
display: grid;
gap: 1rem;
}
.preference-section {
border-left: .25rem solid var(--bs-primary);
}

File diff suppressed because one or more lines are too long

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]

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask>=3.0
Flask-SocketIO>=5.3
python-dotenv>=1.0
geoip2>=4.8
psutil>=5.9
simple-websocket>=1.0

41
scripts/download_geoip.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
DB_PATH="${1:-data/GeoLite2-City.mmdb}"
PRIMARY_URL="https://git.io/GeoLite2-City.mmdb"
FALLBACK_URL="https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
DB_DIR="$(dirname "$DB_PATH")"
TMP_FILE="${DB_PATH}.tmp"
mkdir -p "$DB_DIR"
chmod 755 "$DB_DIR"
if [ -s "$DB_PATH" ]; then
chmod 644 "$DB_PATH"
echo "GeoIP database already exists: $DB_PATH"
exit 0
fi
download() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fL --retry 3 --connect-timeout 15 --output "$TMP_FILE" "$url"
elif command -v wget >/dev/null 2>&1; then
wget -O "$TMP_FILE" "$url"
else
echo "Missing downloader: install curl or wget" >&2
return 127
fi
}
rm -f "$TMP_FILE"
if ! download "$PRIMARY_URL"; then
rm -f "$TMP_FILE"
download "$FALLBACK_URL"
fi
test -s "$TMP_FILE"
mv "$TMP_FILE" "$DB_PATH"
chmod 644 "$DB_PATH"
echo "GeoIP database downloaded: $DB_PATH"

16
systemd/pytorrent.service Normal file
View File

@@ -0,0 +1,16 @@
[Unit]
Description=pyTorrent web UI for rTorrent
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/pytorrent
EnvironmentFile=/opt/pytorrent/.env
ExecStart=/opt/pytorrent/venv/bin/python /opt/pytorrent/app.py
Restart=always
RestartSec=3
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target