Merge pull request 'multilang_and_other' (#8) from multilang_and_other into master

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
gru
2026-05-28 22:08:32 +02:00
19 changed files with 1071 additions and 171 deletions

View File

@@ -23,7 +23,7 @@ from flask import Blueprint, jsonify, request, abort, send_file, redirect, Respo
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
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
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
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
@@ -290,6 +290,7 @@ def cleanup_summary() -> dict:
(profile_id,),
) if profile_id else _table_count("operation_logs")
operation_log_retention = operation_logs.get_settings(profile_id) if profile_id else operation_logs.get_settings(0)
poller_runtime = poller_control.snapshot(profile_id) if profile_id else {}
return {
"jobs_total": _table_count("jobs"),
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
@@ -298,6 +299,7 @@ def cleanup_summary() -> dict:
"automation_history_total": _table_count("automation_history"),
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
"cache": _active_profile_cache_summary(profile_id if profile_id else None),
"poller_runtime": poller_runtime,
"retention_days": {
"jobs": JOBS_RETENTION_DAYS,
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,

View File

@@ -253,6 +253,18 @@ def cleanup_automations():
@bp.post("/cleanup/poller-diagnostics")
def cleanup_poller_diagnostics():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
# Note: This cleanup clears only in-memory poller diagnostics; polling, settings and torrent state are preserved.
runtime = poller_control.reset_runtime_stats(profile_id)
return ok({"deleted": {"poller_runtime_counters": 1}, "runtime": runtime, "cleanup": cleanup_summary()})
@bp.post("/cleanup/all")
def cleanup_all():
deleted_jobs = clear_jobs()

View File

@@ -11,10 +11,11 @@ from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
DEFAULTS = {
"adaptive_enabled": True,
"safe_fallback_enabled": True,
"active_interval_seconds": 5.0,
"active_interval_seconds": 3.0,
"idle_interval_seconds": 15.0,
"error_interval_seconds": 30.0,
"torrent_list_interval_seconds": 5.0,
"live_stats_interval_seconds": 3.0,
"torrent_list_interval_seconds": 30.0,
"system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.0,
@@ -27,6 +28,20 @@ DEFAULTS = {
"recovery_after_errors": 3,
}
SAFE_FALLBACK_MINIMUMS = {
"active_interval_seconds": 3.0,
"idle_interval_seconds": 15.0,
"error_interval_seconds": 30.0,
"live_stats_interval_seconds": 3.0,
"torrent_list_interval_seconds": 30.0,
"system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.0,
"queue_stats_interval_seconds": 15.0,
"slow_stats_interval_seconds": 60.0,
"heartbeat_interval_seconds": 15.0,
}
def _key(profile_id: int) -> str:
return f"poller.settings.{int(profile_id)}"
@@ -52,6 +67,7 @@ def normalize_settings(data: dict | None) -> dict:
"active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0),
"idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
"live_stats_interval_seconds": _coerce_float(raw.get("live_stats_interval_seconds"), DEFAULTS["live_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 60.0),
"torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
@@ -65,9 +81,9 @@ 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"]:
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"):
if settings[key] <= 0:
settings[key] = DEFAULTS[key]
# 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
@@ -102,6 +118,8 @@ def save_settings(profile_id: int, data: dict) -> dict:
class ProfilePollState:
profile_id: int
last_fast_at: float = 0.0
last_live_at: float = 0.0
last_list_at: float = 0.0
last_system_at: float = 0.0
last_slow_at: float = 0.0
last_tracker_at: float = 0.0
@@ -122,6 +140,24 @@ class ProfilePollState:
skipped_emissions: int = 0
emitted_payload_size: int = 0
rtorrent_call_count: int = 0
live_poll_count: int = 0
list_poll_count: int = 0
live_updated_total: int = 0
live_full_refresh_requested_total: int = 0
list_added_total: int = 0
list_updated_total: int = 0
list_removed_total: int = 0
last_live_duration_ms: float = 0.0
last_list_duration_ms: float = 0.0
last_live_updated_count: int = 0
last_list_added_count: int = 0
last_list_updated_count: int = 0
last_list_removed_count: int = 0
last_live_ok: bool = True
last_list_ok: bool = True
last_live_error: str = ""
last_list_error: str = ""
last_live_requires_full_refresh: bool = False
adaptive_mode: str = "normal"
slow_task_running: bool = False
system_task_running: bool = False
@@ -151,12 +187,29 @@ def interval_for(settings: dict, state: ProfilePollState) -> float:
return base
def effective_live_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("live_stats_interval_seconds") or DEFAULTS["live_stats_interval_seconds"]))
def effective_list_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
def effective_fast_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
# Note: Kept for compatibility with older diagnostics; the fast interval now means lightweight live stats.
return effective_live_interval(settings, state)
def should_live_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_live_at) >= effective_live_interval(settings, state)
def should_list_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_list_at) >= effective_list_interval(settings, state)
def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_fast_at) >= effective_fast_interval(settings, state)
return should_live_poll(now, settings, state)
def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
@@ -185,6 +238,69 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change
return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"])
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)
state.live_updated_total += int(updated_count or 0)
state.last_live_requires_full_refresh = bool(requires_full_refresh)
if requires_full_refresh:
state.live_full_refresh_requested_total += 1
state.last_live_ok = bool(ok)
state.last_live_error = str(error or "")
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)
state.last_list_updated_count = int(updated_count or 0)
state.last_list_removed_count = int(removed_count or 0)
state.list_added_total += int(added_count or 0)
state.list_updated_total += int(updated_count or 0)
state.list_removed_total += int(removed_count or 0)
state.last_list_ok = bool(ok)
state.last_list_error = str(error or "")
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
state.last_tick_started_at = 0.0
state.error_count = 0
state.slow_count = 0
state.skipped_emissions = 0
state.emitted_payload_size = 0
state.rtorrent_call_count = 0
state.live_poll_count = 0
state.list_poll_count = 0
state.live_updated_total = 0
state.live_full_refresh_requested_total = 0
state.list_added_total = 0
state.list_updated_total = 0
state.list_removed_total = 0
state.last_live_duration_ms = 0.0
state.last_list_duration_ms = 0.0
state.last_live_updated_count = 0
state.last_list_added_count = 0
state.last_list_updated_count = 0
state.last_list_removed_count = 0
state.last_live_ok = True
state.last_list_ok = True
state.last_live_error = ""
state.last_list_error = ""
state.last_live_requires_full_refresh = False
state.stats = {}
return snapshot(profile_id)
def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict:
now = time.monotonic()
effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS
@@ -194,7 +310,7 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0
state.last_tick_started_at = started_at
state.last_active = bool(active)
state.effective_interval_seconds = effective_fast_interval(effective_settings, state)
state.effective_interval_seconds = effective_live_interval(effective_settings, state)
state.last_ok = bool(ok)
state.last_error = str(error or "")
state.emitted_payload_size = int(emitted_payload_size or 0)
@@ -234,6 +350,8 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
"last_ok": state.last_ok,
"last_tick_gap_ms": state.last_tick_gap_ms,
"effective_interval_seconds": state.effective_interval_seconds,
"live_stats_interval_seconds": effective_live_interval(effective_settings, state),
"torrent_list_interval_seconds": effective_list_interval(effective_settings, state),
"configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
"last_error": state.last_error,
"duration_ms": state.last_tick_ms,
@@ -244,6 +362,24 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
"adaptive_mode": state.adaptive_mode,
"error_count": state.error_count,
"slow_count": state.slow_count,
"live_poll_count": state.live_poll_count,
"list_poll_count": state.list_poll_count,
"last_live_duration_ms": state.last_live_duration_ms,
"last_list_duration_ms": state.last_list_duration_ms,
"last_live_updated_count": state.last_live_updated_count,
"last_list_added_count": state.last_list_added_count,
"last_list_updated_count": state.last_list_updated_count,
"last_list_removed_count": state.last_list_removed_count,
"live_updated_total": state.live_updated_total,
"list_added_total": state.list_added_total,
"list_updated_total": state.list_updated_total,
"list_removed_total": state.list_removed_total,
"live_full_refresh_requested_total": state.live_full_refresh_requested_total,
"last_live_requires_full_refresh": state.last_live_requires_full_refresh,
"last_live_ok": state.last_live_ok,
"last_list_ok": state.last_list_ok,
"last_live_error": state.last_live_error,
"last_list_error": state.last_list_error,
"updated_at": utcnow(),
}
return dict(state.stats)
@@ -251,4 +387,26 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
def snapshot(profile_id: int) -> dict:
state = state_for(profile_id)
return dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
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.
data.update({
"live_poll_count": state.live_poll_count,
"list_poll_count": state.list_poll_count,
"last_live_duration_ms": state.last_live_duration_ms,
"last_list_duration_ms": state.last_list_duration_ms,
"last_live_updated_count": state.last_live_updated_count,
"last_list_added_count": state.last_list_added_count,
"last_list_updated_count": state.last_list_updated_count,
"last_list_removed_count": state.last_list_removed_count,
"live_updated_total": state.live_updated_total,
"list_added_total": state.list_added_total,
"list_updated_total": state.list_updated_total,
"list_removed_total": state.list_removed_total,
"live_full_refresh_requested_total": state.live_full_refresh_requested_total,
"last_live_requires_full_refresh": state.last_live_requires_full_refresh,
"last_live_ok": state.last_live_ok,
"last_list_ok": state.last_list_ok,
"last_live_error": state.last_live_error,
"last_list_error": state.last_list_error,
})
return data

