columns #15

Merged
gru merged 8 commits from columns into master 2026-05-31 13:22:05 +02:00
31 changed files with 146 additions and 251 deletions
Showing only changes of commit b6a5003f2c - Show all commits
-1
View File
@@ -104,7 +104,6 @@ JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)
SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1)
LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 1, 1)
LOG_RETENTION_HOURS = _env_int("PYTORRENT_LOG_RETENTION_HOURS", 24, 1)
LOG_ENABLE = _env_bool("PYTORRENT_LOG_ENABLE", True)
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
if not LOG_DIR.is_absolute():
LOG_DIR = BASE_DIR / LOG_DIR
+2 -4
View File
@@ -292,7 +292,6 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
stop_batch_size INTEGER DEFAULT 50,
start_grace_seconds INTEGER DEFAULT 900,
protect_active_below_cap INTEGER DEFAULT 1,
prefer_partial_progress INTEGER DEFAULT 1,
auto_stop_idle INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY(profile_id)
@@ -576,7 +575,6 @@ MIGRATIONS = [
"ALTER TABLE smart_queue_settings ADD COLUMN stop_batch_size INTEGER DEFAULT 50",
"ALTER TABLE smart_queue_settings ADD COLUMN start_grace_seconds INTEGER DEFAULT 900",
"ALTER TABLE smart_queue_settings ADD COLUMN protect_active_below_cap INTEGER DEFAULT 1",
"ALTER TABLE smart_queue_settings ADD COLUMN prefer_partial_progress INTEGER DEFAULT 1",
"ALTER TABLE smart_queue_settings ADD COLUMN auto_stop_idle INTEGER DEFAULT 0",
"CREATE TABLE IF NOT EXISTS smart_queue_start_grace (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
"ALTER TABLE rss_feeds ADD COLUMN interval_minutes INTEGER DEFAULT 30",
@@ -665,8 +663,8 @@ PROFILE_ONLY_TABLES = {
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')"],
},
"smart_queue_settings": {
"columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, prefer_partial_progress INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)",
"copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "prefer_partial_progress", "auto_stop_idle", "updated_at"],
"columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)",
"copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "auto_stop_idle", "updated_at"],
"indexes": [],
},
"smart_queue_exclusions": {
+1 -4
View File
@@ -8,7 +8,7 @@ from typing import Any
from flask import Flask, g, request
from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS
from .config import LOG_DIR, LOG_RETENTION_HOURS
_CONFIGURED = False
@@ -33,9 +33,6 @@ def _make_handler(path: Path, level: int) -> TimedRotatingFileHandler:
def configure_logging(app: Flask | None = None) -> None:
"""Route pyTorrent app, error and access logs to the configured data log directory."""
global _CONFIGURED
if not LOG_ENABLE:
# Note: Installation can disable file logging while keeping normal service stdout/stderr available.
return
LOG_DIR.mkdir(parents=True, exist_ok=True)
if not _CONFIGURED:
+3 -3
View File
@@ -36,7 +36,7 @@ RECOMMENDED_TABLE_COLUMNS = {
"status": True, "size": True, "progress": True, "down_rate": True, "up_rate": True,
"eta": True, "seeds": True, "peers": True, "ratio": True, "path": True, "label": True,
"ratio_group": False, "down_total": True, "to_download": True, "up_total": True,
"created": False, "last_activity": False, "priority": False, "state": False, "active": False, "complete": False,
"created": False, "priority": False, "state": False, "active": False, "complete": False,
"hashing": False, "message": False, "hash": False,
},
"mobileSortFilters": {
@@ -48,7 +48,7 @@ RECOMMENDED_TABLE_COLUMNS = {
"down_rate": 60, "up_rate": 55, "eta": 53, "seeds": 44, "peers": 49,
"ratio": 47, "path": 135, "label": 67, "ratio_group": 87,
"down_total": 82, "to_download": 89, "up_total": 44, "created": 150,
"last_activity": 150, "priority": 80, "state": 70, "active": 70, "complete": 82, "hashing": 82,
"priority": 80, "state": 70, "active": 70, "complete": 82, "hashing": 82,
"message": 220, "hash": 280,
},
}
@@ -403,7 +403,7 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
direction = int(parsed.get("dir") or 1)
except (TypeError, ValueError):
direction = 1
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "last_activity", "priority", "state", "active", "complete", "hashing", "message", "hash"}
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
sort_key = str(parsed.get("key") or "name")
if sort_key not in allowed_sort_keys:
sort_key = "name"
+13 -67
View File
@@ -86,15 +86,6 @@ RTORRENT_CONFIG_FIELDS = [
"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.dns_cache_timeout",
"label": "HTTP DNS cache timeout",
"type": "number",
"description": "Seconds rTorrent keeps DNS results for tracker and HTTP requests.",
"recommendation": "Use a small positive value, for example 25, when many tracker hostnames are queried repeatedly.",
"runtime_note": "Applied through SCGI immediately; new HTTP lookups use the updated timeout.",
},
{
"group": "Network",
"key": "network.http.ssl_verify_peer",
@@ -171,65 +162,34 @@ RTORRENT_CONFIG_FIELDS = [
{
"group": "Throttle",
"key": "throttle.max_downloads.global",
"label": "Global download slots",
"label": "Max active downloads",
"type": "number",
"description": "Global number of peer download slots across all torrents; this is not the active torrent count.",
"recommendation": "Raise this on large instances so a few busy torrents do not starve the rest.",
"runtime_note": "Applied through SCGI immediately; existing peer scheduling catches up gradually.",
"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": "Global upload slots",
"label": "Max active uploads",
"type": "number",
"description": "Global number of peer upload slots across all torrents; this is not the active torrent count.",
"recommendation": "Keep enough slots for many seeds, but stay below socket and file descriptor limits.",
"runtime_note": "Applied through SCGI immediately; current peer connections may rebalance over time.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads",
"label": "Per-torrent download slots",
"type": "number",
"description": "Maximum peer download slots allowed for a single torrent in the default throttle group.",
"recommendation": "Use values like 5-20 to prevent one torrent from consuming all global download slots.",
"runtime_note": "Applied through SCGI immediately; it affects new and rebalanced peer slot allocation.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads",
"label": "Per-torrent upload slots",
"type": "number",
"description": "Maximum peer upload slots allowed for a single torrent in the default throttle group.",
"recommendation": "Use conservative values on very large seedboxes so many seeds can stay reachable.",
"runtime_note": "Applied through SCGI immediately; it affects new and rebalanced peer slot allocation.",
"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": "Download slot divisor",
"label": "Max downloads per throttle",
"type": "number",
"description": "Per-throttle download slot divisor used by rTorrent throttling logic.",
"recommendation": "Keep at 1 unless you intentionally use advanced throttle groups.",
"runtime_note": "Applied through SCGI immediately for the default throttle scheduler.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.div",
"label": "Upload slot divisor",
"label": "Max uploads per throttle",
"type": "number",
"description": "Per-throttle upload slot divisor used by rTorrent throttling logic.",
"recommendation": "Keep at 1 unless you intentionally use advanced throttle groups.",
"runtime_note": "Applied through SCGI immediately for the default throttle scheduler.",
},
{
"group": "Ratio",
"key": "ratio.max",
"label": "Global ratio max",
"type": "number",
"description": "Global maximum ratio value used by rTorrent ratio logic where enabled.",
"recommendation": "Use -1 for no global cap, or manage per-profile ratio policies from pyTorrent when possible.",
"runtime_note": "Applied through SCGI immediately when the rTorrent ratio method is available.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "DHT / PEX",
@@ -450,19 +410,6 @@ def default_download_path(profile: dict) -> str:
errors.append(f"{method}: {exc}")
raise RuntimeError("Cannot read rTorrent default download directory: " + "; ".join(errors))
def _rtorrent_set_method(key: str, meta: dict) -> str:
# Note: Most runtime values use the conventional <method>.set setter.
# Some rTorrent commands, such as protocol.encryption.set, are already
# setter commands and must not receive another .set suffix.
return str(meta.get("set_method") or (key if key.endswith(".set") else f"{key}.set"))
def _rtorrent_config_line_key(key: str, meta: dict) -> str:
# Note: Generated snippets must match rTorrent config syntax and avoid
# producing invalid protocol.encryption.set.set lines.
return str(meta.get("config_key") or _rtorrent_set_method(key, meta))
def generate_config_text(values: dict) -> str:
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
lines = []
@@ -473,7 +420,7 @@ def generate_config_text(values: dict) -> str:
normalized = _normalize_config_value(meta, value)
if meta.get("type") == "text" and any(ch.isspace() for ch in normalized):
normalized = '"' + normalized.replace('\\', '\\\\').replace('"', '\\"') + '"'
lines.append(f"{_rtorrent_config_line_key(key, meta)} = {normalized}")
lines.append(f"{key}.set = {normalized}")
return "\n".join(lines) + ("\n" if lines else "")
@@ -559,11 +506,10 @@ def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_sta
value = _normalize_config_value(meta, raw_value)
rpc_value = int(value) if meta.get("type") in {"bool", "number"} else value
try:
method = _rtorrent_set_method(key, meta)
try:
c.call(method, "", rpc_value)
c.call(key + ".set", "", rpc_value)
except Exception:
c.call(method, rpc_value)
c.call(key + ".set", rpc_value)
updated.append(key)
except Exception as exc:
errors.append({"key": key, "error": str(exc)})
+3 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from .client import *
from .. import poller_control
import shlex
def scgi_diagnostics(profile: dict) -> dict:
c = client_for(profile)
@@ -89,12 +90,12 @@ def profile_diagnostics(profile: dict) -> dict:
base = paths.get("default_directory") if isinstance(paths.get("default_directory"), str) else ""
if base:
try:
out = _rt_execute(c, "execute.capture", "sh", "-c", 'if test -w "$1"; then printf writable; else printf readonly; fi', "pytorrent-diagnostics-write", base)
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"test -w {shlex.quote(base)} && printf writable || printf readonly")
write_permissions[base] = str(out or "").strip() or "unknown"
except Exception as exc:
write_permissions[base] = f"error: {exc}"
try:
out = _rt_execute(c, "execute.capture", "sh", "-c", "df -Pk \"$1\" 2>/dev/null | awk 'END {print $4}'", "pytorrent-diagnostics-df", base)
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"df -Pk {shlex.quote(base)} | tail -1 | awk '{{print $4}}'")
kb = int(str(out or "0").strip() or 0)
free_disk[base] = {"free_bytes": kb * 1024, "free_h": human_size(kb * 1024)}
except Exception as exc:
+1 -10
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
import time
from .client import *
from .files import set_file_priorities
from .system import disk_usage_for_default_path
@@ -216,7 +214,6 @@ TORRENT_FIELDS = [
]
TORRENT_OPTIONAL_FIELDS = [
"d.timestamp.last_active=",
"d.timestamp.finished=",
]
@@ -255,12 +252,7 @@ def normalize_row(row: list) -> dict:
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
completed_at = int(row[23] or 0) if len(row) > 23 else 0
# Show the selected download location only. Hide the torrent root
# directory for multi-file torrents and the filename for single-file
@@ -318,7 +310,6 @@ def normalize_row(row: list) -> dict:
"priority": int(row[13] or 0),
"path": display_path,
"created": int(row[16] or 0),
"last_activity": last_activity,
"completed_at": completed_at,
"label": str(row[17] or ""),
"ratio_group": str(row[18] or ""),
+14 -63
View File
@@ -66,8 +66,6 @@ def _diagnostics_torrent(t: dict[str, Any] | None) -> dict[str, Any]:
'hashing': int(t.get('hashing') or 0),
'priority': int(t.get('priority') or 0),
'down_rate': int(t.get('down_rate') or 0),
'up_rate': int(t.get('up_rate') or 0),
'last_activity': int(t.get('last_activity') or 0),
'peers': int(t.get('peers') or 0),
'seeds': int(t.get('seeds') or 0),
'label': str(t.get('label') or ''),
@@ -157,7 +155,6 @@ def _default_settings(profile_id: int) -> dict[str, Any]:
'stop_batch_size': 50,
'start_grace_seconds': 900,
'protect_active_below_cap': 1,
'prefer_partial_progress': 1,
'auto_stop_idle': 0,
'updated_at': utcnow(),
}
@@ -197,8 +194,6 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
'start_grace_seconds': _int_setting(data, current, 'start_grace_seconds', 900, 0),
# Note: When below the target cap, prefer refilling first instead of reducing active slots by stopping stalled downloads.
'protect_active_below_cap': 1 if data.get('protect_active_below_cap', current.get('protect_active_below_cap', 1)) else 0,
# Note: Prefer partially downloaded stopped torrents so Smart Queue finishes existing work before opening fresh downloads.
'prefer_partial_progress': 1 if data.get('prefer_partial_progress', current.get('prefer_partial_progress', 1)) else 0,
# Note: Optional safety valve that disables Smart Queue when there are no active or waiting downloads to manage.
'auto_stop_idle': 1 if data.get('auto_stop_idle', current.get('auto_stop_idle', 0)) else 0,
}
@@ -216,8 +211,8 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
now = utcnow()
with connect() as conn:
conn.execute(
'''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,prefer_partial_progress,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
'''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET
enabled=excluded.enabled,
max_active_downloads=excluded.max_active_downloads,
@@ -232,12 +227,11 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
stop_batch_size=excluded.stop_batch_size,
start_grace_seconds=excluded.start_grace_seconds,
protect_active_below_cap=excluded.protect_active_below_cap,
prefer_partial_progress=excluded.prefer_partial_progress,
auto_stop_idle=excluded.auto_stop_idle,
refill_enabled=excluded.refill_enabled,
refill_interval_minutes=excluded.refill_interval_minutes,
updated_at=excluded.updated_at''',
(profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['prefer_partial_progress'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now),
(profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now),
)
return get_settings(profile_id, user_id)
@@ -806,22 +800,9 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
return _is_started_download_slot(t)
def _has_recent_transfer_activity(t: dict[str, Any], stalled_seconds: int) -> bool:
"""Return True when a torrent is currently transferring or was active within the stalled window."""
# Note: Live transfer rates always protect a torrent from being marked as stalled.
if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0:
return True
last_activity = int(t.get('last_activity') or 0)
if last_activity <= 0:
return False
return time.time() - last_activity < max(1, int(stalled_seconds or 0))
def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, stalled_seconds: int, ignore_seed_peer: bool, ignore_speed: bool) -> bool:
def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool, ignore_speed: bool) -> bool:
"""Return True when a started torrent should begin or continue the stalled timer."""
# Note: Recent transfer activity wins over ignored source/speed criteria, preventing active torrents from being stopped as stalled.
if _has_recent_transfer_activity(t, stalled_seconds):
return False
# Note: Each ignore switch removes only its own criterion; the stalled timer still applies after criteria match.
speed_ok = True if ignore_speed else int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
source_ok = True if ignore_seed_peer else int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) and (min_peers <= 0 or int(t.get('peers') or 0) <= min_peers)
return speed_ok and source_ok
@@ -829,15 +810,13 @@ def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_
def _stalled_timer_key(min_speed: int, min_seeds: int, min_peers: int, stalled_seconds: int, ignore_seed_peer: bool, ignore_speed: bool) -> str:
"""Return a stable key for the stalled rules that started the current timer."""
# Note: Version bump clears old timers created by the previous ignore-speed/source behavior.
return f"v5|speed={int(min_speed or 0)}|seeds={int(min_seeds or 0)}|peers={int(min_peers or 0)}|seconds={int(stalled_seconds or 0)}|ignore_sources={int(bool(ignore_seed_peer))}|ignore_speed={int(bool(ignore_speed))}"
# Note: Changing ignore switches or thresholds restarts existing stalled timers instead of reusing old rows.
return f"v4|speed={int(min_speed or 0)}|seeds={int(min_seeds or 0)}|peers={int(min_peers or 0)}|seconds={int(stalled_seconds or 0)}|ignore_sources={int(bool(ignore_seed_peer))}|ignore_speed={int(bool(ignore_speed))}"
def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, stalled_seconds: int, ignore_seed_peer: bool = False, ignore_speed: bool = False) -> bool:
def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool = False, ignore_speed: bool = False) -> bool:
"""Return True when a started torrent is weak and should be stopped first."""
# Note: Active transfers are never preferred for cleanup while non-transferring rows are available.
if _has_recent_transfer_activity(t, stalled_seconds):
return False
# Note: Stop priority uses only criteria that are not ignored, so disabled criteria cannot stop torrents earlier.
low_speed = False if ignore_speed else int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
low_seeds = False if ignore_seed_peer else int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0))
low_peers = False if ignore_seed_peer or min_peers <= 0 else int(t.get('peers') or 0) <= max(0, int(min_peers or 0))
@@ -863,28 +842,6 @@ def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> b
def _progress_value(torrent: dict[str, Any]) -> float:
"""Return a safe 0-100 progress value for queue ranking."""
try:
value = float(torrent.get('progress') or 0)
except (TypeError, ValueError):
return 0.0
return max(0.0, min(100.0, value))
def _start_candidate_sort_key(torrent: dict[str, Any], prefer_partial_progress: bool) -> tuple[float, float, int, int, int]:
"""Rank stopped downloads for starting; partial progress can win so work is finished first."""
progress = _progress_value(torrent)
# Note: Existing partial downloads are preferred by default, then higher progress, then better source counts.
partial_rank = 1.0 if prefer_partial_progress and 0.0 < progress < 100.0 else 0.0
return (
partial_rank,
progress if prefer_partial_progress else 0.0,
int(torrent.get('seeds') or 0),
int(torrent.get('peers') or 0),
int(torrent.get('down_rate') or 0),
)
def _split_start_candidates(torrents: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Return all stopped torrents as start candidates without relying on stale source counts."""
# Note: rTorrent/tracker source counts can be missing before announce, so start decisions are not filtered by seeds or peers.
@@ -975,10 +932,9 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
return _disable_when_idle(profile_id, user_id, torrents, idle_details)
available_slots = max(0, max_active - len(downloading))
startable_stopped, source_skipped = _split_start_candidates(stopped)
prefer_partial_progress = bool(int(settings.get('prefer_partial_progress', 1) or 0))
candidates = sorted(
startable_stopped,
key=lambda t: _start_candidate_sort_key(t, prefer_partial_progress),
key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)),
reverse=True,
)
c = rtorrent.client_for(profile)
@@ -1070,7 +1026,6 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'labels_failed': label_failed,
'labels_restored': restored,
'max_active_downloads': max_active,
'prefer_partial_progress': prefer_partial_progress,
'excluded': len(user_excluded),
'excluded_stalled': len(stalled_label_hashes),
}
@@ -1103,7 +1058,6 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0),
'min_seeds': min_seeds,
'min_peers': min_peers,
'prefer_partial_progress': prefer_partial_progress,
},
'to_start': _diagnostics_torrents(to_start),
'to_label_waiting': _diagnostics_torrents(to_label_waiting),
@@ -1278,9 +1232,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
ignored_seed_peer_count += 1
if ignore_speed and int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0)):
ignored_speed_count += 1
is_stalled = _is_stalled_download(t, min_speed, min_seeds, min_peers, stalled_seconds, ignore_seed_peer, ignore_speed)
is_stalled = _is_stalled_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed)
# Note: Hard-limit enforcement uses only non-ignored weak criteria before choosing weak items.
if _is_low_activity_download(t, min_speed, min_seeds, min_peers, stalled_seconds, ignore_seed_peer, ignore_speed):
if _is_low_activity_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed):
stop_eligible.append(t)
h = str(t.get('hash') or '')
if not h:
@@ -1305,10 +1259,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
# Note: Start candidates are not filtered by seeds/peers because those counts may be stale before announce.
startable_stopped, source_skipped = _split_start_candidates(stopped)
prefer_partial_progress = bool(int(settings.get('prefer_partial_progress', 1) or 0))
candidates = sorted(
startable_stopped,
key=lambda t: _start_candidate_sort_key(t, prefer_partial_progress),
key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)),
reverse=True,
)
max_active = max(1, int(settings.get('max_active_downloads') or 5))
@@ -1430,7 +1383,6 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
'enabled': bool(settings.get('enabled')),
'checked': len(torrents),
'max_active_downloads': max_active,
'prefer_partial_progress': prefer_partial_progress,
'active_before': len(downloading),
'active_after_stop': active_after_stop,
'active_after_expected': active_after_stop + len(started_by_queue),
@@ -1513,7 +1465,6 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
'start_grace_seconds': start_grace_seconds,
'protect_active_below_cap': protect_active_below_cap,
'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)),
'prefer_partial_progress': prefer_partial_progress,
},
'rtorrent_cap': rtorrent_cap,
'to_stop': _diagnostics_torrents(to_stop),
@@ -1534,4 +1485,4 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
mark_run(profile_id, user_id)
settings = get_settings(profile_id, user_id)
remaining = cooldown_remaining(settings)
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'prefer_partial_progress': prefer_partial_progress, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining}
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining}
+30
View File
@@ -9,6 +9,21 @@ import { torrentDetailsSource } from './torrentDetails.js';
import { modalsSource } from './modals.js';
import { rssSource } from './rss.js';
import { smartQueueSource } from './smartQueue.js';
import { rtorrentConfigSource } from './rtorrentConfig.js';
import { appearancePreferencesSource } from './appearancePreferences.js';
import { automationRulesSource } from './automationRules.js';
import { cleanupToolsSource } from './cleanupTools.js';
import { diagnosticCardsSource } from './diagnosticCards.js';
import { footerPreferencesSource } from './footerPreferences.js';
import { liveSpeedStatsSource } from './liveSpeedStats.js';
import { statusBarSource } from './statusBar.js';
import { portCheckUiSource } from './portCheckUi.js';
import { preferencesLoaderSource } from './preferencesLoader.js';
import { diskMonitorSource } from './diskMonitor.js';
import { portCheckActionsSource } from './portCheckActions.js';
import { appStatusSource } from './appStatus.js';
import { torrentStatsSource } from './torrentStats.js';
import { toolHelpersSource } from './toolHelpers.js';
import { authUsersSource } from './authUsers.js';
import { plannerSource } from './planner.js';
import { pollerSource } from './poller.js';
@@ -30,6 +45,21 @@ export const moduleSources = [
modalsSource,
rssSource,
smartQueueSource,
rtorrentConfigSource,
appearancePreferencesSource,
automationRulesSource,
cleanupToolsSource,
diagnosticCardsSource,
footerPreferencesSource,
liveSpeedStatsSource,
statusBarSource,
portCheckUiSource,
preferencesLoaderSource,
diskMonitorSource,
portCheckActionsSource,
appStatusSource,
torrentStatsSource,
toolHelpersSource,
authUsersSource,
plannerSource,
dashboardSource,
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk keeps one Tools/status feature separate from the former smartQueue.js bundle.
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";
@@ -0,0 +1,2 @@
// Note: This chunk was split from smartQueue.js so each Tools feature can evolve independently.
export const appearancePreferencesSource = "function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){\n // Note: Custom Bootstrap 2-inspired themes are normal selectable themes and keep light/dark compatibility through data-bs-theme.\n bootstrapTheme = theme || \"default\";\n document.documentElement.dataset.bootstrapSkin = bootstrapTheme;\n const link=$(\"bootstrapThemeStylesheet\");\n if(link) link.href = bootstrapThemeUrl(bootstrapTheme);\n if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme;\n }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n function torrentRowHeight(){ return compactTorrentListEnabled ? COMPACT_ROW_HEIGHT : ROW_HEIGHT; }\n function applyCompactTorrentList(value){\n // Note: The compact switch changes density only; filtering, sorting and existing row actions stay unchanged.\n compactTorrentListEnabled = !!value;\n document.body.classList.toggle(\"compact-torrent-list\", compactTorrentListEnabled);\n if($(\"compactTorrentListEnabled\")) $(\"compactTorrentListEnabled\").checked = compactTorrentListEnabled;\n scheduleRender(true);\n }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); applyCompactTorrentList($(\"compactTorrentListEnabled\")?.checked); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale,compact_torrent_list_enabled:compactTorrentListEnabled}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n applyBootstrapTheme(bootstrapTheme);\n applyCompactTorrentList(compactTorrentListEnabled);\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function refreshPeersOnceForReverseDns(){\n // Note: Reverse DNS can resolve after the first peers fetch, so trigger one silent follow-up even when auto-refresh is disabled.\n if(activeTab()==='peers' && selectedHash){\n loadDetails('peers');\n setTimeout(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers',{silent:true}); }, 1200);\n }\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk keeps one Tools/status feature separate from the former smartQueue.js bundle.
export const diagnosticCardsSource = "function diagCard(label,value,extra=''){ return `<div class=\"diag-card ${extra}\"><b>${esc(label)}</b><span>${esc(value ?? '-')}</span></div>`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n";
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk keeps one Tools/status feature separate from the former smartQueue.js bundle.
export const footerPreferencesSource = "function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>`<label class=\"footer-pref-card form-check form-switch ${footerItems[key]===false?'':'active'}\"><input class=\"form-check-input footer-pref-toggle\" type=\"checkbox\" data-footer-key=\"${esc(key)}\" ${footerItems[key]===false?'':'checked'}><span class=\"form-check-label\">${esc(label)}</span></label>`).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk keeps one Tools/status feature separate from the former smartQueue.js bundle.
export const portCheckActionsSource = "async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n";
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk keeps one Tools/status feature separate from the former smartQueue.js bundle.
export const portCheckUiSource = "function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }\n function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }\n function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }\n function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return `<span ${attrs}class=\"port-status ${portStatusClass(st)}\"><i class=\"fa-solid ${portStatusIcon(st)}\"></i> ${esc(label)}</span>`; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(' · ') || 'Port check disabled';\n if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id=\"portCheckBadge\" ');\n if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' · ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';\n if($('statusPortCheck')){\n $('statusPortCheck').classList.toggle('d-none', !data.enabled);\n $('statusPortCheck').title=title;\n }\n if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id=\"statusPortCheckBadge\" ',true);\n }\n";
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk keeps one Tools/status feature separate from the former smartQueue.js bundle.
export const preferencesLoaderSource = "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 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 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); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\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
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk keeps one Tools/status feature separate from the former smartQueue.js bundle.
export const statusBarSource = "function updateFooterClock(){\n const el=$('statClock');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});\n }\n function updateSocketStatus(s={}){\n const el=$('statSockets');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);\n const box=$('statusSockets');\n if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;\n }\n";
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk was split from smartQueue.js so each Tools feature can evolve independently.
export const toolHelpersSource = "function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=`<button class=\"nav-link tool-tab\" data-tool=\"${tool}\" type=\"button\"><i class=\"fa-solid ${icon}\"></i> ${label}</button>`;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return `<label class=\"form-check form-switch inline-switch ${extraClass}\"><input id=\"${id}\" class=\"form-check-input\" type=\"checkbox\"><span class=\"form-check-label\">${label}</span></label>`;\n }\n function plannerToggleRow(id,title,description){\n return `<div class=\"smart-setting-row smart-toggle-row\"><div><b>${title}</b><small>${description}</small></div>${inlineSwitch(id)}</div>`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `<div class=\"smart-input-field planner-speed-card\" data-planner-speed=\"${prefix}\">\n <span>${title}</span>\n <small>${sub}</small>\n <div class=\"planner-limit-summary\" id=\"${prefix}Summary\">Unlimited</div>\n <div class=\"planner-presets\" aria-label=\"${title} presets\">\n <button class=\"btn btn-sm btn-outline-secondary planner-speed-preset\" data-prefix=\"${prefix}\" data-mbps=\"0\" type=\"button\">Unlimited</button>\n <button class=\"btn btn-sm btn-outline-secondary planner-speed-preset\" data-prefix=\"${prefix}\" data-mbps=\"50\" type=\"button\">50</button>\n <button class=\"btn btn-sm btn-outline-secondary planner-speed-preset\" data-prefix=\"${prefix}\" data-mbps=\"100\" type=\"button\">100</button>\n <button class=\"btn btn-sm btn-outline-secondary planner-speed-preset\" data-prefix=\"${prefix}\" data-mbps=\"250\" type=\"button\">250</button>\n <button class=\"btn btn-sm btn-outline-secondary planner-speed-preset\" data-prefix=\"${prefix}\" data-mbps=\"500\" type=\"button\">500</button>\n <button class=\"btn btn-sm btn-outline-secondary planner-speed-preset\" data-prefix=\"${prefix}\" data-mbps=\"1000\" type=\"button\">1G</button>\n </div>\n <div class=\"planner-speed-sliders\">\n <label>Download <b id=\"${prefix}DownMbps\">Unlimited</b><input id=\"${prefix}DownSlider\" class=\"form-range planner-mbps-slider\" type=\"range\" min=\"0\" max=\"1000\" step=\"5\" value=\"0\" data-target=\"${prefix}Down\"></label>\n <input id=\"${prefix}Down\" class=\"form-control form-control-sm planner-byte-input\" type=\"number\" min=\"0\" placeholder=\"B/s\" aria-label=\"${title} download bytes per second\">\n <label>Upload <b id=\"${prefix}UpMbps\">Unlimited</b><input id=\"${prefix}UpSlider\" class=\"form-range planner-mbps-slider\" type=\"range\" min=\"0\" max=\"1000\" step=\"5\" value=\"0\" data-target=\"${prefix}Up\"></label>\n <input id=\"${prefix}Up\" class=\"form-control form-control-sm planner-byte-input\" type=\"number\" min=\"0\" placeholder=\"B/s\" aria-label=\"${title} upload bytes per second\">\n </div>\n <small>Slider uses Mbit/s. Numeric fields store B/s for rTorrent.</small>\n </div>`;\n }\n";
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
// Note: This chunk was split from smartQueue.js so each Tools feature can evolve independently.
export const torrentStatsSource = "const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `<div class=\"torrent-stats-card\"><b>${esc(label)}</b><span>${esc(value ?? '-')}</span>${note?`<small>${esc(note)}</small>`:''}</div>`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `<div class=\"alert alert-warning py-2 mt-3 mb-0\">File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}</div>` : '';\n box.innerHTML=`<div class=\"column-manager-tabs\"><ul class=\"nav nav-pills\">${panes.map(p=>`<li class=\"nav-item\"><button class=\"nav-link ${p[0]===active?'active':''}\" type=\"button\" data-torrentstats-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>${panes.map(p=>`<div class=\"torrentstats-pane ${p[0]===active?'':'d-none'}\" data-torrentstats-panel=\"${p[0]}\"><div class=\"torrent-stats-grid\">${p[2].join('')}</div></div>`).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`<div class=\"text-danger\">${esc(e.message)}</div>`; }\n }\n";
File diff suppressed because one or more lines are too long
+40 -89
View File
@@ -2046,7 +2046,6 @@ body.mobile-mode .mobile-filter-bar {
}
.rt-config-card-head small,
.rt-config-runtime-note,
.rt-config-value-note {
display: block;
overflow-wrap: anywhere;
@@ -2120,15 +2119,10 @@ body.mobile-mode .mobile-filter-bar {
box-shadow: 0 0 0 0.12rem rgba(var(--bs-danger-rgb), 0.18);
}
.rt-config-runtime-note,
.rt-config-value-note {
margin-top: -0.2rem;
}
.rt-config-runtime-note i {
color: var(--bs-warning-text-emphasis);
}
.rt-config-output {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
@@ -4647,7 +4641,7 @@ body,
}
}
/* Note: Peers tables keep hostnames readable and keep progress columns stable. */
/* Note: Peers tables keep hostnames readable without letting the Host column dominate the layout. */
.peers-table {
min-width: 960px;
table-layout: fixed;
@@ -4662,76 +4656,56 @@ body,
}
.peers-table .peer-progress-wide {
min-width: 0;
min-width: 108px;
width: 100%;
}
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(1),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(1),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(1),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(1) {
.peers-table-hosts th:nth-child(1),
.peers-table-hosts td:nth-child(1) {
width: 4%;
}
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(2),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(2),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(2),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(2) {
.peers-table-hosts th:nth-child(2),
.peers-table-hosts td:nth-child(2) {
width: 13%;
}
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(3),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(3) {
.peers-table-hosts th:nth-child(3),
.peers-table-hosts td:nth-child(3) {
width: 15%;
}
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(3),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(3),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(4),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(4),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(4),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(4),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(5),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(5) {
.peers-table-hosts th:nth-child(4),
.peers-table-hosts td:nth-child(4),
.peers-table-hosts th:nth-child(5),
.peers-table-hosts td:nth-child(5) {
width: 8%;
}
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(5),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(5),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(6),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(6) {
.peers-table-hosts th:nth-child(6),
.peers-table-hosts td:nth-child(6) {
width: 15%;
}
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(6),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(6),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(7),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(7) {
width: 8rem;
.peers-table-hosts th:nth-child(7),
.peers-table-hosts td:nth-child(7) {
width: 10%;
}
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(7),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(7),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(8),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(8),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(8),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(8),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(9),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(9) {
.peers-table-hosts th:nth-child(8),
.peers-table-hosts td:nth-child(8),
.peers-table-hosts th:nth-child(9),
.peers-table-hosts td:nth-child(9) {
width: 6%;
}
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(9),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(9),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(10),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(10) {
.peers-table-hosts th:nth-child(10),
.peers-table-hosts td:nth-child(10) {
width: 5%;
}
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(10),
.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(10),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(11),
.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(11) {
.peers-table-hosts th:nth-child(11),
.peers-table-hosts td:nth-child(11) {
width: 10%;
}
@@ -4744,7 +4718,7 @@ body,
white-space: nowrap;
}
/* Note: Mobile torrent details use a stable table so progress bars always render on a 0-100% track. */
/* Note: Mobile torrent details use a narrower fixed table so long reverse-DNS names cannot stretch the modal. */
.mobile-details-modal .modal-body {
overflow-x: hidden;
}
@@ -4754,54 +4728,36 @@ body,
}
.mobile-details-peers-table {
margin-bottom: 0;
min-width: 780px;
min-width: 720px;
}
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(1),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(1),
.mobile-details-peers-table.peers-table-hosts th:nth-child(1),
.mobile-details-peers-table.peers-table-hosts td:nth-child(1) {
width: 5%;
}
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(2),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(2),
.mobile-details-peers-table.peers-table-hosts th:nth-child(2),
.mobile-details-peers-table.peers-table-hosts td:nth-child(2) {
width: 14%;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(3),
.mobile-details-peers-table.peers-table-hosts td:nth-child(3) {
width: 16%;
}
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(3),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(3),
.mobile-details-peers-table.peers-table-hosts td:nth-child(3),
.mobile-details-peers-table.peers-table-hosts th:nth-child(4),
.mobile-details-peers-table.peers-table-hosts td:nth-child(4) {
width: 16%;
}
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(4),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(4),
.mobile-details-peers-table.peers-table-hosts th:nth-child(5),
.mobile-details-peers-table.peers-table-hosts td:nth-child(5) {
width: 15%;
}
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(5),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(5),
.mobile-details-peers-table.peers-table-hosts th:nth-child(6),
.mobile-details-peers-table.peers-table-hosts td:nth-child(6) {
width: 8rem;
.mobile-details-peers-table.peers-table-hosts th:nth-child(5),
.mobile-details-peers-table.peers-table-hosts td:nth-child(5) {
width: 16%;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(6),
.mobile-details-peers-table.peers-table-hosts td:nth-child(6) {
width: 10%;
}
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(6),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(6),
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(7),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(7),
.mobile-details-peers-table.peers-table-hosts th:nth-child(7),
.mobile-details-peers-table.peers-table-hosts td:nth-child(7),
.mobile-details-peers-table.peers-table-hosts th:nth-child(8),
@@ -4809,19 +4765,13 @@ body,
width: 7%;
}
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(8),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(8),
.mobile-details-peers-table.peers-table-hosts th:nth-child(9),
.mobile-details-peers-table.peers-table-hosts td:nth-child(9) {
.mobile-details-peers-table.peers-table-hosts td:nth-child(9),
.mobile-details-peers-table.peers-table-hosts th:nth-child(10),
.mobile-details-peers-table.peers-table-hosts td:nth-child(10) {
width: 6%;
}
.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(9),
.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(9),
.mobile-details-peers-table.peers-table-hosts th:nth-child(10),
.mobile-details-peers-table.peers-table-hosts td:nth-child(10) {
width: 8%;
}
/* App modal widths stay consistent while Bootstrap still handles full-screen mobile breakpoints. */
.app-modal-dialog,
@@ -5287,6 +5237,7 @@ body,
overflow-wrap: anywhere;
}
.mobile-details-peers-table,
.mobile-details-files-table {
margin-bottom: 0;
}
File diff suppressed because one or more lines are too long