Compare commits
30 Commits
4c30e45e73
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f5d56eb6c0 | |||
| 6ad0102280 | |||
| b7d268dd77 | |||
| d4c9150c42 | |||
| e1b5822a59 | |||
| fc03b7755b | |||
| e6733d6a27 | |||
| 77a6902b13 | |||
| 377e602bd3 | |||
| f3bf67a641 | |||
| b98505fd31 | |||
| 99692ef217 | |||
| 48f68cf125 | |||
| 03ce088d24 | |||
| d5fa689dad | |||
| a73aeb5544 | |||
| c796a740d1 | |||
| 5195809869 | |||
| 0f1ffc1c3d | |||
| fc7ca12a01 | |||
| 3533b694f7 | |||
| a1cc5ac0f9 | |||
| ef8585fe66 | |||
| 337259a099 | |||
| f173cc0a62 | |||
| aa87ced07b | |||
| b710f6e6f9 | |||
| 7fea2bfef8 | |||
| 005867999f | |||
| fc76ca19a1 |
@@ -83,7 +83,7 @@ Clone the repository and run the local development installer:
|
||||
git clone https://github.com/zdzichu6969/pyTorrent.git
|
||||
cd pyTorrent
|
||||
./install.sh
|
||||
. venv/bin/activate
|
||||
. .venv/bin/activate
|
||||
python app.py
|
||||
```
|
||||
|
||||
@@ -146,8 +146,8 @@ The default stack install creates:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||
| PYTORRENT_PORT=8091 \
|
||||
RTORRENT_SCGI_PORT=5001 \
|
||||
| PYTORRENT_PORT=8090 \
|
||||
RTORRENT_SCGI_PORT=5000 \
|
||||
PYTORRENT_PROFILE_NAME="Local rTorrent" \
|
||||
bash
|
||||
```
|
||||
@@ -196,7 +196,7 @@ curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/script
|
||||
Recommended production command:
|
||||
|
||||
```bash
|
||||
. venv/bin/activate
|
||||
. .venv/bin/activate
|
||||
gunicorn --worker-class gthread \
|
||||
--workers 1 \
|
||||
--threads 32 \
|
||||
@@ -276,7 +276,7 @@ PYTORRENT_AUTH_PROVIDER=local
|
||||
Reset a local user's password:
|
||||
|
||||
```bash
|
||||
. venv/bin/activate
|
||||
. .venv/bin/activate
|
||||
python -m pytorrent.cli reset-password admin new_password
|
||||
```
|
||||
|
||||
@@ -470,7 +470,7 @@ PYTORRENT_DEBUG_INSTALL=1 bash scripts/install_stack.sh
|
||||
## Development
|
||||
|
||||
```bash
|
||||
. venv/bin/activate
|
||||
. .venv/bin/activate
|
||||
python app.py
|
||||
```
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Group=pytorrent
|
||||
WorkingDirectory=/opt/pyTorrent
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
EnvironmentFile=/opt/pyTorrent/.env
|
||||
ExecStart=/opt/pyTorrent/venv/bin/gunicorn -c /opt/pyTorrent/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} wsgi:app
|
||||
ExecStart=/opt/pyTorrent/.venv/bin/gunicorn -c /opt/pyTorrent/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} wsgi:app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillSignal=SIGINT
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
cp -n .env.example .env || true
|
||||
@@ -11,4 +11,4 @@ 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"
|
||||
echo "Run: . .venv/bin/activate && python app.py"
|
||||
|
||||
@@ -124,10 +124,8 @@ def create_app() -> Flask:
|
||||
|
||||
from .routes.main import bp as main_bp
|
||||
from .routes.api import bp as api_bp
|
||||
from .routes.planner import bp as planner_api_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(planner_api_bp)
|
||||
register_error_pages(app)
|
||||
init_db()
|
||||
from .services.speed_peaks import load_cache
|
||||
@@ -143,6 +141,8 @@ def create_app() -> Flask:
|
||||
register_socketio_handlers(socketio)
|
||||
from .services.startup_config import schedule_startup_config_apply
|
||||
schedule_startup_config_apply(socketio)
|
||||
from .services.background_automations import start_scheduler as start_background_automation_scheduler
|
||||
start_background_automation_scheduler(socketio)
|
||||
from .services.rss import start_scheduler as start_rss_scheduler
|
||||
from .services.ratio_rules import start_scheduler as start_ratio_scheduler
|
||||
from .services.download_planner import start_scheduler as start_download_planner_scheduler
|
||||
@@ -151,4 +151,6 @@ def create_app() -> Flask:
|
||||
start_ratio_scheduler(socketio)
|
||||
start_download_planner_scheduler(socketio)
|
||||
start_backup_scheduler()
|
||||
from .services.background_cache_warmup import start_scheduler as start_cache_warmup_scheduler
|
||||
start_cache_warmup_scheduler(socketio)
|
||||
return app
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import json
|
||||
|
||||
from .db import connect, init_db, utcnow
|
||||
from .services.auth import password_hash
|
||||
from .services import tracker_cache
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
+28
-1
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
@@ -114,6 +113,25 @@ CREATE TABLE IF NOT EXISTS rtorrent_profiles (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_runtime_stats (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
torrent_count INTEGER DEFAULT 0,
|
||||
total_size_bytes INTEGER DEFAULT 0,
|
||||
completed_bytes INTEGER DEFAULT 0,
|
||||
downloaded_bytes INTEGER DEFAULT 0,
|
||||
uploaded_bytes INTEGER DEFAULT 0,
|
||||
active_count INTEGER DEFAULT 0,
|
||||
seeding_count INTEGER DEFAULT 0,
|
||||
downloading_count INTEGER DEFAULT 0,
|
||||
stopped_count INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_runtime_stats_user ON profile_runtime_stats(user_id, profile_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -372,6 +390,15 @@ CREATE TABLE IF NOT EXISTS traffic_history (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_speed_limits (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
down_limit INTEGER DEFAULT 0,
|
||||
up_limit INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transfer_speed_peaks (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
session_started_at TEXT NOT NULL,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, g, request
|
||||
|
||||
from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
+41
-2
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
Migration = Callable[[sqlite3.Connection], bool]
|
||||
|
||||
|
||||
@@ -133,10 +131,51 @@ def migrate_operation_log_split_retention(conn: sqlite3.Connection) -> bool:
|
||||
return changed
|
||||
|
||||
|
||||
def migrate_profile_speed_limits_table(conn: sqlite3.Connection) -> bool:
|
||||
existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_speed_limits'").fetchone()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profile_speed_limits (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
down_limit INTEGER DEFAULT 0,
|
||||
up_limit INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
return existing is None
|
||||
|
||||
|
||||
def migrate_profile_runtime_stats_table(conn: sqlite3.Connection) -> bool:
|
||||
existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_runtime_stats'").fetchone()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profile_runtime_stats (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
torrent_count INTEGER DEFAULT 0,
|
||||
total_size_bytes INTEGER DEFAULT 0,
|
||||
completed_bytes INTEGER DEFAULT 0,
|
||||
downloaded_bytes INTEGER DEFAULT 0,
|
||||
uploaded_bytes INTEGER DEFAULT 0,
|
||||
active_count INTEGER DEFAULT 0,
|
||||
seeding_count INTEGER DEFAULT 0,
|
||||
downloading_count INTEGER DEFAULT 0,
|
||||
stopped_count INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_profile_runtime_stats_user ON profile_runtime_stats(user_id, profile_id)")
|
||||
return existing is None
|
||||
|
||||
|
||||
MIGRATIONS: tuple[Migration, ...] = (
|
||||
migrate_disk_monitor_preferences_to_profile_scope,
|
||||
migrate_profile_preferences_sidebar_columns,
|
||||
migrate_operation_log_split_retention,
|
||||
migrate_profile_speed_limits_table,
|
||||
migrate_profile_runtime_stats_table,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+1715
-95
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from importlib import import_module
|
||||
|
||||
API_ROUTE_MODULES = (
|
||||
"torrents",
|
||||
"profiles",
|
||||
"rss",
|
||||
"automations",
|
||||
"smart_queue",
|
||||
"system",
|
||||
"backup",
|
||||
"operation_logs",
|
||||
"planner",
|
||||
)
|
||||
|
||||
|
||||
def load_api_route_modules() -> None:
|
||||
"""Import API route modules so their shared blueprint decorators are registered."""
|
||||
for module_name in API_ROUTE_MODULES:
|
||||
import_module(f"{__name__}.{module_name}")
|
||||
+90
-201
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import platform
|
||||
@@ -19,11 +18,10 @@ import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for
|
||||
# Note: url_for is exported through this shared module for API routes that build temporary in-app links.
|
||||
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
|
||||
from ..db import connect, utcnow
|
||||
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write, require_admin, is_admin
|
||||
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
|
||||
from ..services import auth, preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
|
||||
from ..services.torrent_cache import torrent_cache
|
||||
from ..services.torrent_summary import cached_summary
|
||||
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
|
||||
@@ -34,11 +32,93 @@ bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
MOVE_BULK_MAX_HASHES = 100
|
||||
|
||||
|
||||
from .auth_api import register_auth_routes
|
||||
register_auth_routes(bp)
|
||||
|
||||
|
||||
def _request_profile_selector() -> tuple[int | None, str]:
|
||||
"""Return the optional rTorrent profile selector supplied by external API clients."""
|
||||
payload = {}
|
||||
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
profile_id = (
|
||||
request.args.get("profile_id")
|
||||
or request.form.get("profile_id")
|
||||
or payload.get("rtorrent_profile_id")
|
||||
or request.headers.get("X-PyTorrent-Profile-Id")
|
||||
)
|
||||
profile_name = (
|
||||
request.args.get("profile_name")
|
||||
or request.form.get("profile_name")
|
||||
or payload.get("rtorrent_profile_name")
|
||||
or request.headers.get("X-PyTorrent-Profile-Name")
|
||||
or ""
|
||||
)
|
||||
|
||||
try:
|
||||
return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip())
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("profile_id must be an integer")
|
||||
|
||||
|
||||
def _profile_by_name(profile_name: str, user_id: int | None = None):
|
||||
name = str(profile_name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
user_id = user_id or default_user_id()
|
||||
visible = auth.visible_profile_ids(user_id)
|
||||
with connect() as conn:
|
||||
if visible is None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if not visible:
|
||||
return None
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
return conn.execute(
|
||||
f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||
(*tuple(visible), name),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def request_profile(require_write: bool = False):
|
||||
"""Resolve API profile context from profile_id/profile_name, then active profile for compatibility."""
|
||||
try:
|
||||
profile_id, profile_name = _request_profile_selector()
|
||||
except ValueError:
|
||||
raise
|
||||
user_id = default_user_id()
|
||||
profile = None
|
||||
if profile_id:
|
||||
profile = preferences.get_profile(int(profile_id), user_id)
|
||||
elif profile_name:
|
||||
profile = _profile_by_name(profile_name, user_id)
|
||||
else:
|
||||
profile = preferences.active_profile(user_id)
|
||||
if not profile and auth.can_access_profile(1, user_id):
|
||||
profile = preferences.get_profile(1, user_id)
|
||||
if not profile and (profile_id or profile_name):
|
||||
abort(404)
|
||||
if not profile:
|
||||
return None
|
||||
pid = int(profile["id"])
|
||||
if require_write and not auth.can_write_profile(pid, user_id):
|
||||
abort(403)
|
||||
if not require_write and not auth.can_access_profile(pid, user_id):
|
||||
abort(403)
|
||||
return profile
|
||||
|
||||
|
||||
def request_profile_id(require_write: bool = False) -> int | None:
|
||||
profile = request_profile(require_write=require_write)
|
||||
return int(profile["id"]) if profile else None
|
||||
|
||||
|
||||
def _job_profile_id(job_id: str) -> int | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
@@ -51,196 +131,7 @@ def ok(payload=None):
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
|
||||
PORT_CHECK_CACHE_SECONDS = 6 * 60 * 60
|
||||
|
||||
|
||||
def _app_setting_get(key: str):
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
return row.get("value") if row else None
|
||||
|
||||
|
||||
def _app_setting_set(key: str, value: str):
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, value))
|
||||
|
||||
|
||||
def _iso_from_epoch(value) -> str | None:
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), timezone.utc).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _public_ip(profile: dict | None = None, force: bool = False) -> str:
|
||||
if profile and bool(profile.get("is_remote")):
|
||||
return rtorrent.remote_public_ip(profile, force=force)
|
||||
req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": "pyTorrent/port-check"})
|
||||
with urllib.request.urlopen(req, timeout=8) as res:
|
||||
return res.read(64).decode("utf-8", "replace").strip()
|
||||
|
||||
|
||||
MAX_PORT_CHECK_CANDIDATES = 256
|
||||
|
||||
|
||||
def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
|
||||
"""Return valid incoming port candidates from rTorrent network.port_range.
|
||||
|
||||
Note: rTorrent may keep a range/list and pick a random port on start.
|
||||
The old checker used only the first number, which produced false "closed"
|
||||
results when another configured port was actually active.
|
||||
"""
|
||||
ports: list[int] = []
|
||||
seen: set[int] = set()
|
||||
truncated = False
|
||||
|
||||
def add(port: int) -> None:
|
||||
nonlocal truncated
|
||||
if not 1 <= port <= 65535 or port in seen:
|
||||
return
|
||||
if len(ports) >= limit:
|
||||
truncated = True
|
||||
return
|
||||
seen.add(port)
|
||||
ports.append(port)
|
||||
|
||||
for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
|
||||
a, b = int(start), int(end)
|
||||
if a > b:
|
||||
a, b = b, a
|
||||
for port in range(a, b + 1):
|
||||
add(port)
|
||||
if truncated:
|
||||
break
|
||||
|
||||
without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
|
||||
for item in re.findall(r"\d{1,5}", without_ranges):
|
||||
add(int(item))
|
||||
|
||||
return ports, truncated
|
||||
|
||||
|
||||
def _incoming_ports(profile: dict) -> dict:
|
||||
try:
|
||||
raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
|
||||
except Exception:
|
||||
raw_value = ""
|
||||
ports, truncated = _parse_port_candidates(raw_value)
|
||||
return {"ports": ports, "raw": raw_value, "truncated": truncated}
|
||||
|
||||
|
||||
def _yougetsignal_check(public_ip: str, port: int) -> dict:
|
||||
body = urllib.parse.urlencode({"remoteAddress": public_ip, "portNumber": str(port)}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"https://ports.yougetsignal.com/check-port.php",
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"User-Agent": "pyTorrent/port-check",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=12) as res:
|
||||
text = res.read(8192).decode("utf-8", "replace")
|
||||
low = text.lower()
|
||||
if "is open" in low:
|
||||
return {"status": "open", "source": "yougetsignal", "raw": text[:500]}
|
||||
if "is closed" in low:
|
||||
return {"status": "closed", "source": "yougetsignal", "raw": text[:500]}
|
||||
return {"status": "unknown", "source": "yougetsignal", "raw": text[:500]}
|
||||
|
||||
|
||||
def _local_port_fallback(public_ip: str, port: int) -> dict:
|
||||
try:
|
||||
with socket.create_connection((public_ip, port), timeout=3):
|
||||
return {"status": "open", "source": "local-fallback"}
|
||||
except Exception as exc:
|
||||
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
|
||||
|
||||
|
||||
def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
|
||||
checked: list[int] = []
|
||||
first_closed: dict | None = None
|
||||
last_result: dict = {"status": "unknown"}
|
||||
|
||||
for port in ports:
|
||||
checked.append(port)
|
||||
current = checker(public_ip, port)
|
||||
last_result = current
|
||||
if current.get("status") == "open":
|
||||
current.update({"port": port, "open_port": port, "checked_ports": checked})
|
||||
return current
|
||||
if current.get("status") == "closed" and first_closed is None:
|
||||
first_closed = current
|
||||
|
||||
result = first_closed or last_result
|
||||
result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
|
||||
return result
|
||||
|
||||
|
||||
def port_check_status(force: bool = False) -> dict:
|
||||
profile = preferences.active_profile()
|
||||
prefs = preferences.get_preferences()
|
||||
enabled = bool((prefs or {}).get("port_check_enabled"))
|
||||
if not profile:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
|
||||
|
||||
port_info = _incoming_ports(profile)
|
||||
ports = port_info["ports"]
|
||||
if not ports:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
|
||||
|
||||
ports_key = ",".join(str(port) for port in ports)
|
||||
cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
|
||||
if not force:
|
||||
cached = _app_setting_get(cache_key)
|
||||
if cached:
|
||||
try:
|
||||
data = json.loads(cached)
|
||||
if time.time() - float(data.get("checked_at_epoch") or 0) < PORT_CHECK_CACHE_SECONDS:
|
||||
data["cached"] = True
|
||||
data["enabled"] = enabled
|
||||
if not data.get("checked_at"):
|
||||
data["checked_at"] = _iso_from_epoch(data.get("checked_at_epoch"))
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
checked_at_epoch = time.time()
|
||||
result = {
|
||||
"status": "unknown",
|
||||
"enabled": enabled,
|
||||
"port": ports[0],
|
||||
"ports": ports,
|
||||
"port_range": port_info["raw"],
|
||||
"ports_truncated": port_info["truncated"],
|
||||
"checked_at_epoch": checked_at_epoch,
|
||||
"checked_at": _iso_from_epoch(checked_at_epoch),
|
||||
"cached": False,
|
||||
}
|
||||
try:
|
||||
public_ip = _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _yougetsignal_check))
|
||||
except Exception as exc:
|
||||
result["error"] = f"YouGetSignal failed: {exc}"
|
||||
try:
|
||||
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _local_port_fallback))
|
||||
except Exception as fallback_exc:
|
||||
result["fallback_error"] = str(fallback_exc)
|
||||
result["source"] = "none"
|
||||
_app_setting_set(cache_key, json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
from ..services.port_check import port_check_status
|
||||
|
||||
|
||||
def _safe_len(callable_obj) -> int | None:
|
||||
@@ -370,17 +261,20 @@ def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict:
|
||||
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
|
||||
if action_name == "remove":
|
||||
payload["job_context"]["remove_data"] = bool(payload.get("remove_data"))
|
||||
if action_name == "profile_transfer":
|
||||
payload["job_context"]["target_profile_id"] = int(payload.get("target_profile_id") or 0)
|
||||
payload["job_context"]["target_path"] = str(payload.get("target_path") or payload.get("path") or "")
|
||||
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
|
||||
payload["job_context"]["move_data_downgraded"] = bool(payload.get("move_data_downgraded"))
|
||||
return payload
|
||||
|
||||
|
||||
def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]:
|
||||
# Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable.
|
||||
safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES))
|
||||
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
|
||||
|
||||
|
||||
def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]:
|
||||
# Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions.
|
||||
base_payload = enrich_bulk_payload(profile, action_name, data)
|
||||
hashes = base_payload.get("hashes") or []
|
||||
chunks = _chunk_hashes(hashes)
|
||||
@@ -410,17 +304,14 @@ def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict
|
||||
|
||||
|
||||
def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
||||
# Note: Keep the old public move helper while using the same partitioning logic.
|
||||
return enqueue_bulk_parts(profile, "move", data)
|
||||
|
||||
|
||||
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
||||
# Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load.
|
||||
return enqueue_bulk_parts(profile, "remove", data)
|
||||
|
||||
|
||||
def _user_disk_status(profile: dict) -> dict:
|
||||
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
|
||||
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
|
||||
try:
|
||||
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
|
||||
@@ -434,6 +325,4 @@ def _user_disk_status(profile: dict) -> dict:
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Note: Route modules import shared helpers with wildcard imports; include private helper names intentionally.
|
||||
__all__ = [name for name in globals() if not name.startswith('__')]
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import bp
|
||||
from . import load_api_route_modules
|
||||
|
||||
# Note: Route modules are imported for their decorators; this keeps the public API unchanged.
|
||||
from . import torrents as _torrents_routes
|
||||
from . import profiles as _profiles_routes
|
||||
from . import rss as _rss_routes
|
||||
from . import automations as _automations_routes
|
||||
from . import smart_queue as _smart_queue_routes
|
||||
from . import system as _system_routes
|
||||
from . import backup as _backup_routes
|
||||
from . import operation_logs as _operation_logs_routes
|
||||
load_api_route_modules()
|
||||
|
||||
__all__ = ["bp"]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import abort, jsonify, request
|
||||
|
||||
from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
|
||||
@@ -10,7 +9,7 @@ def _automation_user_id() -> int:
|
||||
@bp.get('/automations')
|
||||
def automations_get():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'rules': [], 'history': [], 'error': 'No profile'})
|
||||
try:
|
||||
@@ -26,7 +25,7 @@ def automations_get():
|
||||
@bp.get('/automations/export')
|
||||
def automations_export():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -39,7 +38,7 @@ def automations_export():
|
||||
@bp.post('/automations/import')
|
||||
def automations_import():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -55,7 +54,7 @@ def automations_import():
|
||||
@bp.post('/automations')
|
||||
def automations_save():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -69,7 +68,7 @@ def automations_save():
|
||||
@bp.delete('/automations/<int:rule_id>')
|
||||
def automations_delete(rule_id: int):
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -83,7 +82,7 @@ def automations_delete(rule_id: int):
|
||||
@bp.post('/automations/<int:rule_id>/run')
|
||||
def automations_run_rule(rule_id: int):
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -100,7 +99,7 @@ def automations_run_rule(rule_id: int):
|
||||
@bp.post('/automations/check')
|
||||
def automations_check():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -117,7 +116,7 @@ def automations_check():
|
||||
@bp.delete('/automations/history')
|
||||
def automations_history_clear():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import auth
|
||||
|
||||
|
||||
def _active_profile_id() -> int | None:
|
||||
profile = preferences.active_profile()
|
||||
def _active_profile_id(require_write: bool = False) -> int | None:
|
||||
profile = request_profile(require_write=require_write)
|
||||
return int(profile["id"]) if profile else None
|
||||
|
||||
|
||||
@@ -27,7 +26,7 @@ def backup_list():
|
||||
@bp.post("/backup/profile")
|
||||
def backup_create_profile():
|
||||
data = request.get_json(silent=True) or {}
|
||||
pid = _active_profile_id()
|
||||
pid = _active_profile_id(require_write=True)
|
||||
if not pid:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -53,7 +52,6 @@ def backup_create_app():
|
||||
|
||||
@bp.post("/backup")
|
||||
def backup_create():
|
||||
# Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings.
|
||||
return backup_create_profile()
|
||||
|
||||
|
||||
@@ -84,7 +82,7 @@ def profile_backup_settings_get():
|
||||
@bp.post("/backup/profile/settings")
|
||||
def profile_backup_settings_save():
|
||||
data = request.get_json(silent=True) or {}
|
||||
pid = _active_profile_id()
|
||||
pid = _active_profile_id(require_write=True)
|
||||
if not pid:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -104,7 +102,7 @@ def backup_preview(backup_id: int):
|
||||
@bp.post("/backup/<int:backup_id>/restore")
|
||||
def backup_restore(backup_id: int):
|
||||
try:
|
||||
pid = _active_profile_id()
|
||||
pid = _active_profile_id(require_write=True)
|
||||
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
||||
|
||||
@@ -12,8 +12,6 @@ from ..services.preferences import get_preferences, list_profiles, active_profil
|
||||
from ..services import auth, pdf_preview_links, rtorrent
|
||||
from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||
from ..services.frontend_assets import asset_path
|
||||
|
||||
# for favicon
|
||||
from flask import current_app, send_from_directory
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
@@ -24,8 +22,6 @@ def _asset_url(key: str) -> str:
|
||||
return path if path.startswith("http") else url_for("static", filename=path)
|
||||
|
||||
|
||||
|
||||
|
||||
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
|
||||
safe = Path(download_name or "download.bin").name or "download.bin"
|
||||
safe_disposition = "inline" if disposition == "inline" else "attachment"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import operation_logs
|
||||
|
||||
|
||||
def _active_profile_or_400():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return None
|
||||
return profile
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import jsonify, request
|
||||
|
||||
from ..services import preferences, download_planner, poller_control
|
||||
from ._shared import bp, request_profile
|
||||
from ..services import download_planner, poller_control
|
||||
from ..services.auth import current_user_id
|
||||
|
||||
bp = Blueprint("planner_api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def ok(payload=None):
|
||||
data = {"ok": True}
|
||||
if payload:
|
||||
@@ -16,7 +14,7 @@ def ok(payload=None):
|
||||
|
||||
|
||||
def _profile_or_error():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
|
||||
return profile, None
|
||||
@@ -32,6 +30,7 @@ def download_planner_get():
|
||||
|
||||
@bp.post("/download-planner")
|
||||
def download_planner_save():
|
||||
# Note: Planner settings are saved through one canonical endpoint to keep the frontend/backend contract explicit.
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
@@ -95,7 +94,8 @@ def poller_settings_get():
|
||||
if error:
|
||||
return error
|
||||
pid = int(profile["id"])
|
||||
return ok({"settings": poller_control.get_settings(pid), "runtime": poller_control.snapshot(pid)})
|
||||
settings = poller_control.get_settings(pid)
|
||||
return ok({"settings": settings, "runtime": poller_control.snapshot(pid, settings)})
|
||||
|
||||
|
||||
@bp.post("/poller/settings")
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services.rtorrent.diagnostics import profile_diagnostics
|
||||
from ..services import auth
|
||||
from ..utils import human_size
|
||||
|
||||
@bp.get("/profiles")
|
||||
def profiles_list():
|
||||
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
|
||||
profiles = []
|
||||
for row in preferences.list_profiles():
|
||||
item = dict(row)
|
||||
# Note: Frontend actions can hide write-only operations without trusting this flag; backend still enforces permissions.
|
||||
item["can_write"] = auth.can_write_profile(int(item.get("id") or 0), auth.current_user_id() or default_user_id())
|
||||
stats = preferences.get_profile_runtime_stats(int(item.get("id") or 0))
|
||||
if stats:
|
||||
stats["total_size_h"] = human_size(stats.get("total_size_bytes"))
|
||||
stats["completed_h"] = human_size(stats.get("completed_bytes"))
|
||||
stats["downloaded_h"] = human_size(stats.get("downloaded_bytes"))
|
||||
stats["uploaded_h"] = human_size(stats.get("uploaded_bytes"))
|
||||
item["runtime_stats"] = stats
|
||||
settings = backup_service.get_auto_backup_settings(default_user_id(), "profile", int(item.get("id") or 0))
|
||||
item["profile_backup_enabled"] = bool(settings.get("enabled"))
|
||||
item["profile_backup_interval_hours"] = settings.get("interval_hours")
|
||||
item["profile_backup_retention_days"] = settings.get("retention_days")
|
||||
profiles.append(item)
|
||||
return ok({"profiles": profiles, "active": preferences.active_profile()})
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +35,6 @@ def profiles_create():
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.put("/profiles/<int:profile_id>")
|
||||
def profiles_update(profile_id: int):
|
||||
try:
|
||||
@@ -38,7 +54,17 @@ def profiles_delete(profile_id: int):
|
||||
@bp.post("/profiles/<int:profile_id>/activate")
|
||||
def profiles_activate(profile_id: int):
|
||||
try:
|
||||
return ok({"profile": preferences.activate_profile(profile_id)})
|
||||
profile = preferences.activate_profile(profile_id)
|
||||
stats_error = ""
|
||||
try:
|
||||
# Note: Profile overview metrics are cached only on user-initiated profile switch, not on every profile list render.
|
||||
preferences.save_profile_runtime_stats(profile, rtorrent.list_torrents(profile), user_id=auth.current_user_id() or default_user_id())
|
||||
except Exception as exc:
|
||||
stats_error = str(exc)
|
||||
response = {"profile": profile}
|
||||
if stats_error:
|
||||
response["stats_error"] = stats_error
|
||||
return ok(response)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 404
|
||||
|
||||
@@ -89,25 +115,25 @@ def profiles_import():
|
||||
|
||||
@bp.get("/preferences")
|
||||
def prefs_get():
|
||||
return ok({"preferences": preferences.get_preferences()})
|
||||
return ok({"preferences": preferences.get_preferences(profile_id=request_profile_id())})
|
||||
|
||||
|
||||
|
||||
@bp.post("/preferences")
|
||||
def prefs_save():
|
||||
return ok({"preferences": preferences.save_preferences(request.json or {})})
|
||||
return ok({"preferences": preferences.save_preferences(request.json or {}, profile_id=request_profile_id(require_write=True))})
|
||||
|
||||
|
||||
@bp.post("/preferences/table-columns/recommended")
|
||||
def prefs_table_columns_recommended():
|
||||
# Note: Applies the backend-owned recommended desktop and mobile column layout.
|
||||
return ok({"preferences": preferences.apply_recommended_table_columns()})
|
||||
return ok({"preferences": preferences.apply_recommended_table_columns(profile_id=request_profile_id(require_write=True))})
|
||||
|
||||
|
||||
|
||||
@bp.get("/labels")
|
||||
def labels_list():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
if not pid:
|
||||
return ok({"labels": []})
|
||||
@@ -128,7 +154,7 @@ def labels_list():
|
||||
|
||||
@bp.post("/labels")
|
||||
def labels_save():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -150,7 +176,7 @@ def labels_save():
|
||||
|
||||
@bp.delete("/labels/<int:label_id>")
|
||||
def labels_delete(label_id: int):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
if not pid or not auth.can_write_profile(int(pid), default_user_id()):
|
||||
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||
@@ -162,7 +188,7 @@ def labels_delete(label_id: int):
|
||||
|
||||
@bp.get("/ratio-groups")
|
||||
def ratio_groups_list():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -182,7 +208,7 @@ def ratio_groups_list():
|
||||
|
||||
@bp.post("/ratio-groups")
|
||||
def ratio_groups_save():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -210,9 +236,25 @@ def ratio_groups_save():
|
||||
|
||||
|
||||
|
||||
@bp.delete("/ratio-groups/<int:group_id>")
|
||||
def ratio_groups_delete(group_id: int):
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
if not auth.can_write_profile(int(profile["id"]), default_user_id()):
|
||||
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||
with connect() as conn:
|
||||
# Note: Deleting a ratio group removes only the group definition and its assignment links; history stays as an audit trail.
|
||||
deleted = conn.execute("DELETE FROM ratio_groups WHERE id=? AND profile_id=?", (int(group_id), int(profile["id"]))).rowcount
|
||||
conn.execute("DELETE FROM ratio_assignments WHERE group_id=? AND profile_id=?", (int(group_id), int(profile["id"])))
|
||||
if not deleted:
|
||||
return jsonify({"ok": False, "error": "Ratio group not found"}), 404
|
||||
return ratio_groups_list()
|
||||
|
||||
|
||||
@bp.post("/ratio-groups/check")
|
||||
def ratio_groups_check():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"result": ratio_rules.check(profile, default_user_id())})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
|
||||
def _active_profile_or_400():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return None
|
||||
return profile
|
||||
@@ -117,7 +115,7 @@ def rss_rule_test():
|
||||
|
||||
@bp.post("/rss/check")
|
||||
def rss_check():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok(rss_service.check(profile, only_due=False))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
|
||||
@bp.get('/smart-queue')
|
||||
def smart_queue_get():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
|
||||
try:
|
||||
@@ -19,11 +19,10 @@ def smart_queue_get():
|
||||
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()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'settings': {}, 'error': 'No profile'})
|
||||
try:
|
||||
@@ -37,7 +36,7 @@ def smart_queue_save():
|
||||
|
||||
@bp.post('/smart-queue/check')
|
||||
def smart_queue_check():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'result': {'ok': False, 'error': 'No profile'}})
|
||||
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
|
||||
@@ -66,7 +65,7 @@ def smart_queue_check():
|
||||
@bp.post('/smart-queue/exclusion')
|
||||
def smart_queue_exclusion():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -79,7 +78,7 @@ def smart_queue_exclusion():
|
||||
@bp.delete('/smart-queue/history')
|
||||
def smart_queue_history_clear():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
|
||||
+42
-24
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
import posixpath
|
||||
from ..services import operation_logs
|
||||
@@ -7,7 +6,7 @@ from ..services.frontend_assets import static_hash
|
||||
|
||||
@bp.get("/system/disk")
|
||||
def system_disk():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"})
|
||||
try:
|
||||
@@ -19,7 +18,7 @@ def system_disk():
|
||||
|
||||
@bp.get("/system/status")
|
||||
def system_status():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"})
|
||||
try:
|
||||
@@ -27,7 +26,6 @@ def system_status():
|
||||
status["disk"] = _user_disk_status(profile)
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
@@ -40,7 +38,6 @@ def system_status():
|
||||
status["ram"] = psutil.virtual_memory().percent
|
||||
status["usage_source"] = "local"
|
||||
status["usage_available"] = True
|
||||
# Note: REST status returns the latest records without waiting for the next Socket.IO message.
|
||||
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
|
||||
return ok({"status": status})
|
||||
except Exception as exc:
|
||||
@@ -80,7 +77,7 @@ def health_check_nagios():
|
||||
@bp.get("/app/status")
|
||||
def app_status():
|
||||
started = time.perf_counter()
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
proc = psutil.Process(os.getpid())
|
||||
try:
|
||||
jobs = list_jobs(10, 0)
|
||||
@@ -120,11 +117,22 @@ def app_status():
|
||||
status["speed_peaks"] = speed_peaks.current(profile["id"])
|
||||
except Exception as exc:
|
||||
status["speed_peaks"] = {"error": str(exc)}
|
||||
try:
|
||||
# Note: App status carries poller settings and runtime so the panel still renders when the separate poller endpoint is unavailable.
|
||||
poller_settings = poller_control.get_settings(int(profile["id"]))
|
||||
status["poller"] = {"settings": poller_settings, "runtime": poller_control.snapshot(int(profile["id"]), poller_settings)}
|
||||
except Exception as exc:
|
||||
status["poller"] = {"settings": {}, "runtime": {}, "error": str(exc)}
|
||||
try:
|
||||
prefs = preferences.get_preferences()
|
||||
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
|
||||
except Exception as exc:
|
||||
status["port_check"] = {"status": "error", "error": str(exc)}
|
||||
try:
|
||||
from ..services import background_cache_warmup
|
||||
status["background_cache_warmup"] = background_cache_warmup.status()
|
||||
except Exception as exc:
|
||||
status["background_cache_warmup"] = {"started": False, "error": str(exc)}
|
||||
status["api_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||
return ok({"status": status})
|
||||
|
||||
@@ -173,7 +181,7 @@ def cleanup_status():
|
||||
|
||||
@bp.post("/cleanup/cache")
|
||||
def cleanup_profile_cache():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
@@ -220,7 +228,7 @@ def cleanup_database_vacuum():
|
||||
|
||||
@bp.post("/cleanup/smart-queue")
|
||||
def cleanup_smart_queue():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
@@ -238,7 +246,7 @@ def cleanup_smart_queue():
|
||||
|
||||
@bp.post("/cleanup/operation-logs")
|
||||
def cleanup_operation_logs():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
|
||||
@@ -249,7 +257,7 @@ def cleanup_operation_logs():
|
||||
|
||||
@bp.post("/cleanup/planner")
|
||||
def cleanup_planner():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
|
||||
@@ -259,7 +267,7 @@ def cleanup_planner():
|
||||
|
||||
@bp.post("/cleanup/automations")
|
||||
def cleanup_automations():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
@@ -279,7 +287,7 @@ def cleanup_automations():
|
||||
|
||||
@bp.post("/cleanup/poller-diagnostics")
|
||||
def cleanup_poller_diagnostics():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
@@ -290,7 +298,7 @@ def cleanup_poller_diagnostics():
|
||||
@bp.post("/cleanup/all")
|
||||
def cleanup_all():
|
||||
deleted_jobs = clear_jobs()
|
||||
active_profile = preferences.active_profile()
|
||||
active_profile = request_profile()
|
||||
active_profile_id = int(active_profile["id"]) if active_profile else 0
|
||||
deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0
|
||||
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
|
||||
@@ -364,9 +372,21 @@ def _annotate_path_directories(profile: dict, payload: dict) -> dict:
|
||||
return payload
|
||||
|
||||
|
||||
def _path_profile_from_request(*, require_write_access: bool = False):
|
||||
profile_id = 0
|
||||
try:
|
||||
profile_id = int((request.args.get("profile_id") if request.method == "GET" else (request.get_json(silent=True) or {}).get("profile_id")) or 0)
|
||||
except Exception:
|
||||
profile_id = 0
|
||||
profile = preferences.get_profile(profile_id, auth.current_user_id() or default_user_id()) if profile_id else request_profile()
|
||||
if profile and require_write_access:
|
||||
require_profile_write(profile.get("id"))
|
||||
return profile
|
||||
|
||||
|
||||
@bp.get("/path/default")
|
||||
def path_default():
|
||||
profile = preferences.active_profile()
|
||||
profile = _path_profile_from_request()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -378,7 +398,7 @@ def path_default():
|
||||
|
||||
@bp.get("/path/browse")
|
||||
def path_browse():
|
||||
profile = preferences.active_profile()
|
||||
profile = _path_profile_from_request()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
base = request.args.get("path") or ""
|
||||
@@ -390,10 +410,9 @@ def path_browse():
|
||||
|
||||
@bp.post("/path/directories")
|
||||
def path_directory_create():
|
||||
profile = preferences.active_profile()
|
||||
profile = _path_profile_from_request(require_write_access=True)
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
require_profile_write(profile.get("id"))
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
# Note: This endpoint only creates an empty directory and does not alter any torrent state.
|
||||
@@ -405,10 +424,9 @@ def path_directory_create():
|
||||
|
||||
@bp.post("/path/directories/rename")
|
||||
def path_directory_rename():
|
||||
profile = preferences.active_profile()
|
||||
profile = _path_profile_from_request(require_write_access=True)
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
require_profile_write(profile.get("id"))
|
||||
data = request.get_json(silent=True) or {}
|
||||
path = str(data.get("path") or "").strip()
|
||||
if _path_has_cached_torrents(int(profile.get("id") or 0), path):
|
||||
@@ -424,7 +442,7 @@ def path_directory_rename():
|
||||
|
||||
@bp.get('/rtorrent-config')
|
||||
def rtorrent_config_get():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -435,7 +453,7 @@ def rtorrent_config_get():
|
||||
|
||||
@bp.post('/rtorrent-config')
|
||||
def rtorrent_config_save():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -452,7 +470,7 @@ def rtorrent_config_save():
|
||||
|
||||
@bp.post('/rtorrent-config/reset')
|
||||
def rtorrent_config_reset():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -463,7 +481,7 @@ def rtorrent_config_reset():
|
||||
|
||||
@bp.post('/rtorrent-config/generate')
|
||||
def rtorrent_config_generate():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -476,7 +494,7 @@ def rtorrent_config_generate():
|
||||
@bp.get('/traffic/history')
|
||||
def traffic_history_get():
|
||||
from ..services import traffic_history
|
||||
profile = preferences.active_profile()
|
||||
profile = request_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'
|
||||
|
||||
+174
-33
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
import json
|
||||
import posixpath
|
||||
from ..services import profile_speed_limits
|
||||
from ..services import pdf_preview_links, torrent_creator
|
||||
from ..services.reverse_dns import attach_reverse_dns
|
||||
|
||||
@bp.get("/torrents")
|
||||
def torrents():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
|
||||
rows = torrent_cache.snapshot(profile["id"])
|
||||
@@ -19,10 +21,9 @@ def torrents():
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.get("/trackers/summary")
|
||||
def trackers_summary():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
|
||||
try:
|
||||
@@ -77,7 +78,7 @@ def tracker_favicon_query():
|
||||
|
||||
@bp.get("/torrent-stats")
|
||||
def torrent_stats_get():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({"stats": {}, "error": "No profile"})
|
||||
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
|
||||
@@ -91,7 +92,7 @@ def torrent_stats_get():
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files")
|
||||
def torrent_files(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
|
||||
@@ -100,7 +101,7 @@ def torrent_files(torrent_hash: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
|
||||
def torrent_file_media_info(torrent_hash: str, file_index: int):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -123,7 +124,7 @@ def torrent_file_media_info(torrent_hash: str, file_index: int):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/priority")
|
||||
def torrent_file_priority(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -138,7 +139,7 @@ def torrent_file_priority(torrent_hash: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/tree")
|
||||
def torrent_file_tree(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
|
||||
@@ -147,7 +148,7 @@ def torrent_file_tree(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
|
||||
def torrent_folder_priority(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -213,7 +214,7 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
|
||||
def torrent_file_download_link(torrent_hash: str, file_index: int):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -237,7 +238,7 @@ def torrent_file_download_link_from_body(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
|
||||
def torrent_files_download_zip_link(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -253,7 +254,7 @@ def torrent_files_download_zip_link(torrent_hash: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
|
||||
def torrent_file_export_link(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -266,7 +267,7 @@ def torrent_file_export_link(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/torrent-files.zip/link")
|
||||
def torrent_files_export_zip_link():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -283,7 +284,7 @@ def torrent_files_export_zip_link():
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
|
||||
def torrent_file_download(torrent_hash: str, file_index: int):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -376,7 +377,7 @@ def _stream_torrent_files_zip(profile: dict, items: list[dict]):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/download.zip")
|
||||
def torrent_files_download_zip(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -392,7 +393,7 @@ def torrent_files_download_zip(torrent_hash: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/torrent-file")
|
||||
def torrent_file_export(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -405,7 +406,7 @@ def torrent_file_export(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/torrent-files.zip")
|
||||
def torrent_files_export_zip():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -454,7 +455,7 @@ def torrent_files_export_zip():
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/chunks")
|
||||
def torrent_chunks(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -466,7 +467,7 @@ def torrent_chunks(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
|
||||
def torrent_chunk_action(torrent_hash: str, action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -479,7 +480,7 @@ def torrent_chunk_action(torrent_hash: str, action_name: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/peers")
|
||||
def torrent_peers(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
||||
@@ -495,7 +496,7 @@ def torrent_peers(torrent_hash: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/trackers")
|
||||
def torrent_trackers(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
|
||||
@@ -504,7 +505,7 @@ def torrent_trackers(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
|
||||
def torrent_tracker_action(torrent_hash: str, action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -515,17 +516,153 @@ def torrent_tracker_action(torrent_hash: str, action_name: str):
|
||||
|
||||
|
||||
|
||||
|
||||
def _clean_remote_transfer_path(path: str) -> str:
|
||||
clean = posixpath.normpath(str(path or "").strip())
|
||||
if not clean or clean in {".", "/"} or not clean.startswith("/") or "\x00" in clean:
|
||||
raise ValueError("Unsafe target path")
|
||||
return clean
|
||||
|
||||
|
||||
def _path_inside_root(path: str, root: str) -> bool:
|
||||
path = _clean_remote_transfer_path(path)
|
||||
root = _clean_remote_transfer_path(root)
|
||||
return path == root or path.startswith(root.rstrip("/") + "/")
|
||||
|
||||
|
||||
def _target_profile_allowed_roots(target_profile: dict, user_id: int) -> list[str]:
|
||||
roots = []
|
||||
try:
|
||||
roots.append(_clean_remote_transfer_path(rtorrent.default_download_path(target_profile)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
prefs = preferences.get_disk_monitor_preferences(int(target_profile.get("id") or 0), user_id=user_id)
|
||||
for item in json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]"):
|
||||
try:
|
||||
roots.append(_clean_remote_transfer_path(str(item or "")))
|
||||
except Exception:
|
||||
continue
|
||||
selected = str((prefs or {}).get("disk_monitor_selected_path") or "").strip()
|
||||
if selected:
|
||||
roots.append(_clean_remote_transfer_path(selected))
|
||||
except Exception:
|
||||
pass
|
||||
seen = []
|
||||
for root in roots:
|
||||
if root not in seen:
|
||||
seen.append(root)
|
||||
return seen
|
||||
|
||||
|
||||
def _profile_transfer_payload(source_profile: dict, data: dict, *, require_hashes: bool = True) -> dict:
|
||||
user_id = auth.current_user_id() or default_user_id()
|
||||
source_id = int(source_profile.get("id") or 0)
|
||||
if not auth.can_write_profile(source_id, user_id):
|
||||
raise PermissionError("No write access to source profile")
|
||||
hashes = [str(h).strip() for h in (data.get("hashes") or []) if str(h).strip()]
|
||||
if require_hashes and not hashes:
|
||||
raise ValueError("No torrents selected")
|
||||
target_id = int(data.get("target_profile_id") or 0)
|
||||
if not target_id or target_id == source_id:
|
||||
raise ValueError("Choose a different target profile")
|
||||
if not auth.can_write_profile(target_id, user_id):
|
||||
raise PermissionError("No write access to target profile")
|
||||
target_profile = preferences.get_profile(target_id, user_id)
|
||||
if not target_profile:
|
||||
raise ValueError("Target profile does not exist")
|
||||
|
||||
roots = _target_profile_allowed_roots(target_profile, user_id)
|
||||
default_target_path = roots[0] if roots else _clean_remote_transfer_path(rtorrent.default_download_path(target_profile))
|
||||
requested_target_path = str(data.get("target_path") or data.get("path") or "").strip()
|
||||
target_path = _clean_remote_transfer_path(requested_target_path or default_target_path)
|
||||
inside_allowed_root = bool(roots and any(_path_inside_root(target_path, root) for root in roots))
|
||||
if not inside_allowed_root:
|
||||
# Note: A chosen target path must stay inside the target profile roots even for metadata-only transfers.
|
||||
if requested_target_path:
|
||||
raise ValueError("Target path is outside the target profile download roots")
|
||||
target_path = default_target_path
|
||||
inside_allowed_root = bool(roots and any(_path_inside_root(target_path, root) for root in roots))
|
||||
|
||||
requested_move_data = bool(data.get("move_data"))
|
||||
move_data = requested_move_data
|
||||
write_check = {"ok": False, "message": "not requested"}
|
||||
downgrade_reason = ""
|
||||
if requested_move_data:
|
||||
if not inside_allowed_root:
|
||||
move_data = False
|
||||
downgrade_reason = "Target path is outside the target profile download roots"
|
||||
write_check = {"ok": False, "message": downgrade_reason, "path": target_path}
|
||||
else:
|
||||
# Note: Data moves are allowed only when the source rTorrent OS user can write to the target profile path.
|
||||
write_check = rtorrent.remote_can_write_directory(source_profile, target_path)
|
||||
move_data = bool(write_check.get("ok"))
|
||||
if not move_data:
|
||||
downgrade_reason = str(write_check.get("message") or write_check.get("error") or "Target path is not writable by the source rTorrent user")
|
||||
|
||||
return {
|
||||
"hashes": hashes,
|
||||
"target_profile_id": target_id,
|
||||
"target_path": target_path,
|
||||
"path": target_path,
|
||||
"move_data": move_data,
|
||||
"move_data_requested": requested_move_data,
|
||||
"move_data_downgraded": bool(requested_move_data and not move_data),
|
||||
"move_data_downgrade_reason": downgrade_reason,
|
||||
"target_allowed_roots": roots,
|
||||
"target_write_check": write_check,
|
||||
"label_mode": str(data.get("label_mode") or "none").strip(),
|
||||
"label_value": str(data.get("label_value") or "").strip(),
|
||||
"post_action": str(data.get("post_action") or "current").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _validated_profile_transfer_payload(source_profile: dict, data: dict) -> dict:
|
||||
return _profile_transfer_payload(source_profile, data, require_hashes=True)
|
||||
|
||||
|
||||
@bp.post("/torrents/profile_transfer/validate")
|
||||
def profile_transfer_validate():
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
payload = _profile_transfer_payload(profile, request.get_json(silent=True) or {}, require_hashes=False)
|
||||
target_profile = preferences.get_profile(int(payload["target_profile_id"]), auth.current_user_id() or default_user_id())
|
||||
return ok({
|
||||
"target_profile_id": payload["target_profile_id"],
|
||||
"target_path": payload["target_path"],
|
||||
"move_data_requested": payload["move_data_requested"],
|
||||
"move_data_allowed": bool(payload["move_data"]),
|
||||
"move_data_downgraded": bool(payload["move_data_downgraded"]),
|
||||
"move_data_downgrade_reason": payload.get("move_data_downgrade_reason") or "",
|
||||
"target_write_check": payload.get("target_write_check") or {},
|
||||
"disk": rtorrent.disk_usage_for_paths(target_profile, [payload["target_path"]], mode="selected", selected_path=payload["target_path"]),
|
||||
"target_allowed_roots": payload.get("target_allowed_roots") or [],
|
||||
})
|
||||
except PermissionError as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 403
|
||||
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()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"}
|
||||
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "profile_transfer", "set_label", "set_ratio_group"}
|
||||
if action_name not in allowed:
|
||||
return jsonify({"ok": False, "error": "Unknown action"}), 400
|
||||
if action_name in {"move", "remove"}:
|
||||
# Note: Large move/remove requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
|
||||
if action_name == "profile_transfer":
|
||||
try:
|
||||
data = _validated_profile_transfer_payload(profile, data)
|
||||
except PermissionError as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 403
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
if action_name in {"move", "remove", "profile_transfer"}:
|
||||
# Note: Large move/remove/profile-transfer requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
|
||||
jobs = enqueue_bulk_parts(profile, action_name, data)
|
||||
first_job_id = jobs[0]["job_id"] if jobs else None
|
||||
total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs)
|
||||
@@ -537,6 +674,8 @@ def torrent_action(action_name: str):
|
||||
"bulk": total_hashes > 1,
|
||||
"bulk_parts": len(jobs),
|
||||
"chunk_size": MOVE_BULK_MAX_HASHES,
|
||||
"transfer_move_data_downgraded": bool(data.get("move_data_downgraded")),
|
||||
"transfer_move_data_downgrade_reason": str(data.get("move_data_downgrade_reason") or ""),
|
||||
})
|
||||
payload = enrich_bulk_payload(profile, action_name, data)
|
||||
job_id = enqueue(action_name, profile["id"], payload)
|
||||
@@ -546,7 +685,7 @@ def torrent_action(action_name: str):
|
||||
|
||||
@bp.post("/torrents/create")
|
||||
def torrent_create():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
|
||||
@@ -576,7 +715,7 @@ def torrent_create():
|
||||
|
||||
@bp.post("/torrents/add")
|
||||
def torrent_add():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
job_ids = []
|
||||
@@ -633,7 +772,7 @@ def torrent_add():
|
||||
|
||||
@bp.post("/torrents/preview")
|
||||
def torrent_preview():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
existing_hashes = set()
|
||||
if profile:
|
||||
try:
|
||||
@@ -663,12 +802,14 @@ def torrent_preview():
|
||||
|
||||
@bp.post("/speed/limits")
|
||||
def speed_limits():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_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})
|
||||
limits = profile_speed_limits.save_limits(profile["id"], data.get("down"), data.get("up"))
|
||||
# Note: Manual speed limits are stored once per rTorrent profile, so every user opening this profile sees and applies the same values.
|
||||
job_id = enqueue("set_limits", profile["id"], {"down": limits["down"], "up": limits["up"]})
|
||||
return ok({"job_id": job_id, "limits": limits})
|
||||
|
||||
|
||||
def _user_disk_status(profile: dict) -> dict:
|
||||
|
||||
+37
-12
@@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
import secrets
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
@@ -39,8 +36,6 @@ RTORRENT_WRITE_PREFIXES = (
|
||||
)
|
||||
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
|
||||
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
|
||||
# Note: API reads that expose rTorrent/profile data must also respect profile permissions.
|
||||
# Note: Planner, poller and operation-log endpoints are profile-scoped and must follow the active profile context.
|
||||
PROFILE_READ_PREFIXES = (
|
||||
"/api/torrents",
|
||||
"/api/torrent-stats",
|
||||
@@ -101,7 +96,6 @@ def _host_matches_bypass(host: str) -> bool:
|
||||
|
||||
|
||||
def auth_bypassed_request() -> bool:
|
||||
# Note: Allows trusted direct-IP access to keep auth enabled for reverse-proxy traffic.
|
||||
if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context():
|
||||
return False
|
||||
return _host_matches_bypass(request.host)
|
||||
@@ -115,7 +109,6 @@ def bypass_user_id() -> int:
|
||||
row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
# Note: Keep direct-IP access usable after old installs, but never choose an inactive fallback.
|
||||
row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
@@ -126,7 +119,6 @@ def current_user_id() -> int:
|
||||
if not enabled():
|
||||
return default_user_id()
|
||||
if not has_request_context():
|
||||
# Note: Background jobs and schedulers do not have Flask request/session state.
|
||||
return 0
|
||||
if auth_bypassed_request():
|
||||
return bypass_user_id()
|
||||
@@ -728,12 +720,45 @@ def install_guards(app) -> None:
|
||||
def _request_profile_id() -> int | None:
|
||||
if request.view_args and request.view_args.get("profile_id"):
|
||||
return int(request.view_args["profile_id"])
|
||||
payload = {}
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if payload.get("profile_id"):
|
||||
return int(payload.get("profile_id"))
|
||||
except Exception:
|
||||
pass
|
||||
payload = {}
|
||||
raw_id = (
|
||||
request.args.get("profile_id")
|
||||
or request.form.get("profile_id")
|
||||
or payload.get("profile_id")
|
||||
or request.headers.get("X-PyTorrent-Profile-Id")
|
||||
)
|
||||
if raw_id not in (None, ""):
|
||||
try:
|
||||
return int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
raw_name = (
|
||||
request.args.get("profile_name")
|
||||
or request.form.get("profile_name")
|
||||
or payload.get("profile_name")
|
||||
or request.headers.get("X-PyTorrent-Profile-Name")
|
||||
)
|
||||
if raw_name:
|
||||
from . import preferences
|
||||
visible = visible_profile_ids(current_user_id())
|
||||
with connect() as conn:
|
||||
if visible is None:
|
||||
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", (str(raw_name).strip(),)).fetchone()
|
||||
elif visible:
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
row = conn.execute(
|
||||
f"SELECT id FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||
(*tuple(visible), str(raw_name).strip()),
|
||||
).fetchone()
|
||||
else:
|
||||
row = None
|
||||
return int(row["id"]) if row else None
|
||||
from . import preferences
|
||||
profile = preferences.active_profile()
|
||||
return int(profile["id"]) if profile else None
|
||||
if profile:
|
||||
return int(profile["id"])
|
||||
return 1 if can_access_profile(1) else None
|
||||
|
||||
@@ -2,13 +2,25 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import json
|
||||
import threading
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent, auth
|
||||
from .preferences import active_profile
|
||||
from .preferences import active_profile, get_profile, get_disk_monitor_preferences
|
||||
from .workers import enqueue
|
||||
|
||||
AUTOMATION_JOB_CHUNK_SIZE = 100
|
||||
AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'}
|
||||
_CHECK_LOCKS: dict[tuple[int, int | None], threading.Lock] = {}
|
||||
_CHECK_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _check_lock(profile_id: int, rule_id: int | None = None) -> threading.Lock:
|
||||
"""Prevent overlapping automation runs for the same profile or rule."""
|
||||
key = (int(profile_id), int(rule_id) if rule_id is not None else None)
|
||||
with _CHECK_LOCKS_GUARD:
|
||||
if key not in _CHECK_LOCKS:
|
||||
_CHECK_LOCKS[key] = threading.Lock()
|
||||
return _CHECK_LOCKS[key]
|
||||
|
||||
|
||||
def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None = None) -> int:
|
||||
@@ -357,6 +369,8 @@ def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], actio
|
||||
extra.update({'bulk_label': f'automation-{index}', 'bulk_part': index, 'bulk_parts': len(chunks), 'parent_hash_count': len(hashes)})
|
||||
if action_name == 'move':
|
||||
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
|
||||
if action_name == 'profile_transfer':
|
||||
extra.update({'target_profile_id': int(part_payload.get('target_profile_id') or 0), 'target_path': str(part_payload.get('target_path') or ''), 'move_data': bool(part_payload.get('move_data')), 'post_action': str(part_payload.get('post_action') or 'current')})
|
||||
if action_name == 'remove':
|
||||
extra.update({'remove_data': bool(part_payload.get('remove_data'))})
|
||||
effect_type = str(context_extra.get('effect_type') if context_extra else action_name)
|
||||
@@ -365,6 +379,81 @@ def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], actio
|
||||
return job_ids
|
||||
|
||||
|
||||
|
||||
|
||||
def _safe_remote_path(value: str) -> str:
|
||||
path = str(value or '').strip().replace('\\', '/')
|
||||
while '//' in path:
|
||||
path = path.replace('//', '/')
|
||||
if path.endswith('/') and path != '/':
|
||||
path = path.rstrip('/')
|
||||
return path
|
||||
|
||||
def _path_inside_root(path: str, root: str) -> bool:
|
||||
path = _safe_remote_path(path)
|
||||
root = _safe_remote_path(root)
|
||||
return bool(path and root and (path == root or path.startswith(root.rstrip('/') + '/')))
|
||||
|
||||
def _automation_profile_transfer_payload(profile: dict[str, Any], eff: dict[str, Any], user_id: int) -> dict[str, Any]:
|
||||
# Note: Automation profile transfers reuse server-side permission checks; UI values are not trusted.
|
||||
source_id = int(profile.get('id') or 0)
|
||||
if not auth.can_write_profile(source_id, user_id):
|
||||
raise ValueError('Rule owner has no write access to source profile')
|
||||
target_id = int(eff.get('target_profile_id') or 0)
|
||||
if not target_id or target_id == source_id:
|
||||
raise ValueError('Automation target profile is invalid')
|
||||
if not auth.can_write_profile(target_id, user_id):
|
||||
raise ValueError('Rule owner has no write access to target profile')
|
||||
target_profile = get_profile(target_id, user_id)
|
||||
if not target_profile:
|
||||
raise ValueError('Automation target profile does not exist')
|
||||
default_path = _safe_remote_path(rtorrent.default_download_path(target_profile))
|
||||
requested_target_path = _safe_remote_path(str(eff.get('target_path') or eff.get('path') or ''))
|
||||
target_path = requested_target_path or default_path
|
||||
roots = [default_path]
|
||||
try:
|
||||
prefs = get_disk_monitor_preferences(target_id, user_id=user_id)
|
||||
for item in json.loads((prefs or {}).get('disk_monitor_paths_json') or '[]'):
|
||||
clean = _safe_remote_path(str(item or ''))
|
||||
if clean and clean not in roots:
|
||||
roots.append(clean)
|
||||
selected = _safe_remote_path(str((prefs or {}).get('disk_monitor_selected_path') or ''))
|
||||
if selected and selected not in roots:
|
||||
roots.append(selected)
|
||||
except Exception:
|
||||
pass
|
||||
target_roots = [r for r in roots if r]
|
||||
if not any(_path_inside_root(target_path, root) for root in target_roots):
|
||||
if requested_target_path:
|
||||
raise ValueError('Automation target path is outside the target profile download roots')
|
||||
target_path = default_path
|
||||
requested_move_data = bool(eff.get('move_data'))
|
||||
move_data = False
|
||||
downgrade_reason = ''
|
||||
if requested_move_data:
|
||||
check = rtorrent.remote_can_write_directory(profile, target_path)
|
||||
move_data = bool(check.get('ok'))
|
||||
if not move_data:
|
||||
downgrade_reason = str(check.get('message') or check.get('error') or 'target path is not writable by source rTorrent user')
|
||||
post_action = str(eff.get('post_action') or 'current').strip().lower()
|
||||
if post_action not in {'none', 'current', 'start', 'stop', 'pause', 'check', 'recheck'}:
|
||||
post_action = 'current'
|
||||
label_mode = str(eff.get('label_mode') or 'none').strip().lower()
|
||||
if label_mode not in {'none', 'custom', 'moved_from', 'moved_to'}:
|
||||
label_mode = 'none'
|
||||
return {
|
||||
'target_profile_id': target_id,
|
||||
'target_path': target_path,
|
||||
'path': target_path,
|
||||
'move_data': move_data,
|
||||
'move_data_requested': requested_move_data,
|
||||
'move_data_downgraded': bool(requested_move_data and not move_data),
|
||||
'move_data_downgrade_reason': downgrade_reason,
|
||||
'post_action': post_action,
|
||||
'label_mode': label_mode,
|
||||
'label_value': str(eff.get('label_value') or '').strip(),
|
||||
}
|
||||
|
||||
def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]], rule: dict[str, Any], user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')]
|
||||
torrents_by_hash = {str(t.get('hash') or ''): t for t in torrents if str(t.get('hash') or '')}
|
||||
@@ -383,6 +472,11 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
||||
}
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'move', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'move'})
|
||||
applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'job_ids': job_ids})
|
||||
elif typ == 'profile_transfer':
|
||||
owner_id = int(user_id or rule.get('user_id') or rule.get('owner_user_id') or default_user_id())
|
||||
payload = _automation_profile_transfer_payload(profile, eff, owner_id)
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'profile_transfer', hashes, payload, torrents_by_hash, owner_id, {'effect_type': 'profile_transfer'})
|
||||
applied.append({'type': 'profile_transfer', 'target_profile_id': payload['target_profile_id'], 'target_path': payload['target_path'], 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'move_data_requested': payload['move_data_requested'], 'move_data_downgraded': payload['move_data_downgraded'], 'post_action': payload['post_action'], 'label_mode': payload['label_mode'], 'label': payload['label_value'], 'job_ids': job_ids})
|
||||
elif typ == 'add_label':
|
||||
label = str(eff.get('label') or '').strip()
|
||||
if label:
|
||||
@@ -457,6 +551,11 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
profile_id = int(profile['id'])
|
||||
if rule_id is not None:
|
||||
_require_profile_read(profile_id, user_id)
|
||||
lock = _check_lock(profile_id, rule_id)
|
||||
if not lock.acquire(blocking=False):
|
||||
# Note: Browser, manual and background checks can now coexist without duplicate rule application.
|
||||
return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0, 'skipped': True, 'reason': 'Automation check already running'}
|
||||
try:
|
||||
rules = _list_enabled_rules_for_profile(profile_id, rule_id=rule_id, force=force)
|
||||
if not rules:
|
||||
return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0}
|
||||
@@ -504,3 +603,5 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (owner_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now))
|
||||
batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'owner_user_id': owner_id, 'owner_label': rule.get('owner_label'), 'count': len(changed_hashes), 'actions': history_actions})
|
||||
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches}
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from ..db import connect, default_user_id
|
||||
from . import automation_rules, operation_logs, poller_control, rtorrent
|
||||
from .websocket import emit_profile_event
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
_profile_locks: dict[int, threading.Lock] = {}
|
||||
_profile_locks_lock = threading.Lock()
|
||||
_last_logged_status: dict[int, str] = {}
|
||||
|
||||
|
||||
def _configured_interval() -> float:
|
||||
"""Return the minimum background automation interval from environment settings."""
|
||||
try:
|
||||
return max(5.0, min(3600.0, float(os.environ.get("PYTORRENT_AUTOMATION_BACKGROUND_INTERVAL_SECONDS", "15"))))
|
||||
except Exception:
|
||||
return 15.0
|
||||
|
||||
|
||||
def _profiles() -> list[dict[str, Any]]:
|
||||
"""Read configured profiles without relying on a browser session."""
|
||||
with connect() as conn:
|
||||
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||
|
||||
|
||||
def _profile_lock(profile_id: int) -> threading.Lock:
|
||||
"""Keep one automation pass per profile active at a time."""
|
||||
with _profile_locks_lock:
|
||||
if profile_id not in _profile_locks:
|
||||
_profile_locks[profile_id] = threading.Lock()
|
||||
return _profile_locks[profile_id]
|
||||
|
||||
|
||||
def _owner_user_id(profile: dict[str, Any]) -> int:
|
||||
"""Use the profile owner for background checks so rule permissions stay stable."""
|
||||
return int(profile.get("user_id") or default_user_id())
|
||||
|
||||
|
||||
def _profile_interval(profile_id: int) -> float:
|
||||
"""Reuse the existing queue poller cadence instead of adding another UI setting."""
|
||||
settings = poller_control.get_settings(profile_id)
|
||||
return max(_configured_interval(), float(settings.get("queue_stats_interval_seconds") or 15.0))
|
||||
|
||||
|
||||
def _connected(profile: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""Verify rTorrent connectivity before running automation logic."""
|
||||
try:
|
||||
rtorrent.client_for(profile).call("system.client_version")
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _log_status(profile_id: int, status: str, message: str, *, error: str = "") -> None:
|
||||
"""Log only connectivity state changes to avoid noisy system logs."""
|
||||
if _last_logged_status.get(profile_id) == status:
|
||||
return
|
||||
_last_logged_status[profile_id] = status
|
||||
severity = "warning" if error else "info"
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"background_automation_status",
|
||||
message,
|
||||
severity=severity,
|
||||
source="system",
|
||||
action="background_automation",
|
||||
details={"status": status, "error": error},
|
||||
)
|
||||
|
||||
|
||||
def _run_profile(socketio, profile: dict[str, Any]) -> None:
|
||||
"""Run one safe background automation pass for a connected profile."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
return
|
||||
lock = _profile_lock(profile_id)
|
||||
if not lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
ok, error = _connected(profile)
|
||||
if not ok:
|
||||
_log_status(profile_id, "disconnected", f"Background automations waiting for rTorrent: {error}", error=error)
|
||||
return
|
||||
_log_status(profile_id, "connected", "Background automations detected a working rTorrent connection")
|
||||
result = automation_rules.check(profile, user_id=_owner_user_id(profile), force=False)
|
||||
if result.get("applied") or result.get("batches"):
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"background_automation_run",
|
||||
"Background automations applied matching rules",
|
||||
source="system",
|
||||
action="background_automation",
|
||||
details={"applied": len(result.get("applied") or []), "batches": len(result.get("batches") or []), "result": result},
|
||||
user_id=_owner_user_id(profile),
|
||||
)
|
||||
emit_profile_event(socketio, "automation_update", result, profile_id)
|
||||
except Exception as exc:
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"background_automation_error",
|
||||
f"Background automation check failed: {exc}",
|
||||
severity="warning",
|
||||
source="system",
|
||||
action="background_automation",
|
||||
details={"error": str(exc)},
|
||||
user_id=_owner_user_id(profile),
|
||||
)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def start_scheduler(socketio) -> None:
|
||||
"""Start browser-independent automation checks once per application process."""
|
||||
global _started
|
||||
with _start_lock:
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
|
||||
def runner() -> None:
|
||||
last_run: dict[int, float] = {}
|
||||
while True:
|
||||
started = time.monotonic()
|
||||
next_sleep = _configured_interval()
|
||||
for profile in _profiles():
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
continue
|
||||
interval = _profile_interval(profile_id)
|
||||
elapsed = started - float(last_run.get(profile_id) or 0.0)
|
||||
if elapsed < interval:
|
||||
next_sleep = min(next_sleep, max(1.0, interval - elapsed))
|
||||
continue
|
||||
last_run[profile_id] = started
|
||||
_run_profile(socketio, profile)
|
||||
next_sleep = min(next_sleep, interval)
|
||||
socketio.sleep(max(1.0, next_sleep))
|
||||
|
||||
socketio.start_background_task(runner)
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from ..db import connect, default_user_id
|
||||
from . import port_check, preferences, rtorrent, tracker_cache
|
||||
from .torrent_cache import torrent_cache
|
||||
|
||||
STARTUP_DELAY_SECONDS = 60
|
||||
DEFAULT_TRACKER_INTERVAL_SECONDS = 15 * 60
|
||||
DEFAULT_PORT_INTERVAL_SECONDS = port_check.PORT_CHECK_CACHE_SECONDS
|
||||
FAVICON_BATCH_SIZE = 20
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
_status_lock = threading.Lock()
|
||||
_status: dict[str, Any] = {
|
||||
"started": False,
|
||||
"tracker_warmup": {},
|
||||
"port_check": {},
|
||||
}
|
||||
|
||||
|
||||
def _setting_float(name: str, default: float, minimum: float, maximum: float) -> float:
|
||||
"""Read a bounded worker interval from the environment."""
|
||||
# Note: Defaults keep the worker light while still making UI-independent caches fresh after startup.
|
||||
try:
|
||||
value = float(os.environ.get(name, str(default)))
|
||||
except Exception:
|
||||
value = default
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def _profiles() -> list[dict[str, Any]]:
|
||||
"""Read every rTorrent profile directly from the database."""
|
||||
# Note: The worker cannot rely on active browser session state, so it iterates real configured profiles.
|
||||
with connect() as conn:
|
||||
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||
|
||||
|
||||
def _owner_user_id(profile: dict[str, Any]) -> int:
|
||||
"""Return the profile owner used for profile-scoped preferences."""
|
||||
return int(profile.get("user_id") or default_user_id())
|
||||
|
||||
|
||||
def _connected(profile: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""Check rTorrent connectivity without changing user state."""
|
||||
try:
|
||||
rtorrent.client_for(profile).call("system.client_version")
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _remember(section: str, profile_id: int, payload: dict[str, Any]) -> None:
|
||||
"""Store lightweight in-memory diagnostics for app/status."""
|
||||
# Note: Cache warmups are not user operations, so they stay out of operation logs by default.
|
||||
with _status_lock:
|
||||
data = dict(_status.get(section) or {})
|
||||
data[str(profile_id)] = {**payload, "updated_at_epoch": time.time()}
|
||||
_status[section] = data
|
||||
|
||||
|
||||
def status() -> dict[str, Any]:
|
||||
"""Return current worker diagnostics for system status endpoints."""
|
||||
with _status_lock:
|
||||
return {
|
||||
"started": bool(_status.get("started")),
|
||||
"startup_delay_seconds": STARTUP_DELAY_SECONDS,
|
||||
"tracker_warmup": dict(_status.get("tracker_warmup") or {}),
|
||||
"port_check": dict(_status.get("port_check") or {}),
|
||||
}
|
||||
|
||||
|
||||
def _tracker_domains_from_rows(rows: list[dict[str, Any]], summary: dict[str, Any], profile_id: int) -> list[str]:
|
||||
"""Build a bounded tracker domain list from fresh summary data and cached rows."""
|
||||
domains = [str(item.get("domain") or "") for item in summary.get("trackers") or []]
|
||||
if not domains:
|
||||
domains = tracker_cache.cached_domains_for_profile(profile_id, limit=200)
|
||||
return domains
|
||||
|
||||
|
||||
def _warm_tracker_profile(profile: dict[str, Any]) -> None:
|
||||
"""Warm tracker summary cache and optional favicon cache for one profile."""
|
||||
# Note: This mirrors the sidebar warmup, but runs from the backend scheduler instead of waiting for the filter panel.
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
return
|
||||
ok, error = _connected(profile)
|
||||
if not ok:
|
||||
_remember("tracker_warmup", profile_id, {"ok": False, "skipped": True, "reason": "rtorrent_disconnected", "error": error})
|
||||
return
|
||||
|
||||
owner_id = _owner_user_id(profile)
|
||||
prefs = preferences.get_preferences(owner_id, profile_id)
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
if not rows:
|
||||
torrent_cache.refresh(profile)
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
hashes = [str(row.get("hash") or "") for row in rows if row.get("hash")]
|
||||
if not hashes:
|
||||
_remember("tracker_warmup", profile_id, {"ok": True, "skipped": True, "reason": "no_torrents"})
|
||||
return
|
||||
|
||||
loader = lambda h: rtorrent.torrent_trackers(profile, h)
|
||||
summary = tracker_cache.summary(profile, hashes, loader, scan_limit=tracker_cache.TRACKER_SCAN_LIMIT, include_favicons=False)
|
||||
warming = False
|
||||
if int(summary.get("pending") or 0) > 0:
|
||||
warming = tracker_cache.warm_summary_cache(profile, hashes, loader, batch_size=tracker_cache.TRACKER_SCAN_LIMIT)
|
||||
|
||||
favicon_result = {"checked": 0, "cached": 0, "errors": []}
|
||||
if bool((prefs or {}).get("tracker_favicons_enabled")):
|
||||
domains = _tracker_domains_from_rows(rows, summary, profile_id)
|
||||
favicon_result = tracker_cache.warm_favicon_cache(domains, enabled=True, limit=FAVICON_BATCH_SIZE, force=False)
|
||||
|
||||
_remember(
|
||||
"tracker_warmup",
|
||||
profile_id,
|
||||
{
|
||||
"ok": True,
|
||||
"hashes": len(hashes),
|
||||
"pending": int(summary.get("pending") or 0),
|
||||
"scanned_now": int(summary.get("scanned_now") or 0),
|
||||
"warming": bool(warming),
|
||||
"favicons_enabled": bool((prefs or {}).get("tracker_favicons_enabled")),
|
||||
"favicons": favicon_result,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _check_port_profile(profile: dict[str, Any]) -> None:
|
||||
"""Refresh incoming-port status when the profile preference enables it."""
|
||||
# Note: force=False respects the existing six-hour cache and avoids unnecessary external checks.
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
return
|
||||
owner_id = _owner_user_id(profile)
|
||||
prefs = preferences.get_preferences(owner_id, profile_id)
|
||||
if not bool((prefs or {}).get("port_check_enabled")):
|
||||
_remember("port_check", profile_id, {"ok": True, "enabled": False, "skipped": True, "reason": "disabled"})
|
||||
return
|
||||
result = port_check.port_check_status(profile=profile, force=False, user_id=owner_id)
|
||||
_remember(
|
||||
"port_check",
|
||||
profile_id,
|
||||
{
|
||||
"ok": not bool(result.get("error") and result.get("source") == "none"),
|
||||
"enabled": True,
|
||||
"status": result.get("status"),
|
||||
"cached": bool(result.get("cached")),
|
||||
"checked_at": result.get("checked_at"),
|
||||
"error": result.get("error") or result.get("fallback_error") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
"""Start browser-independent cache warmup and port-check scheduler."""
|
||||
global _started
|
||||
with _start_lock:
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
with _status_lock:
|
||||
_status["started"] = True
|
||||
|
||||
tracker_interval = _setting_float("PYTORRENT_CACHE_WARMUP_INTERVAL_SECONDS", DEFAULT_TRACKER_INTERVAL_SECONDS, 60.0, 24 * 60 * 60.0)
|
||||
port_interval = _setting_float("PYTORRENT_PORT_CHECK_INTERVAL_SECONDS", DEFAULT_PORT_INTERVAL_SECONDS, 60.0, 24 * 60 * 60.0)
|
||||
|
||||
def runner() -> None:
|
||||
time.sleep(STARTUP_DELAY_SECONDS)
|
||||
last_tracker: dict[int, float] = {}
|
||||
last_port: dict[int, float] = {}
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
next_sleep = 60.0
|
||||
for profile in _profiles():
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
continue
|
||||
if now - float(last_tracker.get(profile_id) or 0.0) >= tracker_interval:
|
||||
last_tracker[profile_id] = now
|
||||
try:
|
||||
_warm_tracker_profile(profile)
|
||||
except Exception as exc:
|
||||
_remember("tracker_warmup", profile_id, {"ok": False, "error": str(exc)})
|
||||
if now - float(last_port.get(profile_id) or 0.0) >= port_interval:
|
||||
last_port[profile_id] = now
|
||||
try:
|
||||
_check_port_profile(profile)
|
||||
except Exception as exc:
|
||||
_remember("port_check", profile_id, {"ok": False, "error": str(exc)})
|
||||
next_sleep = min(
|
||||
next_sleep,
|
||||
max(1.0, tracker_interval - (time.monotonic() - float(last_tracker.get(profile_id) or 0.0))),
|
||||
max(1.0, port_interval - (time.monotonic() - float(last_port.get(profile_id) or 0.0))),
|
||||
)
|
||||
sleep_for = max(5.0, min(60.0, next_sleep))
|
||||
if socketio:
|
||||
socketio.sleep(sleep_for)
|
||||
else:
|
||||
time.sleep(sleep_for)
|
||||
|
||||
if socketio:
|
||||
socketio.start_background_task(runner)
|
||||
else:
|
||||
threading.Thread(target=runner, daemon=True, name="pytorrent-cache-warmup-scheduler").start()
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
@@ -175,8 +174,8 @@ def create_app_backup(name: str, user_id: int | None = None, automatic: bool = F
|
||||
|
||||
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
if not auth.can_access_profile(profile_id, user_id):
|
||||
raise PermissionError("No access to profile")
|
||||
if not auth.can_write_profile(profile_id, user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
with connect() as conn:
|
||||
for table in PROFILE_BACKUP_TABLES:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..config import DB_PATH
|
||||
|
||||
_VACUUM_LOCK = threading.Lock()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import download_planner
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import psutil
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import auth, rtorrent
|
||||
from . import auth, operation_logs, rtorrent
|
||||
|
||||
PLANNER_STARTUP_DELAY_SECONDS = 60
|
||||
_APP_STARTED_AT = time.monotonic()
|
||||
|
||||
DEFAULTS = {
|
||||
"enabled": False,
|
||||
@@ -45,6 +46,57 @@ DEFAULTS = {
|
||||
_LAST_RUN: dict[int, float] = {}
|
||||
_LAST_LIMITS: dict[int, tuple[int, int]] = {}
|
||||
_HIGH_CPU_SINCE: dict[int, float] = {}
|
||||
_PLANNER_CONNECTION_STATUS: dict[int, str] = {}
|
||||
_SCHEDULER_STARTED = False
|
||||
_SCHEDULER_LOCK = threading.Lock()
|
||||
_PROFILE_LOCKS: dict[int, threading.Lock] = {}
|
||||
_PROFILE_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _profile_lock(profile_id: int) -> threading.Lock:
|
||||
"""Keep one planner run per profile active at a time."""
|
||||
with _PROFILE_LOCKS_GUARD:
|
||||
if profile_id not in _PROFILE_LOCKS:
|
||||
_PROFILE_LOCKS[profile_id] = threading.Lock()
|
||||
return _PROFILE_LOCKS[profile_id]
|
||||
|
||||
|
||||
def _all_profiles() -> list[dict]:
|
||||
"""Read every configured profile directly from DB for browser-independent background work."""
|
||||
with connect() as conn:
|
||||
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||
|
||||
|
||||
def _owner_user_id(profile: dict) -> int:
|
||||
"""Use the profile owner for background planner checks."""
|
||||
return int(profile.get("user_id") or default_user_id())
|
||||
|
||||
|
||||
def _rtorrent_ready(profile: dict) -> tuple[bool, str]:
|
||||
"""Check rTorrent connectivity before the planner evaluates or applies changes."""
|
||||
try:
|
||||
rtorrent.client_for(profile).call("system.client_version")
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _log_connection_status(profile: dict, status: str, message: str, *, error: str = "", user_id: int | None = None) -> None:
|
||||
"""Record planner connectivity state changes as system operations without noisy repeats."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if _PLANNER_CONNECTION_STATUS.get(profile_id) == status:
|
||||
return
|
||||
_PLANNER_CONNECTION_STATUS[profile_id] = status
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"download_planner_status",
|
||||
message,
|
||||
severity="warning" if error else "info",
|
||||
source="system",
|
||||
action="download_planner",
|
||||
details={"status": status, "error": error},
|
||||
user_id=user_id or int(profile.get("user_id") or 0) or None,
|
||||
)
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
@@ -323,7 +375,7 @@ def _active_downloading_hashes(profile: dict) -> list[str]:
|
||||
for row in rows:
|
||||
if int(row.get("complete") or 0):
|
||||
continue
|
||||
if int(row.get("state") or 0) and not row.get("paused"):
|
||||
if int(row.get("state") or 0) and not row.get("paused") and str(row.get("status") or "") != "Queued":
|
||||
h = str(row.get("hash") or "")
|
||||
if h:
|
||||
hashes.append(h)
|
||||
@@ -471,11 +523,20 @@ def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> d
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id, "skipped": True, "reason": "planner owner has no write access", "history": history(profile_id, 20), "history_total": history_count(profile_id)}
|
||||
if not settings.get("enabled"):
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile, user_id=user_id)}
|
||||
startup_remaining = int(PLANNER_STARTUP_DELAY_SECONDS - (time.monotonic() - _APP_STARTED_AT))
|
||||
if not force and startup_remaining > 0:
|
||||
# Note: The background planner keeps the same startup grace as rTorrent config apply, while manual checks still run immediately.
|
||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True, "reason": "startup_delay", "retry_after_seconds": startup_remaining}
|
||||
now = time.monotonic()
|
||||
interval = int(settings.get("check_interval_seconds") or 30)
|
||||
if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
|
||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True}
|
||||
_LAST_RUN[profile_id] = now
|
||||
ready, connection_error = _rtorrent_ready(profile)
|
||||
if not ready:
|
||||
_log_connection_status(profile, "waiting", f"Download Planner is waiting for rTorrent: {connection_error}", error=connection_error, user_id=user_id)
|
||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True, "reason": "rtorrent_unavailable", "error": connection_error, "retry_after_seconds": interval}
|
||||
_log_connection_status(profile, "connected", "Download Planner detected a working rTorrent connection", user_id=user_id)
|
||||
decision = evaluate(profile, settings)
|
||||
result: dict[str, Any] = {"ok": True, "enabled": True, **decision, "limits_changed": False, "paused": 0, "resumed": 0}
|
||||
wanted_limits = (int(decision["down"]), int(decision["up"]))
|
||||
@@ -543,32 +604,42 @@ def preview(profile: dict, user_id: int | None = None) -> dict:
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
"""Start the browser-independent planner loop for every configured profile."""
|
||||
global _SCHEDULER_STARTED
|
||||
with _SCHEDULER_LOCK:
|
||||
if _SCHEDULER_STARTED:
|
||||
return
|
||||
_SCHEDULER_STARTED = True
|
||||
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
from .preferences import active_profile
|
||||
from .websocket import emit_profile_event
|
||||
from . import auth
|
||||
profiles: list[dict]
|
||||
if auth.enabled():
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
||||
else:
|
||||
profile = active_profile()
|
||||
profiles = [profile] if profile else []
|
||||
for profile in profiles:
|
||||
for profile in _all_profiles():
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
continue
|
||||
lock = _profile_lock(profile_id)
|
||||
if not lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
result = enforce(profile, force=False)
|
||||
# Note: Background planner runs per configured profile with the profile owner, not only for the active UI profile.
|
||||
result = enforce(profile, force=False, user_id=_owner_user_id(profile))
|
||||
if socketio and result.get("enabled") and not result.get("skipped"):
|
||||
emit_profile_event(socketio, "download_plan_update", result, int(profile["id"]))
|
||||
emit_profile_event(socketio, "download_plan_update", result, profile_id)
|
||||
except Exception as exc:
|
||||
if socketio:
|
||||
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": int(profile.get("id") or 0), "error": str(exc)}, int(profile.get("id") or 0))
|
||||
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
finally:
|
||||
lock.release()
|
||||
except Exception:
|
||||
pass
|
||||
if socketio:
|
||||
socketio.sleep(30)
|
||||
else:
|
||||
time.sleep(30)
|
||||
|
||||
if socketio:
|
||||
socketio.start_background_task(loop)
|
||||
else:
|
||||
threading.Thread(target=loop, daemon=True, name="pytorrent-download-planner-scheduler").start()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR, USE_OFFLINE_LIBS
|
||||
|
||||
LIBS_STATIC_DIR = "libs"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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
|
||||
except Exception:
|
||||
geoip2 = None
|
||||
|
||||
_reader = None
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
@@ -81,7 +80,7 @@ def _details_summary(details: dict) -> str:
|
||||
priority = [
|
||||
"status", "job_id", "attempt", "attempts", "count", "hash_count", "action",
|
||||
"source", "source_label", "directory", "label", "target_path", "remove_data",
|
||||
"move_data", "keep_seeding", "error", "error_count", "result_count",
|
||||
"move_data", "target_profile_id", "move_data_downgraded", "keep_seeding", "error", "error_count", "result_count",
|
||||
]
|
||||
parts: list[str] = []
|
||||
for key in priority:
|
||||
@@ -155,19 +154,31 @@ def _next_retention_run(settings: dict, category: str) -> str | None:
|
||||
return (last + timedelta(hours=int(settings.get(f"{category}_retention_interval_hours") or 24))).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _profile_settings_owner_id() -> int:
|
||||
"""Use one canonical owner for profile-level retention settings."""
|
||||
return 0
|
||||
|
||||
|
||||
def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
|
||||
user_id = _user_id(user_id)
|
||||
"""Return profile-level retention settings, with legacy per-user rows as fallback only."""
|
||||
profile_id = int(profile_id or 0)
|
||||
owner_id = _profile_settings_owner_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM operation_log_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1",
|
||||
(profile_id,),
|
||||
"""
|
||||
SELECT *
|
||||
FROM operation_log_settings
|
||||
WHERE profile_id=?
|
||||
ORDER BY CASE WHEN user_id=? THEN 0 ELSE 1 END, updated_at DESC, user_id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id, owner_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
data = {"owner_user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
||||
data = {"owner_user_id": owner_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
||||
else:
|
||||
data = {**DEFAULT_SETTINGS, **dict(row)}
|
||||
data["owner_user_id"] = int(data.pop("user_id", user_id) or user_id)
|
||||
data["owner_user_id"] = int(data.pop("user_id", owner_id) or owner_id)
|
||||
data["profile_id"] = profile_id
|
||||
data["retention_mode"] = _sanitize_mode(data.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"])
|
||||
data["retention_days"] = _sanitize_days(data.get("retention_days"), DEFAULT_SETTINGS["retention_days"])
|
||||
@@ -186,9 +197,11 @@ def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
|
||||
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||
user_id = _user_id(user_id)
|
||||
profile_id = int(profile_id or 0)
|
||||
owner_id = _profile_settings_owner_id()
|
||||
now = utcnow()
|
||||
if not auth.can_write_profile(profile_id, user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
# Note: retention is intentionally shared by every user that works on the same profile.
|
||||
current = get_settings(profile_id, user_id)
|
||||
legacy_mode = _sanitize_mode(data.get("retention_mode") or current.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"])
|
||||
legacy_days = _sanitize_days(data.get("retention_days") or current.get("retention_days"), DEFAULT_SETTINGS["retention_days"])
|
||||
@@ -237,7 +250,7 @@ def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> di
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
user_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"],
|
||||
owner_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"],
|
||||
values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"],
|
||||
values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"],
|
||||
now, now,
|
||||
@@ -302,6 +315,7 @@ def _job_action_label(action: str) -> str:
|
||||
"set_ratio_group": "Set ratio group",
|
||||
"set_limits": "Set speed limits",
|
||||
"smart_queue_check": "Smart Queue check",
|
||||
"profile_transfer": "Move to another profile",
|
||||
}
|
||||
return labels.get(str(action or ""), str(action or "job"))
|
||||
|
||||
@@ -341,6 +355,8 @@ def record_job_event(profile_id: int, action: str, status: str, payload: dict |
|
||||
"target_path": ctx.get("target_path") or payload.get("path"),
|
||||
"remove_data": ctx.get("remove_data") or payload.get("remove_data"),
|
||||
"move_data": ctx.get("move_data") or payload.get("move_data"),
|
||||
"target_profile_id": ctx.get("target_profile_id") or payload.get("target_profile_id"),
|
||||
"move_data_downgraded": ctx.get("move_data_downgraded") or payload.get("move_data_downgraded"),
|
||||
"keep_seeding": payload.get("keep_seeding"),
|
||||
"hash_count": len(hashes),
|
||||
"error": error,
|
||||
@@ -478,29 +494,57 @@ def _apply_retention_category(conn, profile_id: int, settings: dict, category: s
|
||||
|
||||
|
||||
def _update_retention_metadata(conn, profile_id: int, category: str, deleted: int, settings: dict, user_id: int | None = None) -> None:
|
||||
"""Update last retention state on the shared profile settings row."""
|
||||
now = utcnow()
|
||||
owner_id = int(settings.get("owner_user_id") or _user_id(user_id))
|
||||
owner_id = _profile_settings_owner_id()
|
||||
profile_id = int(profile_id or 0)
|
||||
cur = conn.execute(
|
||||
f"""
|
||||
UPDATE operation_log_settings
|
||||
SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=?
|
||||
WHERE profile_id=?
|
||||
WHERE user_id=? AND profile_id=?
|
||||
""",
|
||||
(now, int(deleted or 0), now, profile_id),
|
||||
(now, int(deleted or 0), now, owner_id, profile_id),
|
||||
)
|
||||
if int(cur.rowcount or 0) == 0:
|
||||
# Note: preserve legacy settings when creating the shared profile row lazily.
|
||||
values = {
|
||||
"retention_mode": _sanitize_mode(settings.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"]),
|
||||
"retention_days": _sanitize_days(settings.get("retention_days"), DEFAULT_SETTINGS["retention_days"]),
|
||||
"retention_lines": _sanitize_lines(settings.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"]),
|
||||
"retention_interval_hours": _sanitize_interval(settings.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"]),
|
||||
}
|
||||
for cat, defaults in DEFAULT_CATEGORY_SETTINGS.items():
|
||||
values[f"{cat}_retention_mode"] = _sanitize_mode(settings.get(f"{cat}_retention_mode"), defaults["retention_mode"])
|
||||
values[f"{cat}_retention_days"] = _sanitize_days(settings.get(f"{cat}_retention_days"), defaults["retention_days"])
|
||||
values[f"{cat}_retention_lines"] = _sanitize_lines(settings.get(f"{cat}_retention_lines"), defaults["retention_lines"])
|
||||
values[f"{cat}_retention_interval_hours"] = _sanitize_interval(settings.get(f"{cat}_retention_interval_hours"), defaults["retention_interval_hours"])
|
||||
values[f"{cat}_last_retention_run_at"] = settings.get(f"{cat}_last_retention_run_at")
|
||||
values[f"{cat}_last_retention_deleted"] = int(settings.get(f"{cat}_last_retention_deleted") or 0)
|
||||
values[f"{category}_last_retention_run_at"] = now
|
||||
values[f"{category}_last_retention_deleted"] = int(deleted or 0)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO operation_log_settings(user_id, profile_id, created_at, updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET updated_at=excluded.updated_at
|
||||
INSERT INTO operation_log_settings(
|
||||
user_id, profile_id, retention_mode, retention_days, retention_lines,
|
||||
retention_interval_hours,
|
||||
job_retention_mode, job_retention_days, job_retention_lines, job_retention_interval_hours, job_last_retention_run_at, job_last_retention_deleted,
|
||||
operation_retention_mode, operation_retention_days, operation_retention_lines, operation_retention_interval_hours, operation_last_retention_run_at, operation_last_retention_deleted,
|
||||
created_at, updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||
job_last_retention_run_at=excluded.job_last_retention_run_at,
|
||||
job_last_retention_deleted=excluded.job_last_retention_deleted,
|
||||
operation_last_retention_run_at=excluded.operation_last_retention_run_at,
|
||||
operation_last_retention_deleted=excluded.operation_last_retention_deleted,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(owner_id, profile_id, now, now),
|
||||
)
|
||||
conn.execute(
|
||||
f"UPDATE operation_log_settings SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=? WHERE profile_id=?",
|
||||
(now, int(deleted or 0), now, profile_id),
|
||||
(
|
||||
owner_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"],
|
||||
values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"],
|
||||
values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"],
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
@@ -18,7 +17,6 @@ def _cleanup_expired(now: float | None = None) -> None:
|
||||
|
||||
def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict:
|
||||
"""Create a short-lived in-app link target used by preview and download routes."""
|
||||
# Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI.
|
||||
now = time.time()
|
||||
token = secrets.token_urlsafe(24)
|
||||
with _TEMPORARY_LINK_LOCK:
|
||||
@@ -35,7 +33,6 @@ def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: di
|
||||
|
||||
def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a short-lived in-app PDF preview link without exposing the API download URL."""
|
||||
# Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader.
|
||||
return _create_temporary_link(
|
||||
"pdf_preview",
|
||||
profile_id,
|
||||
@@ -46,7 +43,6 @@ def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int,
|
||||
|
||||
def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for one torrent file."""
|
||||
# Note: File downloads use /download/<token> in the UI, but the backend keeps the same rTorrent streaming logic.
|
||||
return _create_temporary_link(
|
||||
"file_download",
|
||||
profile_id,
|
||||
@@ -57,7 +53,6 @@ def create_file_download_link(torrent_hash: str, file_index: int, profile_id: in
|
||||
|
||||
def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for a ZIP of torrent files."""
|
||||
# Note: Selected indexes are stored with the token so the final /download route does not need an API body.
|
||||
clean_indexes = None if indexes is None else [int(index) for index in indexes]
|
||||
return _create_temporary_link(
|
||||
"file_zip_download",
|
||||
@@ -69,7 +64,6 @@ def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None,
|
||||
|
||||
def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for an exported .torrent file."""
|
||||
# Note: The token hides the stable export API URL from browser-visible download actions.
|
||||
return _create_temporary_link(
|
||||
"torrent_file_download",
|
||||
profile_id,
|
||||
@@ -80,7 +74,6 @@ def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_i
|
||||
|
||||
def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for a ZIP of exported .torrent files."""
|
||||
# Note: Hashes are copied into the token target after the API validates that the request is non-empty.
|
||||
return _create_temporary_link(
|
||||
"torrent_files_zip_download",
|
||||
profile_id,
|
||||
@@ -91,7 +84,6 @@ def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, u
|
||||
|
||||
def get_temporary_link(token: str) -> dict | None:
|
||||
"""Return a temporary target if the link is still valid."""
|
||||
# Note: Expired links are removed on read so stale browser tabs stop resolving automatically.
|
||||
clean = str(token or "").strip()
|
||||
if not clean:
|
||||
return None
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
|
||||
|
||||
@@ -81,7 +79,6 @@ def normalize_settings(data: dict | None) -> dict:
|
||||
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
|
||||
}
|
||||
if settings["safe_fallback_enabled"]:
|
||||
# Note: Safe fallback keeps existing functionality, but prevents very aggressive polling from overloading rTorrent or the browser.
|
||||
for key, minimum in SAFE_FALLBACK_MINIMUMS.items():
|
||||
settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum))
|
||||
return settings
|
||||
@@ -91,7 +88,6 @@ def get_settings(profile_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
|
||||
if not row:
|
||||
# Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read.
|
||||
legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||
if legacy:
|
||||
try:
|
||||
@@ -240,7 +236,6 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change
|
||||
|
||||
def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", updated_count: int = 0, requires_full_refresh: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
# Note: Live poller diagnostics track only lightweight speed/status refreshes, not the full torrent snapshot loop.
|
||||
state.live_poll_count += 1
|
||||
state.last_live_duration_ms = round((now - started_at) * 1000.0, 2)
|
||||
state.last_live_updated_count = int(updated_count or 0)
|
||||
@@ -254,7 +249,6 @@ def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error:
|
||||
|
||||
def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", added_count: int = 0, updated_count: int = 0, removed_count: int = 0) -> None:
|
||||
now = time.monotonic()
|
||||
# Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation.
|
||||
state.list_poll_count += 1
|
||||
state.last_list_duration_ms = round((now - started_at) * 1000.0, 2)
|
||||
state.last_list_added_count = int(added_count or 0)
|
||||
@@ -269,7 +263,6 @@ def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error:
|
||||
|
||||
def reset_runtime_stats(profile_id: int) -> dict:
|
||||
state = state_for(profile_id)
|
||||
# Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged.
|
||||
state.tick_count = 0
|
||||
state.last_tick_ms = 0.0
|
||||
state.last_tick_gap_ms = 0.0
|
||||
@@ -385,10 +378,19 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
|
||||
return dict(state.stats)
|
||||
|
||||
|
||||
def snapshot(profile_id: int) -> dict:
|
||||
def snapshot(profile_id: int, settings: dict | None = None) -> dict:
|
||||
state = state_for(profile_id)
|
||||
effective_settings = normalize_settings(settings) if settings is not None else get_settings(profile_id)
|
||||
data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
||||
# Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats.
|
||||
runtime_ready = bool(state.stats) or state.tick_count > 0
|
||||
data.setdefault("runtime_ready", runtime_ready)
|
||||
data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"])))
|
||||
data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting"))
|
||||
data.setdefault("live_stats_interval_seconds", effective_live_interval(effective_settings, state))
|
||||
data.setdefault("torrent_list_interval_seconds", effective_list_interval(effective_settings, state))
|
||||
data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS)
|
||||
if not runtime_ready:
|
||||
data["last_ok"] = None
|
||||
data.update({
|
||||
"live_poll_count": state.live_poll_count,
|
||||
"list_poll_count": state.list_poll_count,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from ..db import connect
|
||||
from . import preferences, rtorrent
|
||||
|
||||
PORT_CHECK_CACHE_SECONDS = 6 * 60 * 60
|
||||
MAX_PORT_CHECK_CANDIDATES = 256
|
||||
|
||||
|
||||
def _app_setting_get(key: str) -> str | None:
|
||||
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) -> None:
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, value))
|
||||
|
||||
|
||||
def _iso_from_epoch(value: Any) -> 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 _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
|
||||
"""Return valid incoming port candidates from rTorrent network.port_range."""
|
||||
ports: list[int] = []
|
||||
seen: set[int] = set()
|
||||
truncated = False
|
||||
|
||||
def add(port: int) -> None:
|
||||
nonlocal truncated
|
||||
if not 1 <= port <= 65535 or port in seen:
|
||||
return
|
||||
if len(ports) >= limit:
|
||||
truncated = True
|
||||
return
|
||||
seen.add(port)
|
||||
ports.append(port)
|
||||
|
||||
for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
|
||||
a, b = int(start), int(end)
|
||||
if a > b:
|
||||
a, b = b, a
|
||||
for port in range(a, b + 1):
|
||||
add(port)
|
||||
if truncated:
|
||||
break
|
||||
|
||||
without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
|
||||
for item in re.findall(r"\d{1,5}", without_ranges):
|
||||
add(int(item))
|
||||
|
||||
return ports, truncated
|
||||
|
||||
|
||||
def _incoming_ports(profile: dict) -> dict:
|
||||
try:
|
||||
raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
|
||||
except Exception:
|
||||
raw_value = ""
|
||||
ports, truncated = _parse_port_candidates(raw_value)
|
||||
return {"ports": ports, "raw": raw_value, "truncated": truncated}
|
||||
|
||||
|
||||
def _yougetsignal_check(public_ip: str, port: int) -> dict:
|
||||
body = urllib.parse.urlencode({"remoteAddress": public_ip, "portNumber": str(port)}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"https://ports.yougetsignal.com/check-port.php",
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"User-Agent": "pyTorrent/port-check",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=12) as res:
|
||||
text = res.read(8192).decode("utf-8", "replace")
|
||||
low = text.lower()
|
||||
if "is open" in low:
|
||||
return {"status": "open", "source": "yougetsignal", "raw": text[:500]}
|
||||
if "is closed" in low:
|
||||
return {"status": "closed", "source": "yougetsignal", "raw": text[:500]}
|
||||
return {"status": "unknown", "source": "yougetsignal", "raw": text[:500]}
|
||||
|
||||
|
||||
def _local_port_fallback(public_ip: str, port: int) -> dict:
|
||||
try:
|
||||
with socket.create_connection((public_ip, port), timeout=3):
|
||||
return {"status": "open", "source": "local-fallback"}
|
||||
except Exception as exc:
|
||||
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
|
||||
|
||||
|
||||
def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
|
||||
checked: list[int] = []
|
||||
first_closed: dict | None = None
|
||||
last_result: dict = {"status": "unknown"}
|
||||
|
||||
for port in ports:
|
||||
checked.append(port)
|
||||
current = checker(public_ip, port)
|
||||
last_result = current
|
||||
if current.get("status") == "open":
|
||||
current.update({"port": port, "open_port": port, "checked_ports": checked})
|
||||
return current
|
||||
if current.get("status") == "closed" and first_closed is None:
|
||||
first_closed = current
|
||||
|
||||
result = first_closed or last_result
|
||||
result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
|
||||
return result
|
||||
|
||||
|
||||
def port_check_status(profile: dict | None = None, force: bool = False, user_id: int | None = None) -> dict:
|
||||
"""Return cached or freshly checked incoming-port status for one rTorrent profile."""
|
||||
profile = profile or preferences.active_profile(user_id)
|
||||
prefs = preferences.get_preferences(user_id, int(profile.get("id"))) if profile else preferences.get_preferences(user_id)
|
||||
enabled = bool((prefs or {}).get("port_check_enabled"))
|
||||
if not profile:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
|
||||
|
||||
port_info = _incoming_ports(profile)
|
||||
ports = port_info["ports"]
|
||||
if not ports:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
|
||||
|
||||
ports_key = ",".join(str(port) for port in ports)
|
||||
cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
|
||||
if not force:
|
||||
cached = _app_setting_get(cache_key)
|
||||
if cached:
|
||||
try:
|
||||
data = json.loads(cached)
|
||||
if time.time() - float(data.get("checked_at_epoch") or 0) < PORT_CHECK_CACHE_SECONDS:
|
||||
data["cached"] = True
|
||||
data["enabled"] = enabled
|
||||
if not data.get("checked_at"):
|
||||
data["checked_at"] = _iso_from_epoch(data.get("checked_at_epoch"))
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
checked_at_epoch = time.time()
|
||||
result = {
|
||||
"status": "unknown",
|
||||
"enabled": enabled,
|
||||
"port": ports[0],
|
||||
"ports": ports,
|
||||
"port_range": port_info["raw"],
|
||||
"ports_truncated": port_info["truncated"],
|
||||
"checked_at_epoch": checked_at_epoch,
|
||||
"checked_at": _iso_from_epoch(checked_at_epoch),
|
||||
"cached": False,
|
||||
}
|
||||
try:
|
||||
public_ip = _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _yougetsignal_check))
|
||||
except Exception as exc:
|
||||
result["error"] = f"YouGetSignal failed: {exc}"
|
||||
try:
|
||||
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _local_port_fallback))
|
||||
except Exception as fallback_exc:
|
||||
result["fallback_error"] = str(fallback_exc)
|
||||
result["source"] = "none"
|
||||
_app_setting_set(cache_key, json.dumps(result))
|
||||
return result
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth
|
||||
from .frontend_assets import BOOTSTRAP_THEME_LABELS
|
||||
@@ -28,7 +26,6 @@ FONT_FAMILIES = {
|
||||
"adwaita-mono": "Adwaita Mono",
|
||||
}
|
||||
|
||||
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
||||
RECOMMENDED_TABLE_COLUMNS = {
|
||||
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
|
||||
"shown": ["down_total", "to_download", "up_total", "created"],
|
||||
@@ -448,7 +445,7 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
||||
value = str(data.get("active_filter") or "all").strip()
|
||||
if not value or len(value) > 180:
|
||||
value = "all"
|
||||
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
|
||||
allowed_static_filters = {"all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
|
||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||
value = "all"
|
||||
updates["active_filter"] = value
|
||||
@@ -491,9 +488,9 @@ def get_preferences(user_id: int | None = None, profile_id: int | None = None):
|
||||
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
||||
return merged
|
||||
|
||||
def save_preferences(data: dict, user_id: int | None = None):
|
||||
def save_preferences(data: dict, user_id: int | None = None, profile_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = _active_profile_id_for_user(user_id)
|
||||
profile_id = profile_id or _active_profile_id_for_user(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
|
||||
@@ -580,3 +577,77 @@ def save_preferences(data: dict, user_id: int | None = None):
|
||||
if disk_payload is not None:
|
||||
save_disk_monitor_preferences(profile_id, disk_payload, user_id)
|
||||
return get_preferences(user_id, profile_id)
|
||||
|
||||
|
||||
def _row_int(row: dict, key: str) -> int:
|
||||
try:
|
||||
return int(float(row.get(key) or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def profile_runtime_stats_from_rows(profile: dict, rows: list[dict], user_id: int | None = None) -> dict:
|
||||
# Note: Stored profile stats are intentionally approximate and updated only when the user switches to that profile.
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
total_size = completed = downloaded = uploaded = active = seeding = downloading = stopped = 0
|
||||
for row in rows or []:
|
||||
size = _row_int(row, 'size')
|
||||
total_size += size
|
||||
completed += min(size, _row_int(row, 'completed_bytes')) if size else _row_int(row, 'completed_bytes')
|
||||
downloaded += _row_int(row, 'down_total')
|
||||
uploaded += _row_int(row, 'up_total')
|
||||
status = str(row.get('status') or '').strip().lower()
|
||||
state = bool(row.get('state'))
|
||||
complete = bool(row.get('complete'))
|
||||
if state:
|
||||
active += 1
|
||||
if complete and state:
|
||||
seeding += 1
|
||||
if not complete and state and status != 'queued':
|
||||
downloading += 1
|
||||
if not state:
|
||||
stopped += 1
|
||||
return {
|
||||
'profile_id': int(profile.get('id') or 0),
|
||||
'user_id': int(user_id),
|
||||
'torrent_count': len(rows or []),
|
||||
'total_size_bytes': total_size,
|
||||
'completed_bytes': completed,
|
||||
'downloaded_bytes': downloaded,
|
||||
'uploaded_bytes': uploaded,
|
||||
'active_count': active,
|
||||
'seeding_count': seeding,
|
||||
'downloading_count': downloading,
|
||||
'stopped_count': stopped,
|
||||
'updated_at': utcnow(),
|
||||
}
|
||||
|
||||
|
||||
def save_profile_runtime_stats(profile: dict, rows: list[dict], user_id: int | None = None) -> dict:
|
||||
stats = profile_runtime_stats_from_rows(profile, rows, user_id=user_id)
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO profile_runtime_stats(
|
||||
profile_id,user_id,torrent_count,total_size_bytes,completed_bytes,downloaded_bytes,uploaded_bytes,
|
||||
active_count,seeding_count,downloading_count,stopped_count,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
user_id=excluded.user_id, torrent_count=excluded.torrent_count, total_size_bytes=excluded.total_size_bytes,
|
||||
completed_bytes=excluded.completed_bytes, downloaded_bytes=excluded.downloaded_bytes, uploaded_bytes=excluded.uploaded_bytes,
|
||||
active_count=excluded.active_count, seeding_count=excluded.seeding_count, downloading_count=excluded.downloading_count,
|
||||
stopped_count=excluded.stopped_count, updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
stats['profile_id'], stats['user_id'], stats['torrent_count'], stats['total_size_bytes'], stats['completed_bytes'],
|
||||
stats['downloaded_bytes'], stats['uploaded_bytes'], stats['active_count'], stats['seeding_count'],
|
||||
stats['downloading_count'], stats['stopped_count'], stats['updated_at'],
|
||||
),
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def get_profile_runtime_stats(profile_id: int) -> dict | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM profile_runtime_stats WHERE profile_id=?", (int(profile_id),)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
from ..db import connect, utcnow
|
||||
|
||||
|
||||
def normalize_limit(value: object) -> int:
|
||||
try:
|
||||
limit = int(float(value or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(0, limit)
|
||||
|
||||
|
||||
def get_limits(profile_id: int | None) -> dict:
|
||||
profile_id = int(profile_id or 0)
|
||||
if not profile_id:
|
||||
return {"down": 0, "up": 0, "configured": False}
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT down_limit, up_limit FROM profile_speed_limits WHERE profile_id=?", (profile_id,)).fetchone()
|
||||
if not row:
|
||||
return {"down": 0, "up": 0, "configured": False}
|
||||
return {"down": int(row.get("down_limit") or 0), "up": int(row.get("up_limit") or 0), "configured": True}
|
||||
|
||||
|
||||
def save_limits(profile_id: int, down: object, up: object) -> dict:
|
||||
profile_id = int(profile_id or 0)
|
||||
if not profile_id:
|
||||
raise ValueError("Missing profile id")
|
||||
clean = {"down": normalize_limit(down), "up": normalize_limit(up), "configured": True}
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO profile_speed_limits(profile_id, down_limit, up_limit, created_at, updated_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
down_limit=excluded.down_limit,
|
||||
up_limit=excluded.up_limit,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(profile_id, clean["down"], clean["up"], now, now),
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def delete_limits(profile_id: int) -> None:
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM profile_speed_limits WHERE profile_id=?", (int(profile_id or 0),))
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth, rtorrent
|
||||
from .workers import enqueue
|
||||
@@ -139,10 +137,12 @@ def start_scheduler(socketio=None) -> None:
|
||||
profile_id = int(row["profile_id"])
|
||||
with connect() as conn:
|
||||
owner = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
profile = get_profile(profile_id, int(owner["user_id"] if owner and owner.get("user_id") else default_user_id()))
|
||||
owner_id = int(owner["user_id"] if owner and owner.get("user_id") else default_user_id())
|
||||
profile = get_profile(profile_id, owner_id)
|
||||
if not profile:
|
||||
continue
|
||||
result = check(profile)
|
||||
# Note: Ratio rules are evaluated per profile owner, not the active browser user.
|
||||
result = check(profile, user_id=owner_id)
|
||||
if socketio and result.get("applied"):
|
||||
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||
except Exception:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -201,9 +200,14 @@ def start_scheduler(socketio=None) -> None:
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT DISTINCT profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
for row in profiles:
|
||||
profile = get_profile(int(row["profile_id"]))
|
||||
profile_id = int(row["profile_id"])
|
||||
with connect() as conn:
|
||||
owner = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
owner_id = int(owner["user_id"] if owner and owner.get("user_id") else default_user_id())
|
||||
profile = get_profile(profile_id, owner_id)
|
||||
if profile:
|
||||
result = check(profile, only_due=True)
|
||||
# Note: RSS jobs run with the profile owner in background mode, independent of browser activity.
|
||||
result = check(profile, user_id=owner_id, only_due=True)
|
||||
if socketio and result.get("queued"):
|
||||
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||
except Exception:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# rTorrent service modules
|
||||
|
||||
The old `pytorrent/services/rtorrent.py` monolith is end-of-life.
|
||||
Do not recreate it and do not add new rTorrent logic outside this directory.
|
||||
|
||||
Use focused modules in `pytorrent/services/rtorrent/` instead:
|
||||
- `client.py` for SCGI/XMLRPC transport and shared caches.
|
||||
- `system.py` for status, footer metrics, disk and remote host usage.
|
||||
- `torrents.py` for torrent list and torrent operations.
|
||||
- `files.py`, `config.py`, `diagnostics.py` for their dedicated areas.
|
||||
@@ -1,10 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith.
|
||||
# All rTorrent code belongs in this package directory.
|
||||
|
||||
# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent.
|
||||
# Compatibility note: module __all__ definitions include selected private helpers used by existing routes.
|
||||
from .client import *
|
||||
from .system import *
|
||||
from .diagnostics import *
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from .client import *
|
||||
@@ -11,13 +10,11 @@ _HEX_RE = re.compile(r"[0-9a-fA-F]")
|
||||
|
||||
def _clean_hex_bitfield(value) -> str:
|
||||
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
||||
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
|
||||
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
||||
|
||||
|
||||
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
||||
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
||||
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
|
||||
bits: list[int] = []
|
||||
for char in _clean_hex_bitfield(value):
|
||||
nibble = int(char, 16)
|
||||
@@ -47,7 +44,6 @@ def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
|
||||
|
||||
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
||||
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
|
||||
if max_cells <= 0 or len(cells) <= max_cells:
|
||||
return cells
|
||||
grouped: list[dict] = []
|
||||
@@ -79,7 +75,6 @@ def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
|
||||
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
||||
"""Create one raw cell per real torrent piece."""
|
||||
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
|
||||
cells: list[dict] = []
|
||||
for idx in range(max(0, int(total_chunks or 0))):
|
||||
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
||||
@@ -101,7 +96,6 @@ def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[
|
||||
|
||||
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
||||
"""Return ruTorrent-like visual chunk data for one torrent."""
|
||||
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
|
||||
c = client_for(profile)
|
||||
values = {
|
||||
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
||||
@@ -177,7 +171,6 @@ def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk
|
||||
|
||||
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
||||
"""Run safe actions related to visual chunk selection."""
|
||||
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
|
||||
payload = payload or {}
|
||||
action = str(action or "").strip().lower()
|
||||
c = client_for(profile)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import posixpath
|
||||
@@ -95,6 +94,7 @@ _REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
|
||||
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
||||
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
||||
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
|
||||
PY_MANUAL_PAUSE_FIELD = "py_manual_pause"
|
||||
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
|
||||
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
||||
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
||||
@@ -345,6 +345,30 @@ def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0)
|
||||
raise RuntimeError(output)
|
||||
|
||||
|
||||
|
||||
def remote_can_write_directory(profile: dict, path: str) -> dict:
|
||||
"""Return whether the source rTorrent OS user can write to a remote directory safely."""
|
||||
clean = _remote_clean_path(path)
|
||||
# Note: Profile transfers may touch filesystem paths, so only absolute non-root directories are probed.
|
||||
if not clean.startswith("/") or clean in {"/", "."}:
|
||||
return {"ok": False, "path": clean, "error": "unsafe destination path"}
|
||||
script = (
|
||||
'p=$1; '
|
||||
'case "$p" in /*) ;; *) echo "NO\tunsafe path"; exit 0;; esac; '
|
||||
'if [ -d "$p" ]; then '
|
||||
' if [ -w "$p" ]; then echo "OK\tdirectory writable"; else echo "NO\tdirectory not writable"; fi; '
|
||||
' exit 0; '
|
||||
'fi; '
|
||||
'parent=${p%/*}; [ -n "$parent" ] || parent=/; '
|
||||
'if [ -d "$parent" ] && [ -w "$parent" ]; then echo "OK\tparent writable"; else echo "NO\tparent not writable"; fi'
|
||||
)
|
||||
try:
|
||||
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script, "pytorrent-transfer-write-check", clean) or "").strip()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "path": clean, "error": str(exc)}
|
||||
ok = output.startswith("OK")
|
||||
return {"ok": ok, "path": clean, "message": output.split("\t", 1)[1] if "\t" in output else output}
|
||||
|
||||
def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
data_path = _safe_rm_rf_path(_torrent_data_path(c, torrent_hash))
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
|
||||
RTORRENT_CONFIG_FIELDS = [
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
from .. import poller_control
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
from ...config import BASE_DIR
|
||||
|
||||
@@ -25,7 +24,6 @@ def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
|
||||
|
||||
def torrent_file_tree(profile: dict, torrent_hash: str) -> dict:
|
||||
# Note: The tree is built from rTorrent file paths without changing the existing flat file API.
|
||||
root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
parts = [part for part in str(item.get("path") or "").split("/") if part]
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Note: Backward-compatible internal alias for modules created during refactor.
|
||||
from .client import *
|
||||
|
||||
@@ -1,18 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from threading import RLock
|
||||
|
||||
from .client import *
|
||||
from .config import default_download_path
|
||||
from ...utils import human_size
|
||||
|
||||
|
||||
def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions."""
|
||||
# Note: Directory browsing stays remote-side, matching the original monolithic service behavior.
|
||||
|
||||
def _rtorrent_home_path(profile: dict) -> str:
|
||||
# Note: This reads the remote rTorrent process home, not the pyTorrent server home.
|
||||
try:
|
||||
c = client_for(profile)
|
||||
base = _remote_clean_path(path or default_download_path(profile))
|
||||
return _remote_clean_path(str(_rt_execute(c, "execute.capture", "sh", "-c", 'printf "%s" "${HOME:-}"') or "").strip())
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _append_path_browse_candidate(candidates: list[str], value: str) -> None:
|
||||
clean = _remote_clean_path(value or "")
|
||||
if clean and clean.startswith("/") and clean != "/" and clean not in candidates:
|
||||
candidates.append(clean)
|
||||
|
||||
|
||||
def _path_browse_fallback_candidates(profile: dict) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
download_path = _remote_clean_path(default_download_path(profile) or "")
|
||||
download_parent = _remote_clean_path(posixpath.dirname(download_path.rstrip("/")) if download_path else "")
|
||||
|
||||
# Note: Fallback prefers the configured download area, then its parent, then the rTorrent user home.
|
||||
_append_path_browse_candidate(candidates, download_path)
|
||||
_append_path_browse_candidate(candidates, download_parent)
|
||||
_append_path_browse_candidate(candidates, _rtorrent_home_path(profile))
|
||||
return candidates
|
||||
|
||||
|
||||
def _remote_accessible_directory(profile: dict, paths: list[str]) -> str:
|
||||
c = client_for(profile)
|
||||
script = (
|
||||
'for base in "$@"; do '
|
||||
'[ -n "$base" ] || continue; '
|
||||
'[ "$base" = "/" ] && continue; '
|
||||
'[ -d "$base" ] || continue; '
|
||||
'[ -L "$base" ] && continue; '
|
||||
'[ -r "$base" ] || continue; '
|
||||
'[ -x "$base" ] || continue; '
|
||||
'physical=$(cd -P -- "$base" 2>/dev/null && pwd -P) || continue; '
|
||||
'[ -n "$physical" ] || continue; '
|
||||
'[ "$physical" = "/" ] && continue; '
|
||||
'printf "%s" "$physical"; exit 0; '
|
||||
'done'
|
||||
)
|
||||
clean_paths = [_remote_clean_path(path or "") for path in paths if str(path or "").strip()]
|
||||
output = _rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-access-check", *clean_paths)
|
||||
return _remote_clean_path(str(output or "").strip())
|
||||
|
||||
|
||||
def _safe_browse_base(profile: dict, requested_path: str | None) -> tuple[str, str, bool]:
|
||||
fallback_candidates = _path_browse_fallback_candidates(profile)
|
||||
fallback = _remote_accessible_directory(profile, fallback_candidates)
|
||||
if not fallback:
|
||||
raise RuntimeError("Cannot determine an accessible rTorrent browse fallback")
|
||||
|
||||
requested = _remote_clean_path(requested_path or fallback)
|
||||
if requested == "/":
|
||||
return fallback, fallback, True
|
||||
|
||||
allowed = _remote_accessible_directory(profile, [requested])
|
||||
return (allowed or fallback), fallback, not bool(allowed)
|
||||
|
||||
def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
"""List allowed rTorrent directories through execute.capture without exposing the full filesystem."""
|
||||
c = client_for(profile)
|
||||
base, fallback_root, used_fallback = _safe_browse_base(profile, path)
|
||||
script = (
|
||||
'base=$1; '
|
||||
'[ -d "$base" ] || exit 2; '
|
||||
@@ -20,10 +79,17 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
'dir_count=0; file_count=0; '
|
||||
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
||||
'[ -e "$p" ] || continue; '
|
||||
'[ -L "$p" ] && continue; '
|
||||
'if [ -d "$p" ]; then '
|
||||
'dir_count=$((dir_count+1)); name=${p##*/}; empty=1; '
|
||||
'if find "$p" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then empty=0; fi; '
|
||||
'printf "D\\t%s\\t%s\\t%s\\n" "$name" "$p" "$empty"; '
|
||||
'dir_count=$((dir_count+1)); '
|
||||
'[ -r "$p" ] || continue; '
|
||||
'[ -x "$p" ] || continue; '
|
||||
'physical=$(cd -P -- "$p" 2>/dev/null && pwd -P) || continue; '
|
||||
'[ -n "$physical" ] || continue; '
|
||||
'[ "$physical" = "/" ] && continue; '
|
||||
'name=${p##*/}; empty=1; '
|
||||
'if find "$physical" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then empty=0; fi; '
|
||||
'printf "D\\t%s\\t%s\\t%s\\n" "$name" "$physical" "$empty"; '
|
||||
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
||||
'done; '
|
||||
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
||||
@@ -44,7 +110,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
name, full_path = parts[0], parts[1]
|
||||
is_empty = len(parts) > 2 and parts[2] == "1"
|
||||
if name not in {".", ".."}:
|
||||
# Note: Empty status is returned with every directory so the path picker can enable safe inline rename.
|
||||
dirs.append({"name": name, "path": full_path, "empty": is_empty})
|
||||
elif marker == "M" and "\t" in rest:
|
||||
first, second = rest.split("\t", 1)
|
||||
@@ -65,12 +130,15 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
disk_total = disk_used = disk_free = disk_percent = 0
|
||||
dirs.sort(key=lambda x: x["name"].lower())
|
||||
parent = posixpath.dirname(base.rstrip("/")) or "/"
|
||||
if parent == base:
|
||||
if parent == base or parent == "/" or not _remote_accessible_directory(profile, [parent]):
|
||||
parent = base
|
||||
# Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts.
|
||||
return {
|
||||
"path": base,
|
||||
"parent": parent,
|
||||
"root": fallback_root,
|
||||
"allowed_roots": [fallback_root],
|
||||
"access_policy": "rtorrent-permissions",
|
||||
"fallback": used_fallback,
|
||||
"dirs": dirs[:300],
|
||||
"source": "rtorrent",
|
||||
"dir_count": dir_count,
|
||||
@@ -85,7 +153,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _safe_directory_name(name: str) -> str:
|
||||
value = str(name or "").strip()
|
||||
if not value or value in {".", ".."} or "/" in value or "\x00" in value:
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
from .files import export_torrent_file, iter_remote_file_chunks, set_file_priorities
|
||||
from .system import disk_usage_for_default_path
|
||||
|
||||
|
||||
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _parse_xmlrpc_size_limit(value) -> int:
|
||||
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K."""
|
||||
# Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes.
|
||||
text = str(value or '').strip().lower()
|
||||
if not text:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
@@ -29,7 +25,6 @@ def _parse_xmlrpc_size_limit(value) -> int:
|
||||
|
||||
def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
"""Return the current rTorrent XML-RPC request size limit."""
|
||||
# Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC.
|
||||
try:
|
||||
raw = client_for(profile).call('network.xmlrpc.size_limit')
|
||||
limit = _parse_xmlrpc_size_limit(raw)
|
||||
@@ -40,7 +35,6 @@ def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
|
||||
def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int:
|
||||
"""Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file."""
|
||||
# Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file.
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f'd.directory.set={directory}')
|
||||
@@ -93,7 +87,6 @@ def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
|
||||
if age > _POST_CHECK_WATCH_TTL_SECONDS:
|
||||
_clear_post_check_watch(profile_id, torrent_hash)
|
||||
return False
|
||||
# Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet.
|
||||
return age >= _POST_CHECK_WATCH_MIN_SECONDS
|
||||
|
||||
|
||||
@@ -124,7 +117,6 @@ def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, cu
|
||||
labels = _label_names(str(label_source or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
# Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue.
|
||||
c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
|
||||
return True
|
||||
|
||||
@@ -151,11 +143,9 @@ def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
status = str(row.get("status") or "").lower()
|
||||
# Note: rTorrent may report state=1 after a recheck even when the download is not really active yet.
|
||||
started_after_wait = bool(int(row.get("state") or 0)) and bool(int(row.get("active") or 0)) and status != "checking"
|
||||
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
|
||||
return False
|
||||
# Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
|
||||
clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
|
||||
row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
|
||||
return True
|
||||
@@ -183,7 +173,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
||||
complete = _row_progress_complete(row)
|
||||
try:
|
||||
if complete:
|
||||
# Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
|
||||
start_result = start_or_resume_hash(c, h)
|
||||
clear_post_check_download_label(c, h, str(row.get("label") or ""))
|
||||
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
|
||||
@@ -193,7 +182,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
labels.append(POST_CHECK_DOWNLOAD_LABEL)
|
||||
label_value = _label_value(labels)
|
||||
# Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
|
||||
c.call("d.stop", h)
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
@@ -212,7 +200,7 @@ TORRENT_FIELDS = [
|
||||
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
||||
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
|
||||
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
|
||||
"d.custom=py_ratio_group", "d.message=", "d.hashing=", "d.is_active=", "d.is_multi_file=",
|
||||
"d.custom=py_ratio_group", f"d.custom={PY_MANUAL_PAUSE_FIELD}", "d.message=", "d.hashing=", "d.is_active=", "d.is_open=", "d.is_multi_file=",
|
||||
]
|
||||
|
||||
TORRENT_OPTIONAL_FIELDS = [
|
||||
@@ -224,12 +212,11 @@ LIVE_TORRENT_FIELDS = [
|
||||
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
||||
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=",
|
||||
"d.peers_connected=", "d.peers_complete=", "d.message=", "d.hashing=", "d.is_active=",
|
||||
"d.custom1=",
|
||||
"d.is_open=", "d.custom1=", f"d.custom={PY_MANUAL_PAUSE_FIELD}",
|
||||
]
|
||||
|
||||
|
||||
def human_duration(seconds: int) -> str:
|
||||
# Note: Download ETA is derived locally from remaining bytes and current download speed.
|
||||
seconds = max(0, int(seconds or 0))
|
||||
if seconds <= 0:
|
||||
return '-'
|
||||
@@ -254,17 +241,10 @@ def normalize_row(row: list) -> dict:
|
||||
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not int(row[3] or 0) else 0
|
||||
directory = str(row[14] or "")
|
||||
base_path = str(row[15] or "")
|
||||
is_multi_file = int(row[22] or 0) if len(row) > 22 else 0
|
||||
# Note: Last activity is optional because older rTorrent builds may not expose this timestamp.
|
||||
last_activity = int(row[23] or 0) if len(row) > 23 else 0
|
||||
if not last_activity and (down_rate > 0 or up_rate > 0):
|
||||
# Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp.
|
||||
last_activity = int(time.time())
|
||||
completed_at = int(row[24] or 0) if len(row) > 24 else 0
|
||||
state = int(row[2] or 0)
|
||||
complete = int(row[3] or 0)
|
||||
is_multi_file = int(row[24] or 0) if len(row) > 24 else 0
|
||||
|
||||
# Show the selected download location only. Hide the torrent root
|
||||
# directory for multi-file torrents and the filename for single-file
|
||||
# torrents. Data deletion still uses the full d.base_path elsewhere.
|
||||
if base_path and base_path != "/":
|
||||
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
@@ -275,26 +255,31 @@ def normalize_row(row: list) -> dict:
|
||||
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
|
||||
else:
|
||||
display_path = ""
|
||||
msg = str(row[19] or "")
|
||||
manual_pause = str(row[19] or "").strip() == "1"
|
||||
msg = str(row[20] or "")
|
||||
msg_l = msg.lower()
|
||||
hashing = int(row[20] or 0) if len(row) > 20 else 0
|
||||
is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0)
|
||||
state = int(row[2] or 0)
|
||||
complete = int(row[3] or 0)
|
||||
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
|
||||
hashing = int(row[21] or 0) if len(row) > 21 else 0
|
||||
is_active = int(row[22] or 0) if len(row) > 22 else int(state)
|
||||
is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state)
|
||||
last_activity = int(row[25] or 0) if len(row) > 25 else 0
|
||||
if not last_activity and (down_rate > 0 or up_rate > 0):
|
||||
last_activity = int(time.time())
|
||||
completed_at = int(row[26] or 0) if len(row) > 26 else 0
|
||||
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
|
||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
|
||||
is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check
|
||||
# Note: Post-check is an application-level state that separates torrents waiting after a recheck from manually stopped torrents.
|
||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
is_paused = manual_pause and not is_checking and not post_check
|
||||
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
|
||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
to_download_bytes = remaining_bytes if not complete else 0
|
||||
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
|
||||
|
||||
return {
|
||||
"hash": str(row[0] or ""),
|
||||
"name": str(row[1] or ""),
|
||||
"state": state,
|
||||
"active": is_active,
|
||||
"open": is_open,
|
||||
"paused": is_paused,
|
||||
"queued": is_queued,
|
||||
"complete": complete,
|
||||
"size": size,
|
||||
"size_h": human_size(size),
|
||||
@@ -331,7 +316,6 @@ def normalize_row(row: list) -> dict:
|
||||
|
||||
def normalize_live_row(row: list) -> dict:
|
||||
"""Normalize the small row used by the fast live stats poller."""
|
||||
# Note: The live poller intentionally reads only volatile fields so the main list poller can run less often.
|
||||
size = int(row[3] or 0)
|
||||
completed = int(row[4] or 0)
|
||||
complete = int(row[2] or 0)
|
||||
@@ -344,18 +328,24 @@ def normalize_live_row(row: list) -> dict:
|
||||
msg = str(row[12] or "")
|
||||
hashing = int(row[13] or 0)
|
||||
is_active = int(row[14] or 0)
|
||||
labels = str(row[15] or "")
|
||||
is_open = int(row[15] or 0) if len(row) > 15 else int(is_active or state)
|
||||
labels = str(row[16] or "") if len(row) > 16 else ""
|
||||
manual_pause = str(row[17] or "").strip() == "1" if len(row) > 17 else False
|
||||
is_checking = bool(hashing) or _message_indicates_active_check(msg.lower())
|
||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(labels) and not is_checking and not bool(is_active)
|
||||
is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check
|
||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
# Note: Live patches keep Queued separate from explicit user Paused using the same app marker as full snapshots.
|
||||
is_paused = manual_pause and not is_checking and not post_check
|
||||
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
|
||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
progress = 100.0 if size <= 0 and complete else round((completed / size) * 100, 2) if size else 0.0
|
||||
to_download_bytes = remaining_bytes if not complete else 0
|
||||
return {
|
||||
"hash": str(row[0] or ""),
|
||||
"state": state,
|
||||
"active": is_active,
|
||||
"open": is_open,
|
||||
"paused": is_paused,
|
||||
"queued": is_queued,
|
||||
"complete": complete,
|
||||
"completed_bytes": completed,
|
||||
"progress": progress,
|
||||
@@ -393,13 +383,10 @@ def list_torrents(profile: dict) -> list[dict]:
|
||||
try:
|
||||
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
|
||||
except Exception:
|
||||
# Keep compatibility with older rTorrent builds that do not expose optional timestamp fields.
|
||||
rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
|
||||
return [normalize_row(list(row)) for row in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
fields = [
|
||||
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=",
|
||||
@@ -431,8 +418,6 @@ def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
return peers
|
||||
|
||||
|
||||
|
||||
|
||||
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
|
||||
errors = []
|
||||
for method, args in candidates:
|
||||
@@ -444,7 +429,6 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
|
||||
|
||||
def _tracker_domain(url: str) -> str:
|
||||
raw = str(url or '').strip()
|
||||
if not raw:
|
||||
@@ -458,7 +442,6 @@ def _tracker_domain(url: str) -> str:
|
||||
|
||||
def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict:
|
||||
"""Return tracker domains grouped by torrent for the sidebar filter."""
|
||||
# Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list.
|
||||
hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
|
||||
if not hashes:
|
||||
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')]
|
||||
@@ -618,47 +601,77 @@ def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> s
|
||||
return default
|
||||
|
||||
|
||||
|
||||
def _set_manual_pause(c: ScgiRtorrentClient, torrent_hash: str, enabled: bool) -> None:
|
||||
"""Persist the user Pause intent without touching the visible label field."""
|
||||
# Note: rTorrent has no reliable queued-vs-user-paused flag, so pyTorrent stores that intent in d.custom.
|
||||
c.call('d.custom.set', str(torrent_hash or ''), PY_MANUAL_PAUSE_FIELD, '1' if enabled else '')
|
||||
|
||||
|
||||
def _manual_pause_enabled(c: ScgiRtorrentClient, torrent_hash: str) -> bool:
|
||||
h = str(torrent_hash or '')
|
||||
for method, args in (
|
||||
(f'd.custom={PY_MANUAL_PAUSE_FIELD}', (h,)),
|
||||
('d.custom', (h, PY_MANUAL_PAUSE_FIELD)),
|
||||
):
|
||||
try:
|
||||
if str(c.call(method, *args) or '').strip() == '1':
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
||||
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
||||
state = _int_rpc(c, 'd.state', h)
|
||||
active = _int_rpc(c, 'd.is_active', h)
|
||||
opened = _int_rpc(c, 'd.is_open', h)
|
||||
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
|
||||
label = _str_rpc(c, 'd.custom1', h)
|
||||
manual_pause = _manual_pause_enabled(c, h)
|
||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
|
||||
paused = bool(manual_pause and not post_check)
|
||||
queued = bool(state and opened and not active and not paused and not post_check)
|
||||
return {
|
||||
'state': state,
|
||||
'open': opened,
|
||||
'active': active,
|
||||
'paused': bool(state and opened and not active and not post_check),
|
||||
'paused': paused,
|
||||
'queued': queued,
|
||||
'stopped': not bool(state),
|
||||
'post_check': post_check,
|
||||
'label': label,
|
||||
'manual_pause': manual_pause,
|
||||
'message': _str_rpc(c, 'd.message', h),
|
||||
}
|
||||
|
||||
|
||||
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Pause an active rTorrent item without stopping or closing it."""
|
||||
"""Mark a torrent as user-paused and ask rTorrent to pause it."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
try:
|
||||
_set_manual_pause(c, h, True)
|
||||
result['commands'].append('set_py_manual_pause')
|
||||
if before.get('stopped'):
|
||||
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
|
||||
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
|
||||
# Note: A stopped torrent has no native paused flag; opening it first lets the UI and later Resume follow the same path.
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
try:
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
# Note: Smart Queue frees a slot with d.pause, not d.stop, so later d.resume behaves like ruTorrent.
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||
try:
|
||||
c.call('d.pause', h)
|
||||
result['commands'].append('d.pause')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.pause: {exc}')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
@@ -674,9 +687,16 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped') and not before.get('post_check'):
|
||||
if before.get('manual_pause'):
|
||||
_set_manual_pause(c, h, False)
|
||||
result['commands'].append('clear_py_manual_pause')
|
||||
before = _download_runtime_state(c, h)
|
||||
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
||||
return result
|
||||
try:
|
||||
if before.get('manual_pause'):
|
||||
_set_manual_pause(c, h, False)
|
||||
result['commands'].append('clear_py_manual_pause')
|
||||
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
|
||||
if before.get('post_check'):
|
||||
clear_post_check_download_label(c, h, before.get('label'))
|
||||
@@ -692,23 +712,34 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
|
||||
|
||||
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Resume only a paused rTorrent item; never convert it through stop/start."""
|
||||
"""Resume a user-paused torrent and clear pyTorrent's pause marker."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
|
||||
return result
|
||||
if before.get('active'):
|
||||
if before.get('active') and not before.get('manual_pause'):
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
|
||||
# because those commands belong to Stopped/Open state, not a clean Paused state.
|
||||
if before.get('manual_pause'):
|
||||
_set_manual_pause(c, h, False)
|
||||
result['commands'].append('clear_py_manual_pause')
|
||||
try:
|
||||
c.call('d.resume', h)
|
||||
result['commands'].append('d.resume')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.resume: {exc}')
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
try:
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
@@ -717,17 +748,21 @@ def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
|
||||
|
||||
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start: bool = False) -> dict:
|
||||
"""Start stopped torrents or resume real paused torrents.
|
||||
"""Start stopped torrents and recover open/inactive paused torrents.
|
||||
|
||||
Smart Queue passes prefer_start=True for candidates that were selected as stopped.
|
||||
This avoids treating rTorrent's intermediate open/inactive state after a check as
|
||||
a user pause and sending only d.resume, which can leave items pending forever.
|
||||
rTorrent can expose a torrent as state=1, open=1 and active=0 while d.resume/d.start
|
||||
alone does not wake it up. Manual Start uses the same recovery path users already
|
||||
perform by hand: d.stop followed by d.open and d.start.
|
||||
"""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('manual_pause'):
|
||||
_set_manual_pause(c, h, False)
|
||||
result['commands'].append('clear_py_manual_pause')
|
||||
before = _download_runtime_state(c, h)
|
||||
|
||||
if before.get('active'):
|
||||
if before.get('post_check'):
|
||||
@@ -736,17 +771,9 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
|
||||
if before.get('paused') and not prefer_start and not before.get('post_check'):
|
||||
# Note: Manual Start keeps the clean pause-to-resume path. Do not classify every
|
||||
# state=1/active=0 item as paused; after auto-check this can be only a transient
|
||||
# open/inactive rTorrent state and needs d.open + d.start.
|
||||
resumed = resume_paused_hash(c, h)
|
||||
resumed['mode'] = 'resume_paused'
|
||||
return resumed
|
||||
|
||||
if before.get('post_check'):
|
||||
if (before.get('paused') and not prefer_start) or before.get('queued') or before.get('post_check'):
|
||||
try:
|
||||
# Note: Post-check start first forces a clean stopped state, matching the manual Stop -> Start recovery path.
|
||||
# Note: Start intentionally normalizes open/inactive torrents through Stop -> Start because d.resume can leave them stuck.
|
||||
c.call('d.stop', h)
|
||||
result['commands'].append('d.stop')
|
||||
except Exception as exc:
|
||||
@@ -777,6 +804,140 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
|
||||
result['ok'] = result.get('ok', True)
|
||||
return result
|
||||
|
||||
|
||||
def _read_exported_torrent_bytes(profile: dict, torrent_hash: str) -> tuple[bytes, dict]:
|
||||
item = export_torrent_file(profile, torrent_hash)
|
||||
if item.get("local"):
|
||||
return LocalPath(str(item.get("path") or "")).read_bytes(), item
|
||||
data = b"".join(bytes(chunk) for chunk in iter_remote_file_chunks(profile, str(item.get("path") or "")) if chunk)
|
||||
if not data:
|
||||
raise RuntimeError(f"Cannot read exported torrent file for {torrent_hash}")
|
||||
return data, item
|
||||
|
||||
|
||||
def _move_profile_transfer_data(source_client: ScgiRtorrentClient, torrent_hash: str, target_path: str) -> dict:
|
||||
"""Move one torrent data path for a profile transfer after backend permission checks."""
|
||||
src = _remote_clean_path(_torrent_data_path(source_client, torrent_hash))
|
||||
if not src:
|
||||
raise ValueError(f"Cannot determine source path for {torrent_hash}")
|
||||
dst = _remote_join(target_path, posixpath.basename(src.rstrip("/")))
|
||||
try:
|
||||
source_client.call("d.stop", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
source_client.call("d.close", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
if src == dst:
|
||||
return {"skipped_data_move": "source and destination are the same"}
|
||||
_run_remote_move(source_client, src, dst)
|
||||
return {"moved_from": src, "moved_to": dst}
|
||||
|
||||
|
||||
def transfer_profile(source_profile: dict, target_profile: dict, torrent_hashes: list[str], payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
|
||||
"""Move torrent entries between rTorrent profiles; data moving is delegated to a separate helper."""
|
||||
payload = payload or {}
|
||||
resume_state = resume_state or {}
|
||||
target_path = _remote_clean_path(payload.get("target_path") or payload.get("path") or "")
|
||||
move_data = bool(payload.get("move_data"))
|
||||
post_action = str(payload.get("post_action") or "none").strip().lower()
|
||||
if post_action not in {"none", "current", "start", "stop", "pause", "check", "recheck"}:
|
||||
raise ValueError("Unsupported post-transfer action")
|
||||
label_mode = str(payload.get("label_mode") or "none").strip().lower()
|
||||
label_value = str(payload.get("label_value") or "").strip()
|
||||
if label_mode not in {"none", "custom", "moved_from", "moved_to"}:
|
||||
label_mode = "none"
|
||||
if label_mode == "moved_from":
|
||||
label_value = f"Moved from {source_profile.get('name') or source_profile.get('id') or 'profile'}"
|
||||
elif label_mode == "moved_to":
|
||||
label_value = f"Moved to {target_profile.get('name') or target_profile.get('id') or 'profile'}"
|
||||
elif label_mode != "custom":
|
||||
label_value = ""
|
||||
if len(label_value) > 120:
|
||||
label_value = label_value[:120]
|
||||
if not target_path or not target_path.startswith("/") or target_path == "/":
|
||||
raise ValueError("Missing or unsafe target path")
|
||||
completed_hashes = set(str(x) for x in (resume_state.get("completed_hashes") or []))
|
||||
previous_results = list(resume_state.get("results") or [])
|
||||
source_client = client_for(source_profile)
|
||||
target_client = client_for(target_profile)
|
||||
|
||||
def mark_done(torrent_hash: str, results: list) -> None:
|
||||
completed_hashes.add(str(torrent_hash))
|
||||
if checkpoint:
|
||||
checkpoint({"completed_hashes": sorted(completed_hashes), "results": results}, len(completed_hashes), len(torrent_hashes))
|
||||
|
||||
results = previous_results
|
||||
for h in [x for x in torrent_hashes if str(x) not in completed_hashes]:
|
||||
item = {
|
||||
"hash": h,
|
||||
"source_profile_id": int(source_profile.get("id") or 0),
|
||||
"target_profile_id": int(target_profile.get("id") or 0),
|
||||
"target_path": target_path,
|
||||
"move_data": move_data,
|
||||
"move_data_requested": bool(payload.get("move_data_requested")),
|
||||
"move_data_downgraded": bool(payload.get("move_data_downgraded")),
|
||||
}
|
||||
data, exported = _read_exported_torrent_bytes(source_profile, h)
|
||||
item["exported_from"] = exported.get("path")
|
||||
limit = validate_torrent_upload_size(target_profile, data, False, target_path, "")
|
||||
if not limit.get("ok"):
|
||||
raise RuntimeError(f"Target profile XML-RPC limit is too small for {h}: {limit.get('request_h')} > {limit.get('limit_h')}")
|
||||
try:
|
||||
label = str(source_client.call("d.custom1", h) or "")
|
||||
except Exception:
|
||||
label = ""
|
||||
target_label = label_value if label_value else label
|
||||
try:
|
||||
was_state = int(source_client.call("d.state", h) or 0)
|
||||
except Exception:
|
||||
was_state = 0
|
||||
try:
|
||||
was_active = int(source_client.call("d.is_active", h) or 0)
|
||||
except Exception:
|
||||
was_active = was_state
|
||||
moved_to = ""
|
||||
if move_data:
|
||||
move_result = _move_profile_transfer_data(source_client, h, target_path)
|
||||
item.update(move_result)
|
||||
moved_to = str(move_result.get("moved_to") or "")
|
||||
# Note: The default keeps the torrent status from the source profile; explicit actions override it.
|
||||
start_on_target = bool(was_state or was_active) if post_action in {"none", "current"} else post_action == "start"
|
||||
try:
|
||||
added = add_torrent_raw(target_profile, data, start_on_target, target_path, target_label)
|
||||
if not added.get("ok"):
|
||||
raise RuntimeError(added.get("error") or "target add failed")
|
||||
except Exception:
|
||||
if move_data and moved_to:
|
||||
try:
|
||||
source_client.call("d.directory.set", h, target_path)
|
||||
if was_state or was_active:
|
||||
source_client.call("d.start", h)
|
||||
item["rollback"] = "source torrent kept and pointed at moved data"
|
||||
except Exception as rollback_exc:
|
||||
item["rollback_error"] = str(rollback_exc)
|
||||
raise
|
||||
if post_action in {"stop", "pause", "check", "recheck"}:
|
||||
try:
|
||||
if post_action == "stop":
|
||||
target_client.call("d.stop", h)
|
||||
elif post_action == "pause":
|
||||
pause_hash(target_client, h)
|
||||
else:
|
||||
target_client.call("d.check_hash", h)
|
||||
item["post_action_applied"] = post_action
|
||||
except Exception as post_exc:
|
||||
item["post_action_error"] = str(post_exc)
|
||||
source_client.call("d.erase", h)
|
||||
item["target_started"] = start_on_target
|
||||
item["label"] = target_label
|
||||
item["previous_label"] = label
|
||||
item["post_action"] = post_action
|
||||
results.append(item)
|
||||
mark_done(h, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "target_profile_id": int(target_profile.get("id") or 0), "target_path": target_path, "label": label_value, "post_action": post_action, "results": results}
|
||||
|
||||
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
|
||||
payload = payload or {}
|
||||
resume_state = resume_state or {}
|
||||
@@ -890,7 +1051,7 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name in {"resume", "unpause"}:
|
||||
# Note: Resume/Unpause uses only d.resume for Paused state.
|
||||
# Note: Resume/Unpause keeps native rTorrent resume semantics; Start is the recovery action for stuck open/inactive torrents.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = resume_paused_hash(c, h)
|
||||
@@ -898,7 +1059,7 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name == "start":
|
||||
# Note: Start separates Stopped from Paused; paused items go through d.resume, stopped items through d.start.
|
||||
# Note: Start recovers stuck Paused/open-inactive rows with Stop -> Start while keeping normal stopped rows on d.start.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = start_or_resume_hash(c, h)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
@@ -834,7 +832,7 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
|
||||
"""Return True for incomplete torrents that already occupy a Smart Queue slot."""
|
||||
# Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels,
|
||||
# and those torrents still must count toward the global Smart Queue limit.
|
||||
return _is_started_download_slot(t)
|
||||
return _is_started_download_slot(t) and not _is_user_paused(t)
|
||||
|
||||
|
||||
def _has_recent_transfer_activity(t: dict[str, Any], stalled_seconds: int) -> bool:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from .rtorrent import human_rate
|
||||
|
||||
|
||||
@@ -1,26 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from time import sleep
|
||||
from . import preferences, rtorrent
|
||||
import threading
|
||||
from time import monotonic
|
||||
from ..db import connect
|
||||
from . import operation_logs, rtorrent
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
_applied_profiles: set[int] = set()
|
||||
_last_status: dict[int, str] = {}
|
||||
|
||||
|
||||
def schedule_startup_config_apply(socketio, delay_seconds: int = 60) -> None:
|
||||
"""Apply saved rTorrent UI overrides after pyTorrent has been running for a moment."""
|
||||
def _profiles() -> list[dict]:
|
||||
"""Read all configured profiles because startup work has no browser user session."""
|
||||
with connect() as conn:
|
||||
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||
|
||||
|
||||
def _log_status(profile: dict, status: str, message: str, *, error: str = "", result: dict | None = None) -> None:
|
||||
"""Write meaningful startup config state changes as system operations."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if status in {"waiting", "skipped"} and _last_status.get(profile_id) == status:
|
||||
return
|
||||
_last_status[profile_id] = status
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"rtorrent_config_startup",
|
||||
message,
|
||||
severity="warning" if error else "info",
|
||||
source="system",
|
||||
action="rtorrent_config",
|
||||
details={"status": status, "error": error, "result": result or {}},
|
||||
user_id=int(profile.get("user_id") or 0) or None,
|
||||
)
|
||||
|
||||
|
||||
def _rtorrent_ready(profile: dict) -> tuple[bool, str]:
|
||||
"""Check rTorrent before applying saved runtime overrides."""
|
||||
try:
|
||||
rtorrent.client_for(profile).call("system.client_version")
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _apply_profile(socketio, profile: dict) -> None:
|
||||
"""Apply saved config only after the target rTorrent is reachable."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id or profile_id in _applied_profiles:
|
||||
return
|
||||
ok, error = _rtorrent_ready(profile)
|
||||
if not ok:
|
||||
_log_status(profile, "waiting", f"rTorrent config apply is waiting for connection: {error}", error=error)
|
||||
return
|
||||
result = rtorrent.apply_startup_overrides(profile)
|
||||
if result.get("skipped"):
|
||||
_applied_profiles.add(profile_id)
|
||||
_log_status(profile, "skipped", "No saved rTorrent startup config overrides to apply", result=result)
|
||||
return
|
||||
_applied_profiles.add(profile_id)
|
||||
_log_status(profile, "applied", "Saved rTorrent startup config overrides applied", result=result)
|
||||
socketio.emit("rtorrent_config_applied", {"profile_id": profile_id, "result": result}, to=f"profile:{int(profile_id)}")
|
||||
|
||||
|
||||
def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_seconds: int = 30, max_wait_seconds: int = 3600) -> None:
|
||||
"""Apply saved rTorrent UI overrides after the configured startup delay without requiring a browser."""
|
||||
global _started
|
||||
with _start_lock:
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
|
||||
def runner():
|
||||
sleep(max(0, int(delay_seconds)))
|
||||
def runner() -> None:
|
||||
socketio.sleep(max(0, int(delay_seconds)))
|
||||
started_at = monotonic()
|
||||
while True:
|
||||
failed_profile_id = 0
|
||||
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})
|
||||
profiles = _profiles()
|
||||
for profile in profiles:
|
||||
failed_profile_id = int(profile.get("id") or 0)
|
||||
# Note: Startup config applies per profile after connectivity is detected; it does not depend on the active UI profile.
|
||||
_apply_profile(socketio, profile)
|
||||
pending = [int(profile.get("id") or 0) for profile in profiles if int(profile.get("id") or 0) not in _applied_profiles]
|
||||
if not pending or monotonic() - started_at >= max(0, int(max_wait_seconds)):
|
||||
for profile in profiles:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if profile_id in pending:
|
||||
_log_status(profile, "timeout", "rTorrent config startup apply stopped waiting for connection", error="startup wait timeout")
|
||||
return
|
||||
except Exception as exc:
|
||||
socketio.emit("rtorrent_config_applied", {"ok": False, "error": str(exc)})
|
||||
operation_logs.record(
|
||||
failed_profile_id or None,
|
||||
"rtorrent_config_startup",
|
||||
f"rTorrent startup config scheduler failed: {exc}",
|
||||
severity="warning",
|
||||
source="system",
|
||||
action="rtorrent_config",
|
||||
details={"error": str(exc)},
|
||||
)
|
||||
socketio.emit("rtorrent_config_applied", {"ok": False, "profile_id": int(failed_profile_id or 0), "error": str(exc)}, to=f"profile:{int(failed_profile_id)}" if failed_profile_id else None)
|
||||
socketio.sleep(max(5, int(retry_seconds)))
|
||||
|
||||
socketio.start_background_task(runner)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import RLock
|
||||
from time import time
|
||||
from . import rtorrent, operation_logs
|
||||
|
||||
_LIVE_KEYS = {"state", "active", "paused", "complete", "completed_bytes", "progress", "ratio", "up_rate", "up_rate_h", "down_rate", "down_rate_h", "eta_seconds", "eta_h", "up_total", "up_total_h", "down_total", "down_total_h", "to_download", "to_download_h", "peers", "seeds", "message", "status", "post_check", "hashing"}
|
||||
|
||||
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from . import rtorrent
|
||||
from .torrent_cache import torrent_cache
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from threading import RLock
|
||||
from time import time
|
||||
@@ -19,7 +18,7 @@ _ERROR_PATTERNS = (
|
||||
"unreachable",
|
||||
"denied",
|
||||
)
|
||||
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped")
|
||||
_SUMMARY_TYPES = ("all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped")
|
||||
_summary_cache: dict[int, dict] = {}
|
||||
_summary_lock = RLock()
|
||||
|
||||
@@ -46,7 +45,9 @@ def _matches(row: dict, summary_type: str) -> bool:
|
||||
if summary_type == "all":
|
||||
return True
|
||||
if summary_type == "downloading":
|
||||
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused")) and str(row.get("status") or "") != "Queued"
|
||||
if summary_type == "queued":
|
||||
return not checking and (bool(row.get("queued")) or str(row.get("status") or "") == "Queued")
|
||||
if summary_type == "seeding":
|
||||
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||
if summary_type == "paused":
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
@@ -11,7 +10,6 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR
|
||||
from ..db import connect, utcnow
|
||||
|
||||
@@ -438,3 +436,50 @@ def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tupl
|
||||
(clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"),
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
def cached_domains_for_profile(profile_id: int, limit: int = 200) -> list[str]:
|
||||
"""Return tracker domains already known for a profile from the summary cache."""
|
||||
# Note: The background favicon worker reads cached summary rows first, so it does not need the browser sidebar to discover domains.
|
||||
domains: list[str] = []
|
||||
seen: set[str] = set()
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT trackers_json FROM tracker_summary_cache WHERE profile_id=? ORDER BY updated_epoch DESC LIMIT ?",
|
||||
(int(profile_id), max(1, int(limit or 200))),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
items = json.loads(row.get("trackers_json") or "[]")
|
||||
except Exception:
|
||||
items = []
|
||||
for item in items if isinstance(items, list) else []:
|
||||
domain = tracker_domain(str((item or {}).get("url") or (item or {}).get("domain") or "")) or str((item or {}).get("domain") or "")
|
||||
if domain and domain not in seen:
|
||||
seen.add(domain)
|
||||
domains.append(domain)
|
||||
return domains[:max(1, int(limit or 200))]
|
||||
|
||||
|
||||
def warm_favicon_cache(domains: list[str], enabled: bool = True, limit: int = 20, force: bool = False) -> dict:
|
||||
"""Warm missing or stale tracker favicons for a bounded list of domains."""
|
||||
# Note: Favicon lookup can perform network requests, so the caller must keep the batch size small.
|
||||
clean_domains = []
|
||||
seen: set[str] = set()
|
||||
for domain in domains or []:
|
||||
clean = tracker_domain(domain)
|
||||
if clean and clean not in seen:
|
||||
seen.add(clean)
|
||||
clean_domains.append(clean)
|
||||
checked = 0
|
||||
cached = 0
|
||||
errors: list[dict] = []
|
||||
for domain in clean_domains[:max(0, int(limit or 0))]:
|
||||
checked += 1
|
||||
try:
|
||||
path, _mime = favicon_path(domain, enabled=enabled, force=force)
|
||||
if path:
|
||||
cached += 1
|
||||
except Exception as exc:
|
||||
errors.append({"domain": domain, "error": str(exc)})
|
||||
return {"checked": checked, "cached": cached, "errors": errors[:10]}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
@@ -9,7 +8,7 @@ from .preferences import active_profile, get_profile
|
||||
from ..db import default_user_id
|
||||
from .torrent_cache import torrent_cache
|
||||
from .torrent_summary import cached_summary
|
||||
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner
|
||||
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner, profile_speed_limits
|
||||
|
||||
|
||||
def _profile_room(profile_id: int) -> str:
|
||||
@@ -17,33 +16,43 @@ def _profile_room(profile_id: int) -> str:
|
||||
|
||||
|
||||
def _poller_profiles() -> list[dict]:
|
||||
# Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms.
|
||||
if not auth.enabled():
|
||||
profile = active_profile()
|
||||
return [profile] if profile else []
|
||||
from ..db import connect
|
||||
with connect() as conn:
|
||||
# Note: Background polling must be profile-scoped and browser-independent, even when auth is disabled.
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
||||
|
||||
|
||||
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
target = _profile_room(profile_id) if auth.enabled() else None
|
||||
socketio.emit(event, payload, to=target) if target else socketio.emit(event, payload)
|
||||
scoped_payload = {**(payload or {}), "profile_id": int(profile_id)}
|
||||
socketio.emit(event, scoped_payload, to=_profile_room(profile_id))
|
||||
|
||||
|
||||
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
emit_profile_event(socketio, event, payload, profile_id)
|
||||
|
||||
|
||||
_speed_limits_applied: dict[int, tuple[int, int]] = {}
|
||||
|
||||
|
||||
def _apply_configured_speed_limits(profile: dict, *, force: bool = False) -> None:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
limits = profile_speed_limits.get_limits(profile_id)
|
||||
if not limits.get("configured"):
|
||||
return
|
||||
key = (int(limits.get("down") or 0), int(limits.get("up") or 0))
|
||||
if not force and _speed_limits_applied.get(profile_id) == key:
|
||||
return
|
||||
# Note: Persisted per-profile limits are applied by the backend poller, not only after browser profile selection.
|
||||
rtorrent.set_limits(profile, limits.get("down"), limits.get("up"))
|
||||
_speed_limits_applied[profile_id] = key
|
||||
|
||||
|
||||
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
state = poller_control.state_for(profile_id)
|
||||
# Note: Background checks keep the profile owner so bypass/admin profiles do not enqueue jobs as the fallback user.
|
||||
profile_user_id = int(profile.get("user_id") or default_user_id())
|
||||
try:
|
||||
try:
|
||||
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
|
||||
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id))
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
@@ -58,8 +67,7 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
# Note: Automations are profile-scoped; each queued job still runs as the rule owner.
|
||||
auto_result = automation_rules.check(profile, force=False)
|
||||
auto_result = automation_rules.check(profile, user_id=profile_user_id, force=False)
|
||||
if auto_result.get("applied") or auto_result.get("batches"):
|
||||
_emit_profile(socketio, "automation_update", auto_result, profile_id)
|
||||
except Exception as exc:
|
||||
@@ -85,7 +93,6 @@ def _is_active_rows(rows: list[dict]) -> bool:
|
||||
|
||||
|
||||
def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict:
|
||||
# Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats.
|
||||
down_rate = sum(int(row.get("down_rate") or 0) for row in rows or [])
|
||||
up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
|
||||
return {
|
||||
@@ -145,6 +152,8 @@ def register_socketio_handlers(socketio):
|
||||
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
|
||||
|
||||
try:
|
||||
# Note: This keeps per-profile runtime limits active after app start, without waiting for UI contact.
|
||||
_apply_configured_speed_limits(profile)
|
||||
rows = torrent_cache.snapshot(pid)
|
||||
speed_status = _speed_status_from_rows(pid, rows)
|
||||
|
||||
@@ -175,7 +184,6 @@ def register_socketio_handlers(socketio):
|
||||
else:
|
||||
skipped_emissions += 1
|
||||
if live.get("requires_full_refresh"):
|
||||
# Note: Missing or unknown hashes mean the next slow list tick must reconcile rows.
|
||||
state.last_list_at = 0.0
|
||||
run_list = True
|
||||
else:
|
||||
@@ -209,7 +217,6 @@ def register_socketio_handlers(socketio):
|
||||
rtorrent_call_count += 1
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
@@ -263,7 +270,6 @@ def register_socketio_handlers(socketio):
|
||||
global _started
|
||||
with _start_lock:
|
||||
if not _started:
|
||||
# The poller starts with the app, so Smart Queue, planner and automations work without an open UI.
|
||||
socketio.start_background_task(poller)
|
||||
_started = True
|
||||
|
||||
@@ -282,10 +288,14 @@ def register_socketio_handlers(socketio):
|
||||
if not profile:
|
||||
emit("profile_required", {"ok": True, "profiles": []})
|
||||
return
|
||||
try:
|
||||
_apply_configured_speed_limits(profile, force=True)
|
||||
except Exception as exc:
|
||||
emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)})
|
||||
rows = torrent_cache.snapshot(profile["id"])
|
||||
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)})
|
||||
emit("poller_settings", {"settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
|
||||
emit("download_plan_update", {"settings": download_planner.get_settings(int(profile["id"]))})
|
||||
emit("poller_settings", {"profile_id": int(profile["id"]), "settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
|
||||
emit("download_plan_update", {"profile_id": int(profile["id"]), "settings": download_planner.get_settings(int(profile["id"]))})
|
||||
|
||||
@socketio.on("select_profile")
|
||||
def handle_select_profile(data):
|
||||
@@ -304,8 +314,12 @@ def register_socketio_handlers(socketio):
|
||||
emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"})
|
||||
return
|
||||
join_room(_profile_room(profile_id))
|
||||
try:
|
||||
_apply_configured_speed_limits(profile, force=True)
|
||||
except Exception as exc:
|
||||
emit("rtorrent_error", {"profile_id": profile_id, "error": str(exc)})
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")})
|
||||
emit("poller_settings", {"settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
|
||||
emit("download_plan_update", {"settings": download_planner.get_settings(profile_id)})
|
||||
emit("poller_settings", {"profile_id": profile_id, "settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
|
||||
emit("download_plan_update", {"profile_id": profile_id, "settings": download_planner.get_settings(profile_id)})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
@@ -42,8 +41,7 @@ def _emit(name: str, payload: dict):
|
||||
if not _socketio:
|
||||
return
|
||||
profile_id = payload.get("profile_id")
|
||||
if auth.enabled() and profile_id:
|
||||
# Note: Job/socket events are sent only to clients joined to the affected profile room.
|
||||
if profile_id:
|
||||
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
|
||||
else:
|
||||
_socketio.emit(name, payload)
|
||||
@@ -102,8 +100,7 @@ def _job_payload(row) -> dict:
|
||||
def _is_ordered_job(row) -> bool:
|
||||
payload = _job_payload(row)
|
||||
action = str((row or {}).get("action") or "")
|
||||
# Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work.
|
||||
return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
|
||||
return action in {"move", "remove", "profile_transfer", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
|
||||
|
||||
|
||||
def _is_priority_job(row) -> bool:
|
||||
@@ -115,24 +112,55 @@ def _is_light_job(row) -> bool:
|
||||
return _is_light_action(str((row or {}).get("action") or ""))
|
||||
|
||||
|
||||
def _has_prior_ordered_jobs(profile_id: int, rowid: int) -> bool:
|
||||
def _ordered_profile_ids(row) -> set[int]:
|
||||
"""Return every profile touched by an ordered job."""
|
||||
# Note: Profile-transfer jobs touch both source and target profiles, so they must be ordered across both sides.
|
||||
ids: set[int] = set()
|
||||
try:
|
||||
profile_id = int((row or {}).get("profile_id") or 0)
|
||||
if profile_id:
|
||||
ids.add(profile_id)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
payload = _job_payload(row)
|
||||
target_id = int(payload.get("target_profile_id") or 0)
|
||||
if str((row or {}).get("action") or "") == "profile_transfer" and target_id:
|
||||
ids.add(target_id)
|
||||
except Exception:
|
||||
pass
|
||||
return ids
|
||||
|
||||
|
||||
def _ordered_locks_for(row) -> list[threading.Lock]:
|
||||
"""Acquire locks in stable order to avoid deadlocks between cross-profile jobs."""
|
||||
return [_get_exclusive_lock(profile_id) for profile_id in sorted(_ordered_profile_ids(row))]
|
||||
|
||||
|
||||
def _has_prior_ordered_jobs(profile_ids: set[int], rowid: int) -> bool:
|
||||
if not profile_ids:
|
||||
return False
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT rowid AS _rowid, action, payload_json
|
||||
SELECT rowid AS _rowid, profile_id, action, payload_json
|
||||
FROM jobs
|
||||
WHERE profile_id=?
|
||||
AND rowid<?
|
||||
WHERE rowid<?
|
||||
AND status IN ('pending', 'running')
|
||||
ORDER BY rowid
|
||||
""",
|
||||
(profile_id, rowid),
|
||||
(rowid,),
|
||||
).fetchall()
|
||||
return any(_is_ordered_job(row) and not _is_priority_job(row) for row in rows)
|
||||
for row in rows:
|
||||
if not _is_ordered_job(row) or _is_priority_job(row):
|
||||
continue
|
||||
if profile_ids.intersection(_ordered_profile_ids(row)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for_prior_ordered_jobs(job_id: str, profile_id: int, rowid: int) -> bool:
|
||||
while _has_prior_ordered_jobs(profile_id, rowid):
|
||||
def _wait_for_prior_ordered_jobs(job_id: str, profile_ids: set[int], rowid: int) -> bool:
|
||||
while _has_prior_ordered_jobs(profile_ids, rowid):
|
||||
fresh = _job_row(job_id)
|
||||
if not fresh or fresh["status"] == "cancelled":
|
||||
return False
|
||||
@@ -195,7 +223,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non
|
||||
job_id = uuid.uuid4().hex
|
||||
if force:
|
||||
payload = dict(payload or {})
|
||||
# Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation.
|
||||
payload['force_job'] = True
|
||||
payload['priority_job'] = True
|
||||
now = utcnow()
|
||||
@@ -205,7 +232,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non
|
||||
"INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now),
|
||||
)
|
||||
# Note: Queued jobs are now written to operation logs so work is visible before a worker starts it.
|
||||
operation_logs.record_job_event(profile_id, action_name, "queued", payload, job_id=job_id, user_id=user_id)
|
||||
_emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
|
||||
_submit_job(job_id, action_name)
|
||||
@@ -217,7 +243,6 @@ def _job_event_meta(payload: dict) -> dict:
|
||||
source = str(ctx.get("source") or payload.get("source") or "user")
|
||||
meta = {"source": source}
|
||||
if source == "automation":
|
||||
# Note: Socket operation toasts use this flag so automation notifications respect user preferences.
|
||||
meta["automation"] = True
|
||||
meta["source_label"] = str(ctx.get("rule_name") or "automation")
|
||||
if ctx.get("rule_id") is not None:
|
||||
@@ -226,7 +251,6 @@ def _job_event_meta(payload: dict) -> dict:
|
||||
|
||||
|
||||
|
||||
|
||||
def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | None = None) -> bool:
|
||||
# Note: Disk usage refreshes only when a remove job actually requested data deletion.
|
||||
if str(action_name or "") != "remove":
|
||||
@@ -239,7 +263,6 @@ def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | Non
|
||||
|
||||
def _clear_disk_refresh_cache(profile_id: int) -> None:
|
||||
try:
|
||||
# Note: Remove-with-data jobs invalidate disk cache before notifying browsers, otherwise /api/system/disk may return stale values.
|
||||
rtorrent.clear_profile_runtime_caches(int(profile_id))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -247,7 +270,6 @@ def _clear_disk_refresh_cache(profile_id: int) -> None:
|
||||
|
||||
def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None:
|
||||
_clear_disk_refresh_cache(profile_id)
|
||||
# Note: The browser performs the fresh /api/system/disk read so profile-scoped disk monitor preferences stay respected.
|
||||
_emit("disk_refresh_requested", {
|
||||
"profile_id": int(profile_id),
|
||||
"hash_count": int(hash_count or 0),
|
||||
@@ -282,7 +304,6 @@ def _schedule_profile_disk_refresh(profile_id: int, hash_count: int = 0) -> None
|
||||
old_timer = _disk_refresh_timers.get(key)
|
||||
if old_timer:
|
||||
old_timer.cancel()
|
||||
# Note: Repeated delete jobs share one delayed refresh per profile and delay, preventing timer storms during bulk cleanup.
|
||||
timer = threading.Timer(float(delay_seconds), _run_delayed_disk_refresh, args=(profile_id, int(delay_seconds)))
|
||||
timer.daemon = True
|
||||
_disk_refresh_timers[key] = timer
|
||||
@@ -299,9 +320,14 @@ def _emit_disk_refresh_requested(profile_id: int, action_name: str, payload: dic
|
||||
_schedule_profile_disk_refresh(int(profile_id), len((payload or {}).get("hashes") or []))
|
||||
|
||||
def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
|
||||
def checkpoint(next_state: dict, current: int, total: int):
|
||||
# Note: Checkpoint is defined before every action branch so profile-transfer jobs can resume safely.
|
||||
job_id = payload.get("__job_id")
|
||||
if job_id:
|
||||
_checkpoint_job(str(job_id), next_state, current, total)
|
||||
|
||||
if action_name == "smart_queue_check":
|
||||
from . import smart_queue
|
||||
# Note: Worker execution uses the job owner instead of Flask session state.
|
||||
return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
|
||||
if action_name == "add_magnet":
|
||||
if bool(payload.get("start", True)):
|
||||
@@ -313,6 +339,12 @@ def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None
|
||||
if bool(payload.get("start", True)):
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
return rtorrent.add_torrent_raw(profile, raw, bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""), payload.get("file_priorities") or None)
|
||||
if action_name == "profile_transfer":
|
||||
# Note: Target profile is resolved inside the worker with the original user's permissions, not trusted from the request payload.
|
||||
target_profile = get_profile(int(payload.get("target_profile_id") or 0), user_id or default_user_id())
|
||||
if not target_profile:
|
||||
raise ValueError("Target profile does not exist or is not accessible")
|
||||
return rtorrent.transfer_profile(profile, target_profile, payload.get("hashes") or [], payload, checkpoint=checkpoint, resume_state=payload.get("__resume_state") or {})
|
||||
if action_name == "set_limits":
|
||||
return rtorrent.set_limits(profile, payload.get("down"), payload.get("up"))
|
||||
hashes = payload.get("hashes") or []
|
||||
@@ -320,11 +352,6 @@ def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
state = payload.get("__resume_state") or {}
|
||||
|
||||
def checkpoint(next_state: dict, current: int, total: int):
|
||||
job_id = payload.get("__job_id")
|
||||
if job_id:
|
||||
_checkpoint_job(str(job_id), next_state, current, total)
|
||||
|
||||
return rtorrent.action(profile, hashes, action_name, payload, checkpoint=checkpoint, resume_state=state)
|
||||
|
||||
|
||||
@@ -352,18 +379,17 @@ def _mark_running(job_id: str, attempts: int) -> bool:
|
||||
|
||||
|
||||
def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
|
||||
if action_name not in {"add_magnet", "add_torrent_raw", "remove", "move", "start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "recheck"}:
|
||||
if action_name not in {"add_magnet", "add_torrent_raw", "remove", "move", "profile_transfer", "start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "recheck"}:
|
||||
return
|
||||
try:
|
||||
diff = torrent_cache.refresh(profile)
|
||||
profile_id = int(profile["id"])
|
||||
if diff.get("ok"):
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
_emit("torrent_patch", {**diff, "summary": cached_summary(profile_id, rows, force=True)})
|
||||
_emit("torrent_patch", {**diff, "profile_id": profile_id, "summary": cached_summary(profile_id, rows, force=True)})
|
||||
else:
|
||||
_emit("rtorrent_error", diff)
|
||||
_emit("rtorrent_error", {**diff, "profile_id": profile_id})
|
||||
except Exception as exc:
|
||||
# Note: A failed live refresh must not change the already completed job result.
|
||||
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
|
||||
|
||||
|
||||
@@ -372,7 +398,6 @@ def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None:
|
||||
return
|
||||
|
||||
def delayed_refresh():
|
||||
# Note: rTorrent may expose state changes one poll later than the XML-RPC action result.
|
||||
sleep_fn = getattr(_socketio, "sleep", time.sleep)
|
||||
for delay in (0.75, 1.75):
|
||||
sleep_fn(delay)
|
||||
@@ -385,7 +410,7 @@ def _run(job_id: str):
|
||||
if not _claim_runner(job_id):
|
||||
return
|
||||
sem = None
|
||||
ordered_lock = None
|
||||
ordered_locks: list[threading.Lock] = []
|
||||
job = {}
|
||||
payload = {}
|
||||
try:
|
||||
@@ -395,16 +420,17 @@ def _run(job_id: str):
|
||||
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)
|
||||
# Note: Profile lookup failures used to appear only in the job queue; they are now persisted in operation logs too.
|
||||
operation_logs.record_worker_event(int(job.get("profile_id") or 0), str(job.get("action") or ""), "failed", "Job failed: rTorrent profile does not exist", job_id=job_id, user_id=int(job.get("user_id") or 0), error="profile not found")
|
||||
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"})
|
||||
return
|
||||
profile_id = int(profile["id"])
|
||||
if _is_ordered_job(job) and not _is_priority_job(job):
|
||||
if not _wait_for_prior_ordered_jobs(job_id, profile_id, int(job["_rowid"])):
|
||||
involved_profile_ids = _ordered_profile_ids(job)
|
||||
if not _wait_for_prior_ordered_jobs(job_id, involved_profile_ids, int(job["_rowid"])):
|
||||
return
|
||||
ordered_lock = _get_exclusive_lock(profile_id)
|
||||
ordered_lock.acquire()
|
||||
ordered_locks = _ordered_locks_for(job)
|
||||
for lock in ordered_locks:
|
||||
lock.acquire()
|
||||
sem = _get_sem(profile, light=_is_light_job(job))
|
||||
sem.acquire()
|
||||
job = _job_row(job_id)
|
||||
@@ -422,17 +448,22 @@ def _run(job_id: str):
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
||||
result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0))
|
||||
fresh = _job_row(job_id)
|
||||
# Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state.
|
||||
if fresh and fresh["status"] != "running":
|
||||
return
|
||||
_set_job(job_id, "done", result=result, finished=True)
|
||||
operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
|
||||
# Note: Remove-with-data jobs ask connected browsers to refresh disk usage immediately after filesystem deletion finishes.
|
||||
action_name = str(job["action"] or "")
|
||||
_emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result or {})
|
||||
# Note: Completed jobs must publish a fresh torrent snapshot/patch so removed or moved torrents disappear without a page reload.
|
||||
_emit_torrent_refresh(profile, action_name)
|
||||
if action_name == "profile_transfer":
|
||||
# Note: Refresh the destination profile cache as well so users see transferred torrents immediately after switching.
|
||||
try:
|
||||
target_profile = get_profile(int(payload.get("target_profile_id") or 0), int(job.get("user_id") or 0))
|
||||
if target_profile:
|
||||
_emit_torrent_refresh(target_profile, action_name)
|
||||
except Exception:
|
||||
pass
|
||||
_schedule_delayed_torrent_refresh(profile, action_name)
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
||||
except Exception as exc:
|
||||
@@ -456,8 +487,8 @@ def _run(job_id: str):
|
||||
finally:
|
||||
if sem:
|
||||
sem.release()
|
||||
if ordered_lock:
|
||||
ordered_lock.release()
|
||||
for lock in reversed(ordered_locks):
|
||||
lock.release()
|
||||
_release_runner(job_id)
|
||||
|
||||
|
||||
@@ -495,7 +526,6 @@ def _timeout_running_jobs() -> None:
|
||||
continue
|
||||
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
|
||||
_set_job(row["id"], "failed", message, finished=True)
|
||||
# Note: Watchdog timeouts are stored in operation logs because no normal worker exception may be raised.
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "timeout", message, job_id=row["id"], user_id=int(row.get("user_id") or 0), error=message)
|
||||
_emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"})
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message})
|
||||
@@ -514,8 +544,7 @@ def _resubmit_interrupted_running_jobs() -> None:
|
||||
if not profile:
|
||||
continue
|
||||
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at"))
|
||||
# Note: After process restart there is no in-memory runner for this job.
|
||||
# A short grace avoids stealing work from another still-alive Gunicorn worker.
|
||||
|
||||
if last_seen_ts is not None and now_ts - last_seen_ts < 90:
|
||||
continue
|
||||
with connect() as conn:
|
||||
@@ -524,7 +553,6 @@ def _resubmit_interrupted_running_jobs() -> None:
|
||||
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
|
||||
)
|
||||
if int(cur.rowcount or 0):
|
||||
# Note: Interrupted jobs returned to the queue are logged so restart recovery is auditable.
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Interrupted job resubmitted from checkpoint", job_id=row["id"], user_id=int(row.get("user_id") or 0))
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
@@ -547,7 +575,6 @@ def _resubmit_stale_pending_jobs() -> None:
|
||||
continue
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"]))
|
||||
# Note: Stale pending resubmits are logged to explain duplicated queue attempts after watchdog recovery.
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Stale pending job resubmitted by watchdog", job_id=row["id"], user_id=int(row.get("user_id") or 0))
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
@@ -586,7 +613,6 @@ def _job_summary(row: dict, payload: dict, result: dict) -> str:
|
||||
count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
parts = []
|
||||
if ctx.get("bulk_label"):
|
||||
# Note: Shows which generated bulk part is being displayed in the job queue.
|
||||
parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
|
||||
if count:
|
||||
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)")
|
||||
@@ -652,7 +678,6 @@ def cancel_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row["status"] not in {"pending", "running"}:
|
||||
return False
|
||||
# Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup.
|
||||
_set_job(job_id, "cancelled", finished=True)
|
||||
payload = _job_payload(row)
|
||||
operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "cancelled", payload, error="Cancelled by user", job_id=job_id, user_id=int(row.get("user_id") or 0))
|
||||
@@ -670,7 +695,6 @@ def clear_jobs() -> int:
|
||||
|
||||
|
||||
def emergency_clear_jobs() -> int:
|
||||
# Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list.
|
||||
now = utcnow()
|
||||
where, params = _job_scope_sql(writable=True)
|
||||
status_clause = "status IN ('pending', 'running')"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const rt=poller.runtime||{}, ps=poller.settings||{};\n // Note: App status now keeps only unique operational diagnostics; storage, jobs, planner and queue details stay in their dedicated tools.\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'),\n diagCard('Mode', rt.adaptive_mode||'-'),\n diagCard('Live interval', `${rt.live_stats_interval_seconds ?? ps.live_stats_interval_seconds ?? '-'}s`),\n diagCard('List interval', `${rt.torrent_list_interval_seconds ?? ps.torrent_list_interval_seconds ?? '-'}s`),\n diagCard('Last tick', `${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', rt.rtorrent_call_count||0)\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`<div class=\"column-manager-tabs appstatus-tabs\"><ul class=\"nav nav-pills\">${panes.map((p,i)=>`<li class=\"nav-item\"><button class=\"nav-link ${i?'':'active'}\" type=\"button\" data-appstatus-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`<div class=\"appstatus-pane ${i?'d-none':''}\" data-appstatus-panel=\"${p[0]}\">${p[2]}</div>`).join('')}${scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''}`;\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';";
|
||||
export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,pollerResponse]=await Promise.all([\n fetch('/api/app/status',{cache:'no-store'}).then(r=>r.json()),\n fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const pollerBundle=(pollerResponse && pollerResponse.ok!==false) ? pollerResponse : (st.poller||{});\n const rt=pollerBundle.runtime||{}, ps=pollerBundle.settings||{};\n // Note: App status uses embedded poller data as a fallback, so one failing endpoint cannot leave Runtime poller empty.\n const intervalValue=(runtimeKey,settingsKey)=>rt[runtimeKey] ?? ps[settingsKey] ?? '-';\n const runtimeReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0 || Number(rt.last_tick_ms||0)>0);\n const waiting=!runtimeReady && rt.runtime_ready===false;\n const mode=waiting?'waiting':(rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal'));\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', mode),\n diagCard('Live interval', `${intervalValue('live_stats_interval_seconds','live_stats_interval_seconds')}s`),\n diagCard('List interval', `${intervalValue('torrent_list_interval_seconds','torrent_list_interval_seconds')}s`),\n diagCard('Last tick', waiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', waiting?'waiting':`${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', waiting?'waiting':fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', waiting?'waiting':(rt.rtorrent_call_count||0))\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`<div class=\"column-manager-tabs appstatus-tabs\"><ul class=\"nav nav-pills\">${panes.map((p,i)=>`<li class=\"nav-item\"><button class=\"nav-link ${i?'':'active'}\" type=\"button\" data-appstatus-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`<div class=\"appstatus-pane ${i?'d-none':''}\" data-appstatus-panel=\"${p[0]}\">${p[2]}</div>`).join('')}${scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''}`;\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';";
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('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||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
|
||||
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('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||'-')];\n const pollerReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0);\n const pollerWaiting=!pollerReady && rt.runtime_ready===false;\n const pollerCards=[diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'), diagCard('Mode', pollerWaiting?'waiting':(rt.adaptive_mode||'-')), diagCard('Effective interval', `${rt.effective_interval_seconds??rt.live_stats_interval_seconds??ps.live_stats_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', pollerWaiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', pollerWaiting?'waiting':`${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', pollerWaiting?'waiting':fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', pollerWaiting?'waiting':(rt.rtorrent_call_count||0)), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }";
|
||||
export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n const nextFilter = String(prefs.active_filter || 'all');\n // Note: Profile switches refresh the in-memory filter/sort immediately, matching the state that a full page reload would load.\n activeTrackerFilter = nextFilter.startsWith('tracker:') ? nextFilter.slice(8) : '';\n activeFilter = nextFilter.startsWith('tracker:') ? 'all' : nextFilter;\n mobileActiveFilterKey = nextFilter || 'all';\n try{\n const nextSort = typeof prefs.torrent_sort_json === 'string' ? JSON.parse(prefs.torrent_sort_json || '{}') : (prefs.torrent_sort_json || {});\n if(SORT_KEYS.has(nextSort.key)) sortState = {key: nextSort.key, dir: Number(nextSort.dir) < 0 ? -1 : 1};\n }catch(_){}\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
export const profileListSource = " function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=profileDiagnosticStatusLabel(d.status || 'unknown'); const cls=profileDiagnosticStatusClass(st); const response=d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` · slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; return `<div class=\"profile-row ${isActive?'active':''}\" data-profile-id=\"${esc(p.id)}\" aria-current=\"${isActive?'true':'false'}\"><b>${esc(p.name)} <span data-active-profile-badge class='badge text-bg-primary ms-1 ${isActive?'':'d-none'}'>active</span> ${p.is_remote?\"<span class='badge text-bg-secondary ms-1'>remote</span>\":''} <span class=\"badge text-bg-${cls}\">${esc(st)}</span></b><span>${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}</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-info\" data-test-saved-profile=\"${p.id}\" title=\"Diagnostics\"><i class=\"fa-solid fa-stethoscope\"></i></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-can\"></i> Remove</button></div></div>`; }).join('')||'No profiles.'; }\n";
|
||||
export const profileListSource = " function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){\n const j=await (await fetch('/api/profiles')).json();\n profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p]));\n const active=String(j.active?.id ?? activeProfileId ?? '');\n const rows=j.profiles||[];\n const statusMap=new Map();\n try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){}\n $('profileList').innerHTML=rows.map(p=>{\n const d=statusMap.get(String(p.id))||{};\n const st=profileDiagnosticStatusLabel(d.status || 'unknown');\n const cls=profileDiagnosticStatusClass(st);\n const response=d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:'';\n const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:'';\n const isActive=String(p.id)===active;\n const backupIcon=p.profile_backup_enabled?`<span class=\"profile-backup-icon\" title=\"Automatic profile backup enabled\" aria-label=\"Automatic profile backup enabled\"><i class=\"fa-solid fa-floppy-disk\"></i></span>`:'';\n return `<div class=\"profile-row ${isActive?'active':''}\" data-profile-id=\"${esc(p.id)}\" aria-current=\"${isActive?'true':'false'}\"><b><span class=\"profile-id-badge\">#${esc(p.id)}</span> ${esc(p.name)} <span data-active-profile-badge class='badge text-bg-primary ms-1 ${isActive?'':'d-none'}'>active</span> ${p.is_remote?\"<span class='badge text-bg-secondary ms-1'>remote</span>\":''} <span class=\"badge text-bg-${cls}\">${esc(st)}</span></b><span>ID ${esc(p.id)} \u00b7 ${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}</span><div class=\"profile-actions\">${backupIcon}<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-info\" data-test-saved-profile=\"${p.id}\" title=\"Diagnostics\"><i class=\"fa-solid fa-stethoscope\"></i></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-can\"></i> Remove</button></div></div>`;\n }).join('')||'No profiles.';\n }\n";
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; 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=`<h6>Groups</h6>${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.owner_name||'-'),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('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(); });\n $('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(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('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(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('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,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
|
||||
export const ratioToolsSource = " async function deleteRatioGroup(groupId, groupName){\n if(!groupId) return;\n if(!confirm(`Delete ratio group \"${groupName || groupId}\"? Assigned torrents will lose only this group link.`)) return;\n try{\n await post(`/api/ratio-groups/${encodeURIComponent(groupId)}`,{},'DELETE');\n toast('Ratio group deleted','success');\n await loadRatios();\n }catch(e){ toast(e.message,'danger'); }\n }\n async function loadRatios(){\n const j=await (await fetch('/api/ratio-groups')).json();\n const groups=j.groups||[], history=j.history||[];\n 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('');\n if($('ratioManager')){\n const groupRows=groups.map(g=>[\n esc(g.name),\n esc(g.owner_name||'-'),\n esc(g.min_ratio),\n esc(g.max_ratio),\n esc(g.seed_time_minutes||g.min_seed_time_minutes||0),\n esc(g.action),\n esc(g.move_path||''),\n esc(g.set_label||''),\n g.enabled?'yes':'no',\n `<button class=\"btn btn-xs btn-outline-danger ratio-group-delete\" type=\"button\" data-ratio-group-id=\"${esc(g.id)}\" data-ratio-group-name=\"${esc(g.name)}\" title=\"Delete ratio group\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`\n ]);\n const historyRows=history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]);\n $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled','Delete'],groupRows)}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],historyRows)}`;\n }\n }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('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(); });\n $('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(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('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(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios);\n $('ratioManager')?.addEventListener('click',e=>{ const btn=e.target.closest('.ratio-group-delete'); if(btn) deleteRatioGroup(btn.dataset.ratioGroupId, btn.dataset.ratioGroupName); });\n $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); });\n $('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,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); });\n $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; 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');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); ";
|
||||
export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),j.runtime||null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; 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');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); ";
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
export const torrentDetailsLoaderSource = " async function loadDetails(tab, options={}){\n const hash=selectedHash;\n const t=torrents.get(hash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if(tab !== 'peers') clearReverseDnsPeerRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`<pre class=\"torrent-log-message\">${esc(t.message||'No logs')}</pre>`;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`<div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading ${esc(tab)}...</div>`;\n try{\n // Note: Background peer refresh keeps the current table visible and only swaps in fresh rows after a successful response.\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(hash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(hash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab() || selectedHash!==hash) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`<div class=\"text-danger\">${esc(e.message)}</div>`;\n }\n }\n";
|
||||
export const torrentDetailsLoaderSource = " async function loadDetails(tab, options={}){\n const hash=selectedHash;\n const t=torrents.get(hash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if(tab !== 'peers') clearReverseDnsPeerRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n // Note: The Log tab uses the same torrent message field as General and renders it as normal readable text.\n $('detailPane').innerHTML=`<div class=\"torrent-log-message\">${esc(t.message||'No logs')}</div>`;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`<div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading ${esc(tab)}...</div>`;\n try{\n // Note: Background peer refresh keeps the current table visible and only swaps in fresh rows after a successful response.\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(hash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(hash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab() || selectedHash!==hash) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`<div class=\"text-danger\">${esc(e.message)}</div>`;\n }\n }\n";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const torrentFilterHelpersSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n";
|
||||
export const torrentFilterHelpersSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', queued:'countQueued', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='queued' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
export const torrentGeneralDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>`<span class=\"chip label-mini\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)}</span>`).join(' ') || '<span class=\"text-muted\">-</span>';\n const ratioGroup=t.ratio_group ? `<span class=\"badge text-bg-info\">${esc(t.ratio_group)}</span>` : '<span class=\"text-muted\">Not assigned</span>';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Created', esc(formatDateTime(t.created))],\n ['Last activity', esc(formatDateTime(t.last_activity))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`<div class=\"general-stat\"><b>${label}</b><span>${value}</span></div>`).join('');\n $('detailPane').innerHTML=`\n <section class=\"general-summary\">\n <div class=\"general-summary-main\">\n <div class=\"general-title-row\"><h6>${esc(t.name||'-')}</h6><span class=\"badge text-bg-${statusClass}\">${esc(t.status||'-')}</span></div>\n <div class=\"general-path\"><b>Directory</b><span>${esc(t.path||'-')}</span></div>\n <div class=\"general-path\"><b>Full data path</b><span>${esc(fullPath)}</span></div>\n </div>\n <div class=\"general-summary-side\"><b>Hash</b><code>${esc(t.hash||'-')}</code></div>\n </section>\n <div class=\"general-grid\">${cards}</div>\n <div class=\"general-meta\"><div><b>Labels</b><span>${labels}</span></div><div><b>Ratio rule</b><span>${ratioGroup}</span></div><div><b>Message</b><span>${esc(t.message||'-')}</span></div></div>`;\n }\n";
|
||||
export const torrentGeneralDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function generalInfoItem(label, value, className=''){\n const modifier = className ? ` ${className}` : '';\n return `<div class=\"general-info-item${modifier}\"><b>${esc(label)}</b><span>${value}</span></div>`;\n }\n function generalCodeValue(value){\n return `<code>${esc(value || '-')}</code>`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>`<span class=\"chip label-mini\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)}</span>`).join(' ') || '<span class=\"text-muted\">-</span>';\n const ratioGroup=t.ratio_group ? `<span class=\"badge text-bg-info\">${esc(t.ratio_group)}</span>` : '<span class=\"text-muted\">Not assigned</span>';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const stats=[\n ['Size', esc(t.size_h||'-')],\n ['Done', `${esc(t.progress??0)}%`],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['DL / UL', `${esc(t.down_rate_h||'-')} / ${esc(t.up_rate_h||'-')}`],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Priority', esc(t.priority??'-')],\n ['Created', esc(formatDateTime(t.created))],\n ['Last activity', esc(formatDateTime(t.last_activity))],\n ].map(([label,value])=>generalInfoItem(label,value)).join('');\n const meta=[\n generalInfoItem('Directory', generalCodeValue(t.path)),\n generalInfoItem('Full data path', generalCodeValue(fullPath)),\n generalInfoItem('Hash', generalCodeValue(t.hash)),\n generalInfoItem('Labels', labels),\n generalInfoItem('Ratio rule', ratioGroup),\n ].join('');\n // Note: General details keep path-like values in code blocks for easier copying and omit tracker message because it is shown in Log.\n $('detailPane').innerHTML=`\n <section class=\"general-panel\">\n <div class=\"general-header\">\n <h6>${esc(t.name||'-')}</h6>\n <span class=\"badge text-bg-${statusClass}\">${esc(t.status||'-')}</span>\n </div>\n <div class=\"general-stats\">${stats}</div>\n <div class=\"general-info\">${meta}</div>\n </section>`;\n }\n";
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user