View File

@@ -3,45 +3,347 @@ from __future__ import annotations
from .client import *
RTORRENT_CONFIG_FIELDS = [
{"group": "Directories", "key": "directory.default", "label": "Default download directory", "type": "text"},
{"group": "Directories", "key": "session.path", "label": "Session path", "type": "text"},
{"group": "Directories", "key": "system.cwd", "label": "Working directory", "type": "text", "readonly": True},
{"group": "Network", "key": "network.port_range", "label": "Incoming port range", "type": "text", "placeholder": "49164-49164"},
{"group": "Network", "key": "network.port_random", "label": "Random incoming port", "type": "bool"},
{"group": "Network", "key": "network.bind_address", "label": "Bind address", "type": "text", "placeholder": "0.0.0.0"},
{"group": "Network", "key": "network.local_address", "label": "Local address", "type": "text"},
{"group": "Network", "key": "network.max_open_files", "label": "Max open files", "type": "number"},
{"group": "Network", "key": "network.max_open_sockets", "label": "Max open sockets", "type": "number"},
{"group": "Network", "key": "network.http.max_open", "label": "Max HTTP connections", "type": "number"},
{"group": "Network", "key": "network.http.ssl_verify_peer", "label": "Verify SSL peers", "type": "bool"},
{"group": "Network", "key": "network.xmlrpc.size_limit", "label": "XML-RPC upload size limit", "type": "text", "placeholder": "16M"},
{"group": "Peers", "key": "throttle.min_peers.normal", "label": "Min peers downloading", "type": "number"},
{"group": "Peers", "key": "throttle.max_peers.normal", "label": "Max peers downloading", "type": "number"},
{"group": "Peers", "key": "throttle.min_peers.seed", "label": "Min peers seeding", "type": "number"},
{"group": "Peers", "key": "throttle.max_peers.seed", "label": "Max peers seeding", "type": "number"},
{"group": "Peers", "key": "trackers.numwant", "label": "Tracker numwant", "type": "number"},
{"group": "Throttle", "key": "throttle.global_down.max_rate", "label": "Global download limit B/s", "type": "number"},
{"group": "Throttle", "key": "throttle.global_up.max_rate", "label": "Global upload limit B/s", "type": "number"},
{"group": "Throttle", "key": "throttle.max_downloads.global", "label": "Max active downloads", "type": "number"},
{"group": "Throttle", "key": "throttle.max_uploads.global", "label": "Max active uploads", "type": "number"},
{"group": "Throttle", "key": "throttle.max_downloads.div", "label": "Max downloads per throttle", "type": "number"},
{"group": "Throttle", "key": "throttle.max_uploads.div", "label": "Max uploads per throttle", "type": "number"},
{"group": "DHT / PEX", "key": "dht.mode", "label": "DHT mode", "type": "text", "placeholder": "disable/off/auto/on"},
{"group": "DHT / PEX", "key": "dht.port", "label": "DHT port", "type": "number"},
{"group": "DHT / PEX", "key": "protocol.pex", "label": "Peer exchange", "type": "bool"},
{"group": "Protocol", "key": "protocol.encryption.set", "label": "Encryption flags", "type": "text", "placeholder": "allow_incoming,try_outgoing,enable_retry"},
{"group": "Protocol", "key": "protocol.connection.leech", "label": "Leech connection type", "type": "text", "placeholder": "leech"},
{"group": "Protocol", "key": "protocol.connection.seed", "label": "Seed connection type", "type": "text", "placeholder": "seed"},
{"group": "Files", "key": "pieces.hash.on_completion", "label": "Hash check on completion", "type": "bool"},
{"group": "Files", "key": "pieces.preload.type", "label": "Pieces preload type", "type": "number"},
{"group": "Files", "key": "pieces.preload.min_size", "label": "Pieces preload min size", "type": "number"},
{"group": "Files", "key": "pieces.preload.min_rate", "label": "Pieces preload min rate", "type": "number"},
{"group": "Files", "key": "system.file.allocate", "label": "File allocation", "type": "number"},
{"group": "Files", "key": "system.file.max_size", "label": "Max file size", "type": "number"},
{"group": "System", "key": "system.umask", "label": "File umask", "type": "text", "placeholder": "0002"},
{"group": "System", "key": "system.hostname", "label": "Hostname", "type": "text", "readonly": True},
{"group": "System", "key": "system.client_version", "label": "Client version", "type": "text", "readonly": True},
{"group": "System", "key": "system.library_version", "label": "Library version", "type": "text", "readonly": True},
{
"group": "Directories",
"key": "directory.default",
"label": "Default download directory",
"type": "text",
"description": "Main destination for new downloads added without an explicit directory.",
"recommendation": "Use a stable absolute path on storage with enough free space; avoid changing it while active torrents use relative paths.",
},
{
"group": "Directories",
"key": "session.path",
"label": "Session path",
"type": "text",
"description": "Directory where rTorrent stores session state, resume data and internal torrent metadata.",
"recommendation": "Keep it on reliable local storage and include it in backups before maintenance.",
},
{
"group": "Directories",
"key": "system.cwd",
"label": "Working directory",
"type": "text",
"readonly": True,
"description": "Current rTorrent process working directory reported by rTorrent.",
"recommendation": "Read-only diagnostic value; change it in the service or startup configuration if needed.",
},
{
"group": "Network",
"key": "network.port_range",
"label": "Incoming port range",
"type": "text",
"placeholder": "49164-49164",
"description": "TCP port or range used for incoming peer connections.",
"recommendation": "Use a fixed forwarded port, for example 49164-49164, for stable connectivity.",
},
{
"group": "Network",
"key": "network.port_random",
"label": "Random incoming port",
"type": "bool",
"description": "Lets rTorrent select a random incoming port on startup.",
"recommendation": "Disable it when using router/NAT forwarding; fixed ports are easier to monitor.",
},
{
"group": "Network",
"key": "network.bind_address",
"label": "Bind address",
"type": "text",
"placeholder": "0.0.0.0",
"description": "Local interface address used for peer traffic binding.",
"recommendation": "Leave empty unless the host has multiple interfaces or policy routing.",
},
{
"group": "Network",
"key": "network.local_address",
"label": "Announced local address",
"type": "text",
"description": "Address rTorrent may announce as its local network address.",
"recommendation": "Usually leave empty; set only when a specific advertised address is required.",
},
{
"group": "Network",
"key": "network.max_open_files",
"label": "Max open files",
"type": "number",
"description": "Maximum number of files rTorrent can keep open at once.",
"recommendation": "Raise together with the OS file descriptor limit on large seeds.",
},
{
"group": "Network",
"key": "network.max_open_sockets",
"label": "Max open sockets",
"type": "number",
"description": "Upper bound for peer and tracker sockets opened by rTorrent.",
"recommendation": "Keep below OS limits; increase gradually when many torrents are active.",
},
{
"group": "Network",
"key": "network.http.max_open",
"label": "Max HTTP connections",
"type": "number",
"description": "Maximum simultaneous HTTP connections for tracker and metadata requests.",
"recommendation": "Moderate values reduce tracker pressure; increase only if tracker requests queue up.",
},
{
"group": "Network",
"key": "network.http.ssl_verify_peer",
"label": "Verify SSL peers",
"type": "bool",
"description": "Controls certificate verification for HTTPS tracker connections.",
"recommendation": "Keep enabled unless a private tracker has a known certificate problem.",
},
{
"group": "Network",
"key": "network.xmlrpc.size_limit",
"label": "XML-RPC upload size limit",
"type": "text",
"placeholder": "16M",
"description": "Maximum XML-RPC payload size accepted by rTorrent.",
"recommendation": "Keep enough headroom for large UI responses; avoid very high values on public endpoints.",
},
{
"group": "Peers",
"key": "throttle.min_peers.normal",
"label": "Min peers while downloading",
"type": "number",
"description": "Minimum peer target for incomplete torrents.",
"recommendation": "Use a conservative floor; too high values can waste sockets on weak swarms.",
},
{
"group": "Peers",
"key": "throttle.max_peers.normal",
"label": "Max peers while downloading",
"type": "number",
"description": "Maximum peer target for incomplete torrents.",
"recommendation": "Increase for fast lines, but keep total sockets and CPU usage under control.",
},
{
"group": "Peers",
"key": "throttle.min_peers.seed",
"label": "Min peers while seeding",
"type": "number",
"description": "Minimum peer target for complete torrents.",
"recommendation": "Lower than download min peers is usually enough for long-term seeding.",
},
{
"group": "Peers",
"key": "throttle.max_peers.seed",
"label": "Max peers while seeding",
"type": "number",
"description": "Maximum peer target for complete torrents.",
"recommendation": "Avoid excessive values on many seeding torrents because sockets multiply quickly.",
},
{
"group": "Peers",
"key": "trackers.numwant",
"label": "Tracker numwant",
"type": "number",
"description": "Number of peers requested from trackers per announce where supported.",
"recommendation": "Use moderate values; many trackers cap this server-side anyway.",
},
{
"group": "Throttle",
"key": "throttle.global_down.max_rate",
"label": "Global download limit B/s",
"type": "number",
"description": "Global download speed cap in bytes per second. Zero usually means unlimited.",
"recommendation": "Leave unlimited or cap below line speed if other services share the connection.",
},
{
"group": "Throttle",
"key": "throttle.global_up.max_rate",
"label": "Global upload limit B/s",
"type": "number",
"description": "Global upload speed cap in bytes per second. Zero usually means unlimited.",
"recommendation": "Keep below real upstream capacity to avoid bufferbloat and slow downloads.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads.global",
"label": "Max active downloads",
"type": "number",
"description": "Maximum number of downloading torrents active at once.",
"recommendation": "Match disk and network capacity; fewer active downloads often finish faster.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.global",
"label": "Max active uploads",
"type": "number",
"description": "Maximum number of uploading torrents active at once.",
"recommendation": "Keep enough slots for ratio goals without overloading disks and sockets.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads.div",
"label": "Max downloads per throttle",
"type": "number",
"description": "Per-throttle download slot divisor used by rTorrent throttling logic.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.div",
"label": "Max uploads per throttle",
"type": "number",
"description": "Per-throttle upload slot divisor used by rTorrent throttling logic.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "DHT / PEX",
"key": "dht.mode",
"label": "DHT mode",
"type": "text",
"placeholder": "disable/off/auto/on",
"description": "Controls Distributed Hash Table usage for peer discovery.",
"recommendation": "Private-tracker setups often disable DHT; public torrents usually benefit from auto/on.",
},
{
"group": "DHT / PEX",
"key": "dht.port",
"label": "DHT port",
"type": "number",
"description": "UDP port used by DHT traffic.",
"recommendation": "Use the same forwarded port strategy as incoming TCP when DHT is enabled.",
},
{
"group": "DHT / PEX",
"key": "protocol.pex",
"label": "Peer exchange",
"type": "bool",
"description": "Enables Peer Exchange peer discovery between connected peers.",
"recommendation": "Disable for strict private-tracker policies; enable for public swarms if allowed.",
},
{
"group": "DHT / PEX",
"key": "trackers.use_udp",
"label": "UDP trackers",
"type": "bool",
"description": "Allows rTorrent to use UDP trackers where supported.",
"recommendation": "Keep enabled for public torrents unless the network blocks UDP tracker traffic.",
},
{
"group": "Protocol",
"key": "protocol.encryption.set",
"label": "Encryption flags",
"type": "text",
"placeholder": "allow_incoming,try_outgoing,enable_retry",
"description": "Encryption policy flags for peer connections.",
"recommendation": "Prefer permissive settings unless a tracker or network requires strict encryption.",
},
{
"group": "Protocol",
"key": "protocol.connection.leech",
"label": "Leech connection type",
"type": "text",
"placeholder": "leech",
"description": "Connection behavior profile used by incomplete torrents.",
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
},
{
"group": "Protocol",
"key": "protocol.connection.seed",
"label": "Seed connection type",
"type": "text",
"placeholder": "seed",
"description": "Connection behavior profile used by complete torrents.",
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
},
{
"group": "Files",
"key": "pieces.hash.on_completion",
"label": "Hash check on completion",
"type": "bool",
"description": "Runs a hash verification after a torrent completes.",
"recommendation": "Enable for data integrity when storage is unreliable; disable if completion checks are too expensive.",
},
{
"group": "Files",
"key": "pieces.preload.type",
"label": "Pieces preload type",
"type": "number",
"description": "Controls how rTorrent preloads torrent pieces from disk.",
"recommendation": "Keep default unless you are tuning disk cache behavior for a known workload.",
},
{
"group": "Files",
"key": "pieces.preload.min_size",
"label": "Pieces preload min size",
"type": "number",
"description": "Minimum piece size threshold for preload behavior.",
"recommendation": "Keep default unless large-piece torrents show disk latency issues.",
},
{
"group": "Files",
"key": "pieces.preload.min_rate",
"label": "Pieces preload min rate",
"type": "number",
"description": "Minimum transfer rate threshold for preloading pieces.",
"recommendation": "Tune only after measuring disk read pressure.",
},
{
"group": "Files",
"key": "pieces.memory.max",
"label": "Pieces memory max",
"type": "text",
"placeholder": "512M",
"description": "Maximum memory rTorrent may use for piece handling where supported.",
"recommendation": "Avoid values that compete with OS page cache; increase only on hosts with spare RAM.",
},
{
"group": "Files",
"key": "system.file.allocate",
"label": "File allocation",
"type": "number",
"description": "Controls preallocation behavior for downloaded files.",
"recommendation": "Preallocation can reduce fragmentation but may slow adding very large torrents.",
},
{
"group": "Files",
"key": "system.file.max_size",
"label": "Max file size",
"type": "number",
"description": "Maximum single file size rTorrent accepts where supported.",
"recommendation": "Leave default unless you intentionally need to block oversized files.",
},
{
"group": "System",
"key": "system.umask",
"label": "File umask",
"type": "text",
"placeholder": "0002",
"description": "Permission mask applied to files created by rTorrent.",
"recommendation": "Use 0002 for shared media groups, 0022 for private single-user setups.",
},
{
"group": "System",
"key": "system.hostname",
"label": "Hostname",
"type": "text",
"readonly": True,
"description": "Hostname reported by the rTorrent runtime.",
"recommendation": "Read-only diagnostic value.",
},
{
"group": "System",
"key": "system.client_version",
"label": "Client version",
"type": "text",
"readonly": True,
"description": "rTorrent client version reported through XML-RPC.",
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
},
{
"group": "System",
"key": "system.library_version",
"label": "Library version",
"type": "text",
"readonly": True,
"description": "libTorrent library version used by rTorrent.",
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
},
]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from .client import *
from .. import poller_control
import shlex
def scgi_diagnostics(profile: dict) -> dict:
@@ -64,7 +65,12 @@ def scgi_diagnostics(profile: dict) -> dict:
def profile_diagnostics(profile: dict) -> dict:
"""Lightweight per-profile diagnostics for save/test UI."""
started = time.perf_counter()
result = {"profile_id": profile.get("id"), "ok": False, "checks": {}}
profile_id = profile.get("id")
try:
slow_threshold_ms = float(poller_control.get_settings(int(profile_id)).get("slow_response_threshold_ms") or poller_control.DEFAULTS["slow_response_threshold_ms"])
except Exception:
slow_threshold_ms = float(poller_control.DEFAULTS["slow_response_threshold_ms"])
result = {"profile_id": profile_id, "ok": False, "checks": {}, "slow_threshold_ms": slow_threshold_ms}
try:
c = client_for(profile)
version = str(c.call("system.client_version") or "")
@@ -96,7 +102,7 @@ def profile_diagnostics(profile: dict) -> dict:
free_disk[base] = {"error": str(exc)}
result.update({
"ok": True,
"status": "online",
"status": "normal",
"version": version,
"library_version": library,
"base_paths": paths,
@@ -106,7 +112,8 @@ def profile_diagnostics(profile: dict) -> dict:
})
except Exception as exc:
result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)})
if result.get("ok") and result.get("response_time_ms", 0) > 1500:
# Note: Profile diagnostics uses the same slow-response threshold as Tools -> Poller for this profile.
if result.get("ok") and result.get("response_time_ms", 0) > slow_threshold_ms:
result["status"] = "slow"
return result

View File

@@ -217,6 +217,13 @@ TORRENT_OPTIONAL_FIELDS = [
"d.timestamp.finished=",
]
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=",
]
def human_duration(seconds: int) -> str:
# Note: Download ETA is derived locally from remaining bytes and current download speed.
@@ -313,6 +320,65 @@ 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)
state = int(row[1] or 0)
down_rate = int(row[7] or 0)
up_rate = int(row[6] or 0)
ratio_raw = int(row[5] or 0)
remaining_bytes = max(0, size - completed)
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not complete else 0
msg = str(row[12] or "")
hashing = int(row[13] or 0)
is_active = int(row[14] or 0)
labels = str(row[15] or "")
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"
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,
"paused": is_paused,
"complete": complete,
"completed_bytes": completed,
"progress": progress,
"ratio": round(ratio_raw / 1000, 3),
"up_rate": up_rate,
"up_rate_h": human_rate(up_rate),
"down_rate": down_rate,
"down_rate_h": human_rate(down_rate),
"eta_seconds": eta_seconds,
"eta_h": human_duration(eta_seconds) if eta_seconds else "-",
"up_total": int(row[8] or 0),
"up_total_h": human_size(row[8] or 0),
"down_total": int(row[9] or 0),
"down_total_h": human_size(row[9] or 0),
"to_download": to_download_bytes,
"to_download_h": human_size(to_download_bytes) if to_download_bytes else "",
"peers": int(row[10] or 0),
"seeds": int(row[11] or 0),
"message": msg,
"status": status,
"post_check": post_check,
"hashing": hashing,
}
def list_torrent_live_stats(profile: dict) -> list[dict]:
"""Return lightweight live torrent stats for the fast poller."""
# Note: This avoids the full torrent row multicall on every speed/status tick.
rows = client_for(profile).d.multicall2("", "main", *LIVE_TORRENT_FIELDS)
return [normalize_live_row(list(row)) for row in rows]
def list_torrents(profile: dict) -> list[dict]:
c = client_for(profile)
try:

View File

@@ -977,13 +977,37 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
| {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)}
)
restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True)
# Note: Cooldown refill uses started incomplete torrents as queue slots. This diagnostic
# explains why a refill may legitimately start nothing even when only a few torrents transfer data.
active_transferring = sum(1 for t in downloading if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0)
active_rtorrent = sum(1 for t in downloading if int(t.get('active') or 0))
active_state = sum(1 for t in downloading if int(t.get('state') or 0))
active_after_expected = len(downloading) + len(start_requested)
if available_slots <= 0:
refill_decision = f'Cooldown refill skipped: active slots at limit ({len(downloading)}/{max_active})'
refill_blocked_reason = 'active_slots_at_limit'
elif not candidates:
refill_decision = 'Cooldown refill skipped: no stopped candidates available'
refill_blocked_reason = 'no_candidates'
elif start_requested:
refill_decision = f'Cooldown refill requested {len(start_requested)} start(s)'
refill_blocked_reason = ''
else:
refill_decision = 'Cooldown refill ran but rTorrent did not confirm new starts yet'
refill_blocked_reason = 'start_not_confirmed'
details = {
'decision': refill_decision,
'blocked_reason': refill_blocked_reason,
'enabled': bool(settings.get('enabled')),
'cooldown_refill': True,
'cooldown_respected': True,
'refill_mode': _refill_mode(settings),
'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0),
'active_before': len(downloading),
'active_after_expected': active_after_expected,
'active_transferring_count': active_transferring,
'active_rtorrent_count': active_rtorrent,
'active_state_count': active_state,
'available_slots': available_slots,
'candidates': len(candidates),
'start_source_skipped': len(source_skipped),
@@ -1014,6 +1038,10 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'max_active_downloads': max_active,
'available_slots': available_slots,
'candidates': len(candidates),
'active_transferring': active_transferring,
'active_rtorrent': active_rtorrent,
'active_state': active_state,
'blocked_reason': refill_blocked_reason,
'start_source_skipped': len(source_skipped),
'requested': len(start_requested),
'verified': len(active_verified),
@@ -1069,7 +1097,11 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'start_pending_confirmation': start_pending_confirmation,
'active_verified': active_verified,
'active_before': len(downloading),
'active_after_expected': len(downloading) + len(started_by_queue),
'active_after_expected': active_after_expected,
'active_transferring_count': active_transferring,
'active_rtorrent_count': active_rtorrent,
'active_state_count': active_state,
'blocked_reason': refill_blocked_reason,
'available_slots': available_slots,
'start_source_skipped': len(source_skipped),
'checked': len(torrents),

View File

@@ -4,6 +4,8 @@ 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"}
@@ -33,6 +35,42 @@ class TorrentCache:
self._updated_at.pop(profile_id, None)
return removed
def refresh_live(self, profile: dict) -> dict:
"""Refresh only volatile live fields without replacing the full cached torrent rows."""
# Note: The fast poller uses this lightweight path so speeds/statuses can update often while the full list poller stays slower.
profile_id = int(profile["id"])
try:
rows = rtorrent.list_torrent_live_stats(profile)
live = {t["hash"]: t for t in rows if t.get("hash")}
with self._lock:
old = dict(self._data.get(profile_id, {}))
if not old:
self._errors[profile_id] = ""
return {"ok": True, "profile_id": profile_id, "updated": [], "missing": [], "unknown": list(live.keys()), "requires_full_refresh": bool(live)}
updated = []
for h, live_row in live.items():
current = old.get(h)
if not current:
continue
patch = {"hash": h}
for key in _LIVE_KEYS:
if key in live_row and current.get(key) != live_row.get(key):
patch[key] = live_row.get(key)
if len(patch) > 1:
current.update({k: v for k, v in patch.items() if k != "hash"})
updated.append(patch)
missing = [h for h in old.keys() if h not in live]
unknown = [h for h in live.keys() if h not in old]
self._data[profile_id] = old
self._errors[profile_id] = ""
self._updated_at[profile_id] = time()
return {"ok": True, "profile_id": profile_id, "updated": updated, "missing": missing, "unknown": unknown, "requires_full_refresh": bool(missing or unknown)}
except Exception as exc:
with self._lock:
self._errors[profile_id] = str(exc)
return {"ok": False, "profile_id": profile_id, "error": str(exc), "updated": [], "missing": [], "unknown": [], "requires_full_refresh": False}
def refresh(self, profile: dict) -> dict:
profile_id = int(profile["id"])
try:

View File

@@ -106,7 +106,7 @@ def register_socketio_handlers(socketio):
def poller():
while True:
loop_started = time.monotonic()
next_sleep = poller_control.MIN_POLL_INTERVAL_SECONDS
next_sleep = 10.0
for profile in _poller_profiles():
if not profile:
continue
@@ -114,47 +114,96 @@ def register_socketio_handlers(socketio):
settings = poller_control.get_settings(pid)
state = poller_control.state_for(pid)
now = time.monotonic()
next_sleep = min(next_sleep, poller_control.effective_fast_interval(settings, state))
if not poller_control.should_fast_poll(now, settings, state):
live_interval = poller_control.effective_live_interval(settings, state)
list_interval = poller_control.effective_list_interval(settings, state)
next_sleep = min(
next_sleep,
max(poller_control.MIN_POLL_INTERVAL_SECONDS, live_interval - (now - state.last_live_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, list_interval - (now - state.last_list_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["system_stats_interval_seconds"]) - (now - state.last_system_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["slow_stats_interval_seconds"]) - (now - state.last_slow_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["queue_stats_interval_seconds"]) - (now - state.last_queue_at)),
)
run_live = poller_control.should_live_poll(now, settings, state)
run_list = poller_control.should_list_poll(now, settings, state)
run_system = poller_control.should_system_poll(now, settings, state)
run_slow = poller_control.should_slow_poll(now, settings, state)
run_queue = poller_control.should_queue_poll(now, settings, state)
if not (run_live or run_list or run_system or run_slow or run_queue):
continue
tick_started = time.monotonic()
changed = False
ok = True
error = ""
active = False
active = state.last_active
emitted_payload_size = 0
rtorrent_call_count = 0
skipped_emissions = 0
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
try:
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_fast_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else None
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
changed = True
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_patch", payload, pid)
elif not diff.get("ok"):
_emit_profile(socketio, "rtorrent_error", diff, pid)
else:
# Note: Speeds and peak records may change even when no torrent rows need repainting.
if speed_status:
payload = {"ok": True, "profile_id": pid, "added": [], "updated": [], "removed": [], "speed_status": speed_status}
speed_status = _speed_status_from_rows(pid, rows)
if run_live:
live_started = time.monotonic()
live = torrent_cache.refresh_live(profile)
rtorrent_call_count += 1
state.last_live_at = now
state.last_fast_at = now
ok = bool(live.get("ok"))
error = str(live.get("error") or "")
poller_control.mark_live_poll(state, live_started, ok, error, len(live.get("updated") or []), bool(live.get("requires_full_refresh")))
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if live.get("ok") else speed_status
if live.get("ok"):
if live.get("updated") or speed_status:
changed = changed or bool(live.get("updated"))
payload = {
"ok": True,
"profile_id": pid,
"updated": live.get("updated") or [],
"speed_status": speed_status,
"requires_full_refresh": bool(live.get("requires_full_refresh")),
}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_live_patch", payload, pid)
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:
_emit_profile(socketio, "rtorrent_error", live, pid)
if run_list:
list_started = time.monotonic()
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_list_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
poller_control.mark_list_poll(state, list_started, ok, error, len(diff.get("added") or []), len(diff.get("updated") or []), len(diff.get("removed") or []))
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else speed_status
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
changed = True
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_patch", payload, pid)
elif not diff.get("ok"):
_emit_profile(socketio, "rtorrent_error", diff, pid)
else:
skipped_emissions += 1
if poller_control.should_system_poll(now, settings, state):
if run_system:
state.last_system_at = now
rows = torrent_cache.snapshot(pid)
status = rtorrent.system_status(profile, rows)
rtorrent_call_count += 1
if bool(profile.get("is_remote")):
@@ -185,9 +234,11 @@ def register_socketio_handlers(socketio):
if poller_control.should_tracker_poll(now, settings, state):
state.last_tracker_at = now
if poller_control.should_slow_poll(now, settings, state) or poller_control.should_queue_poll(now, settings, state):
state.last_slow_at = now
state.last_queue_at = now
if run_slow or run_queue:
if run_slow:
state.last_slow_at = now
if run_queue:
state.last_queue_at = now
if state.slow_task_running:
skipped_emissions += 1
else:

View File

@@ -287,6 +287,20 @@ def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None:
if action_name not in {"start", "stop", "pause", "resume", "unpause"} or not _socketio:
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)
_emit_torrent_refresh(profile, action_name)
_socketio.start_background_task(delayed_refresh)
def _run(job_id: str):
if not _claim_runner(job_id):
return
@@ -333,7 +347,9 @@ def _run(job_id: str):
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: Completed jobs must publish a fresh torrent snapshot/patch so removed or moved torrents disappear without a page reload.
_emit_torrent_refresh(profile, str(job["action"] or ""))
action_name = str(job["action"] or "")
_emit_torrent_refresh(profile, action_name)
_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:
fresh = _job_row(job_id) or {}

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

View File

@@ -551,7 +551,7 @@ body.resizing-details {
min-width: 680px;
}
.smart-history-table {
min-width: 760px;
min-width: 920px;
table-layout: fixed;
}
.smart-history-table th,
@@ -738,23 +738,6 @@ body.resizing-details {
.profile-actions {
justify-content: flex-end;
}
.profile-status-badge.badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
min-width: 0;
min-height: 1rem;
max-width: max-content;
padding: 0.1rem 0.32rem;
border-radius: 999px;
font-size: 0.58rem;
line-height: 1;
letter-spacing: 0.01em;
text-transform: uppercase;
white-space: nowrap;
vertical-align: middle;
}
.profile-form-grid {
display: grid;
grid-template-columns: minmax(150px, 1.1fr) minmax(260px, 2.1fr) minmax(
@@ -1950,45 +1933,123 @@ body.mobile-mode .mobile-filter-bar {
}
}
.rt-config-grid {
.rt-config-shell {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 0.85rem;
}
.rt-config-group {
grid-column: 1 / -1;
padding: 0.45rem 0.2rem 0.1rem;
border-bottom: 1px solid var(--bs-border-color);
.rt-config-section {
border: 1px solid var(--bs-border-color);
border-radius: 0.85rem;
background: rgba(var(--bs-secondary-bg-rgb), 0.22);
overflow: hidden;
}
.rt-config-section > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0.9rem;
cursor: pointer;
list-style: none;
}
.rt-config-section > summary::-webkit-details-marker {
display: none;
}
.rt-config-section > summary span {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--bs-primary-text-emphasis);
font-weight: 800;
}
.rt-config-section > summary small {
max-width: 58ch;
color: var(--bs-secondary-color);
font-size: 0.76rem;
text-align: right;
}
.rt-config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 0.75rem;
padding: 0 0.9rem 0.9rem;
}
.rt-config-note {
margin-bottom: 0.75rem;
}
.rt-config-toolbar {
.rt-config-toolbar,
.rt-config-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
gap: 0.55rem;
}
.rt-config-toolbar {
margin-bottom: 0.75rem;
}
.rt-config-row {
.rt-config-card {
display: grid;
grid-template-columns: 1fr minmax(120px, 190px);
align-items: center;
gap: 0.6rem;
padding: 0.6rem;
gap: 0.55rem;
min-width: 0;
padding: 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.7rem;
background: rgba(var(--bs-secondary-bg-rgb), 0.35);
border-radius: 0.75rem;
background: var(--bs-body-bg);
}
.rt-config-card-head,
.rt-config-control {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.6rem;
min-width: 0;
}
.rt-config-card-head b {
display: block;
font-size: 0.9rem;
line-height: 1.25;
}
.rt-config-card-head small,
.rt-config-help-text,
.rt-config-value-note {
display: block;
overflow-wrap: anywhere;
color: var(--bs-secondary-color);
font-size: 0.73rem;
}
.rt-config-help {
flex: 0 0 auto;
padding: 0;
color: var(--bs-secondary-color);
line-height: 1;
text-decoration: none;
}
.rt-config-control {
align-items: center;
}
.rt-config-control .form-control,
.rt-config-switch {
max-width: 210px;
}
.rt-config-switch {
justify-self: end;
justify-content: flex-end;
margin: 0;
}
@@ -2003,29 +2064,41 @@ body.mobile-mode .mobile-filter-bar {
font-weight: 700;
}
.rt-config-row b {
font-size: 0.88rem;
}
.rt-config-row small {
display: block;
overflow-wrap: anywhere;
.rt-config-state {
flex: 0 0 auto;
padding: 0.16rem 0.42rem;
border: 1px solid var(--bs-border-color);
border-radius: 999px;
color: var(--bs-secondary-color);
font-size: 0.72rem;
font-size: 0.68rem;
font-weight: 700;
white-space: nowrap;
}
.rt-config-row.disabled {
opacity: 0.58;
.rt-config-state-live {
color: var(--bs-success-text-emphasis);
}
.rt-config-row.changed,
.rt-config-row.changed-live {
.rt-config-state-saved {
color: var(--bs-primary-text-emphasis);
}
.rt-config-state-unavailable {
color: var(--bs-danger-text-emphasis);
}
.rt-config-card.disabled {
opacity: 0.62;
}
.rt-config-card.changed,
.rt-config-card.changed-live {
border-color: var(--bs-danger);
box-shadow: 0 0 0 0.12rem rgba(220, 53, 69, 0.2);
box-shadow: 0 0 0 0.12rem rgba(var(--bs-danger-rgb), 0.18);
}
.rt-config-value-note {
margin-top: 0.15rem;
margin-top: -0.2rem;
}
.rt-config-output {
@@ -2033,6 +2106,26 @@ body.mobile-mode .mobile-filter-bar {
font-size: 0.82rem;
}
@media (max-width: 575.98px) {
.rt-config-section > summary,
.rt-config-card-head,
.rt-config-control {
align-items: stretch;
flex-direction: column;
}
.rt-config-section > summary small {
max-width: none;
text-align: left;
}
.rt-config-control .form-control,
.rt-config-switch {
max-width: none;
}
}
.tracker-toolbar,
.tracker-actions {
display: flex;
@@ -3246,10 +3339,6 @@ body.mobile-mode .mobile-filter-bar {
display: block;
}
#pollerRuntime {
line-height: 1.45;
}
.planner-history-item {
background: rgba(var(--bs-secondary-bg-rgb), 0.45);
border: 1px solid var(--bs-border-color);
@@ -3259,10 +3348,6 @@ body.mobile-mode .mobile-filter-bar {
padding: 0.15rem 0.4rem;
}
#pollerRuntime {
margin-top: 0.25rem;
}
.status-planner {
align-items: center;
background: transparent;
@@ -3477,9 +3562,21 @@ body.mobile-mode .mobile-filter-bar {
display: none;
}
.profile-diagnostics-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.5rem;}
.profile-diagnostics-card{border:1px solid var(--bs-border-color);border-radius:.5rem;padding:.5rem;background:var(--bs-body-bg);}
.profile-diagnostics-card small{display:block;color:var(--bs-secondary-color);}
.profile-diagnostics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.5rem;
}
.profile-diagnostics-card {
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 0.5rem;
background: var(--bs-body-bg);
}
.profile-diagnostics-card small {
display: block;
color: var(--bs-secondary-color);
}
.labels-manager {
display: grid;
@@ -4271,6 +4368,33 @@ body,
/* Smart Queue exception picker */
.smart-exclusion-toolbar {
align-items: center;
display: grid;
gap: 0.65rem;
grid-template-columns: minmax(16rem, 1fr) auto auto auto;
}
.smart-exclusion-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.smart-exclusion-counter {
color: var(--bs-secondary-color);
font-size: 0.875rem;
white-space: nowrap;
}
.smart-exclusion-toggle {
align-items: center;
display: flex;
gap: 0.45rem;
margin: 0;
white-space: nowrap;
}
.smart-exclusion-choice-list {
display: grid;
gap: 0.45rem;
@@ -4280,14 +4404,14 @@ body,
}
.smart-exclusion-choice-row {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0.75rem;
background: rgba(var(--bs-secondary-bg-rgb), 0.24);
border: 1px solid var(--bs-border-color);
border-radius: 0.65rem;
background: rgba(var(--bs-secondary-bg-rgb), 0.24);
cursor: pointer;
display: flex;
gap: 0.65rem;
padding: 0.65rem 0.75rem;
}
.smart-exclusion-choice-row:hover {
@@ -4311,6 +4435,16 @@ body,
overflow-wrap: anywhere;
}
@media (max-width: 768px) {
.smart-exclusion-toolbar {
grid-template-columns: 1fr;
}
.smart-exclusion-toolbar-actions {
justify-content: flex-start;
}
}
/* Backup preview data samples */
.backup-preview-empty {
color: var(--bs-secondary-color);
@@ -4629,14 +4763,6 @@ body,
}
}
/* Current rTorrent settings uses the same card rhythm as Diagnostics for faster scanning. */
.rt-config-current-summary {
margin-bottom: 1rem;
}
.rt-config-value-note {
word-break: break-word;
}
.file-row-actions {
align-items: center;
@@ -5231,11 +5357,12 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
width: 1rem;
}
/* Note: Planner Current Settings inherits Poller Diagnostics card chrome from .smart-setting-row. */
/* Note: Planner Current Settings inherits the original compact card chrome from .smart-setting-row. */
.planner-current-summary {
align-items: flex-start;
}
/* Note: Keep Planner Current Settings entries on one visual line, with the same separator spacing as before. */
.planner-diagnostic-line {
align-items: center;
color: var(--bs-secondary-color);
@@ -5246,7 +5373,6 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
margin-top: 0.2rem;
}
/* Note: Keep each Current Settings entry on a single visual line so labels do not break above values. */
.planner-diagnostic-item {
align-items: baseline;
display: inline-flex;
@@ -5260,8 +5386,6 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
font-weight: 700;
}
/* Note: Add breathing room around Current Settings separators to match Poller Diagnostics readability. */
.planner-diagnostic-line .diagnostic-separator {
margin: 0 0.18rem;
}
@@ -5274,3 +5398,69 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
vertical-align: middle;
}
.poller-diagnostics-row {
align-items: flex-start;
}
.poller-diagnostic-item {
align-items: baseline;
display: inline-flex;
gap: 0.25rem;
min-width: 0;
}
.poller-diagnostic-item b {
color: var(--bs-body-color);
display: inline;
font-weight: 700;
}
.poller-layout {
gap: 0.85rem;
}
.poller-card .smart-input-field {
margin-bottom: 0;
}
.poller-safe-preview {
background: rgba(var(--bs-info-rgb), 0.08);
border: 1px solid rgba(var(--bs-info-rgb), 0.22);
border-radius: 0.7rem;
color: var(--bs-secondary-color);
font-size: 0.82rem;
line-height: 1.45;
padding: 0.55rem 0.7rem;
}
.poller-diagnostic-line {
color: var(--bs-secondary-color);
display: grid;
gap: 0.65rem;
line-height: 1.45;
margin-top: 0.35rem;
}
.poller-diagnostic-group {
border-top: 1px solid var(--bs-border-color);
display: grid;
gap: 0.35rem;
padding-top: 0.5rem;
}
.poller-diagnostic-group:first-child {
border-top: 0;
padding-top: 0;
}
.poller-diagnostic-group-title {
color: var(--bs-body-color);
display: block;
font-weight: 700;
}
.poller-diagnostic-values {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.75rem;
}

File diff suppressed because one or more lines are too long