diff --git a/.env.example b/.env.example index ba07c7c..78f7d5d 100644 --- a/.env.example +++ b/.env.example @@ -25,4 +25,4 @@ PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 PYTORRENT_JOBS_RETENTION_DAYS=30 PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS=30 PYTORRENT_LOG_RETENTION_DAYS=30 -PYTORRENT_SMART_QUEUE_LABEL="Smart Queue Paused" \ No newline at end of file +PYTORRENT_SMART_QUEUE_LABEL="Smart Queue" diff --git a/.gitignore b/.gitignore index 0f0aeca..68b35d7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ storage/* *.sqlite3-shm *.sqlite3 data/* +!data/tracker_favicons +data/tracker_favicons/*.ico logs/* todo.txt diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py index 3d6ec4d..2df3fb9 100644 --- a/pytorrent/__init__.py +++ b/pytorrent/__init__.py @@ -30,7 +30,6 @@ def _wants_json_response() -> bool: def register_error_pages(app: Flask) -> None: - # Notatka: własne strony błędów zastępują generyczne 404/500 i zachowują JSON dla API. @app.errorhandler(404) def not_found(error): if _wants_json_response(): @@ -92,7 +91,6 @@ def create_app() -> Flask: return url_for("static", filename=filename) def frontend_asset_url(key: str) -> str: - # Notatka: helper przełącza szablony między CDN i lokalnymi plikami bez duplikowania logiki. path = asset_path(key) return path if path.startswith("http") else static_url(path) @@ -108,12 +106,19 @@ def create_app() -> Flask: @app.after_request def cache_headers(response): - response.headers.pop('Content-Disposition', None) - - if request.endpoint == "static": + response.headers.pop("Content-Disposition", None) + + static_file = request.path.startswith("/static/") + tracker_icon = request.path.startswith("/static/tracker_favicons/") + favicon_ico = request.path == "/favicon.ico" + + if static_file and not tracker_icon: response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + elif favicon_ico: + response.headers["Cache-Control"] = "public, max-age=86400" else: response.headers["Cache-Control"] = "no-store, private" + return response from .routes.main import bp as main_bp @@ -122,6 +127,8 @@ def create_app() -> Flask: app.register_blueprint(api_bp) register_error_pages(app) init_db() + from .services.speed_peaks import load_cache + load_cache() from .services.auth import install_guards install_guards(app) diff --git a/pytorrent/cli.py b/pytorrent/cli.py index 8038c96..b0d5b03 100644 --- a/pytorrent/cli.py +++ b/pytorrent/cli.py @@ -3,9 +3,11 @@ from __future__ import annotations import argparse import getpass import sys +import json from .db import connect, init_db, utcnow from .services.auth import password_hash +from .services import tracker_cache def reset_password(username: str, password: str) -> bool: @@ -30,6 +32,24 @@ def reset_password(username: str, password: str) -> bool: return True + +def fetch_tracker_favicon(domain: str, refresh: bool = True, debug: bool = False) -> str: + """Note: Download or refresh one tracker favicon from CLI without starting the web server.""" + clean = tracker_cache.tracker_domain(domain) + if not clean: + raise ValueError("Tracker domain is required") + init_db() + path, mime = tracker_cache.favicon_path(clean, enabled=True, force=refresh) + row = tracker_cache.favicon_cache_row(clean) + if not path: + detail = (row or {}).get("error") if row else "favicon not found" + if debug and row: + raise RuntimeError(f"{detail or 'favicon not found'}; cache={json.dumps(dict(row), default=str)}") + raise RuntimeError(str(detail or "favicon not found")) + if debug and row: + return f"{path} ({mime or 'unknown'}) cache={json.dumps(dict(row), default=str)}" + return f"{path} ({mime or 'unknown'})" + def _password_from_args(args: argparse.Namespace) -> str: """Note: Allow the password to be passed as an argument or entered securely in interactive mode.""" if args.password is not None: @@ -51,6 +71,12 @@ def build_parser() -> argparse.ArgumentParser: reset.add_argument("password", nargs="?", help="New password; omit to type it interactively") reset.set_defaults(func=_cmd_reset_password) + icon = sub.add_parser("tracker-favicon", help="Download or refresh a tracker favicon cache file") + icon.add_argument("domain", help="Tracker domain, e.g. t.pte.nu") + icon.add_argument("--no-refresh", action="store_true", help="Use fresh cache when available") + icon.add_argument("--debug", action="store_true", help="Print cache diagnostics on success or failure") + icon.set_defaults(func=_cmd_tracker_favicon) + return parser @@ -64,6 +90,12 @@ def _cmd_reset_password(args: argparse.Namespace) -> int: return 1 +def _cmd_tracker_favicon(args: argparse.Namespace) -> int: + """Note: Run favicon discovery from CLI and print the saved file path.""" + print(fetch_tracker_favicon(args.domain, refresh=not args.no_refresh, debug=bool(args.debug))) + return 0 + + def main(argv: list[str] | None = None) -> int: """Note: Main CLI entrypoint with error handling and without starting the web app.""" parser = build_parser() diff --git a/pytorrent/config.py b/pytorrent/config.py index cac301b..97f7f20 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -70,5 +70,5 @@ TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_D JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1) LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 30, 1) -SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Paused") +SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Stopped") SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled") diff --git a/pytorrent/db.py b/pytorrent/db.py index 67186bf..68baa75 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -39,6 +39,9 @@ CREATE TABLE IF NOT EXISTS user_preferences ( peers_refresh_seconds INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0, footer_items_json TEXT, + title_speed_enabled INTEGER DEFAULT 0, + tracker_favicons_enabled INTEGER DEFAULT 0, + interface_scale INTEGER DEFAULT 100, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) @@ -140,6 +143,8 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings ( 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, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id) @@ -150,6 +155,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_stalled ( torrent_hash TEXT NOT NULL, first_stalled_at TEXT NOT NULL, updated_at TEXT NOT NULL, + timer_key TEXT DEFAULT '', PRIMARY KEY(profile_id, torrent_hash) ); @@ -197,6 +203,22 @@ CREATE TABLE IF NOT EXISTS traffic_history ( CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at); +CREATE TABLE IF NOT EXISTS transfer_speed_peaks ( + profile_id INTEGER PRIMARY KEY, + session_started_at TEXT NOT NULL, + session_down_peak INTEGER DEFAULT 0, + session_up_peak INTEGER DEFAULT 0, + session_down_peak_at TEXT, + session_up_peak_at TEXT, + all_time_down_peak INTEGER DEFAULT 0, + all_time_up_peak INTEGER DEFAULT 0, + all_time_down_peak_at TEXT, + all_time_up_peak_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS automation_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -257,6 +279,26 @@ CREATE TABLE IF NOT EXISTS torrent_stats_cache ( updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0 ); + +CREATE TABLE IF NOT EXISTS tracker_summary_cache ( + profile_id INTEGER NOT NULL, + torrent_hash TEXT NOT NULL, + trackers_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_epoch REAL DEFAULT 0, + PRIMARY KEY(profile_id, torrent_hash) +); +CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch); + +CREATE TABLE IF NOT EXISTS tracker_favicon_cache ( + domain TEXT PRIMARY KEY, + source_url TEXT, + file_path TEXT, + mime_type TEXT, + updated_at TEXT NOT NULL, + updated_epoch REAL DEFAULT 0, + error TEXT +); """ MIGRATIONS = [ @@ -269,6 +311,9 @@ MIGRATIONS = [ "ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT", + "ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0", + "ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0", + "ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100", "ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5", "ALTER TABLE rtorrent_profiles ADD COLUMN is_remote INTEGER DEFAULT 0", "ALTER TABLE jobs ADD COLUMN attempts INTEGER DEFAULT 0", @@ -282,6 +327,12 @@ MIGRATIONS = [ "ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN manage_stopped INTEGER DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN min_peers INTEGER DEFAULT 0", + "ALTER TABLE smart_queue_settings ADD COLUMN ignore_seed_peer INTEGER DEFAULT 0", + "ALTER TABLE smart_queue_settings ADD COLUMN ignore_speed INTEGER DEFAULT 0", + "ALTER TABLE smart_queue_stalled ADD COLUMN timer_key TEXT DEFAULT ''", + "CREATE TABLE IF NOT EXISTS tracker_summary_cache (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, trackers_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, PRIMARY KEY(profile_id, torrent_hash))", + "CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch)", + "CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)", ] diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 907e502..5795a14 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -13,11 +13,11 @@ import socket import json import psutil import xml.etree.ElementTree as ET -from flask import Blueprint, jsonify, request, abort +from flask import Blueprint, jsonify, request, abort, send_file, redirect from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS 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 +from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache from ..services.torrent_cache import torrent_cache from ..services.torrent_summary import cached_summary from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, clear_jobs, emergency_clear_jobs @@ -484,6 +484,58 @@ def torrents(): }) + +@bp.get("/trackers/summary") +def trackers_summary(): + profile = preferences.active_profile() + if not profile: + return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"}) + try: + # Note: Tracker summary returns cached data immediately; optional warmup scans rTorrent in the background for very large libraries. + scan_limit = min(250, max(0, int(request.args.get("scan_limit") or 0))) + bg_limit = min(250, max(1, int(request.args.get("bg_limit") or 80))) + warm = str(request.args.get("warm") or "").lower() in {"1", "true", "yes"} + hashes = [t.get("hash") for t in torrent_cache.snapshot(profile["id"]) if t.get("hash")] + prefs = preferences.get_preferences() + include_favicons = bool(prefs and prefs.get("tracker_favicons_enabled")) + loader = lambda h: rtorrent.torrent_trackers(profile, h) + summary = tracker_cache.summary(profile, hashes, loader, scan_limit=scan_limit, include_favicons=include_favicons) + if warm and int(summary.get("pending") or 0) > 0: + summary["warming"] = tracker_cache.warm_summary_cache(profile, hashes, loader, batch_size=bg_limit) + return ok({"summary": summary}) + except Exception as exc: + return ok({"summary": {"hashes": {}, "trackers": [], "errors": [{"error": str(exc)}], "scanned": 0, "pending": 0}, "error": str(exc)}) + + +@bp.get("/trackers/favicon/") +@bp.get("/tracker-favicon/") +def tracker_favicon(domain: str): + prefs = preferences.get_preferences() + force = str(request.args.get("refresh") or "").lower() in {"1", "true", "yes", "force"} + # Note: Manual refresh must work from CLI even when tracker favicons are disabled in Preferences. + enabled = force or bool(prefs and prefs.get("tracker_favicons_enabled")) + static_url = tracker_cache.favicon_public_url(domain, enabled=enabled, create=True, force=force) + if static_url: + # Note: The API only discovers/cache-warms the icon; the browser receives the file from /static/tracker_favicons/. + return redirect(static_url, code=302) + cached = tracker_cache.favicon_cache_row(domain) + return jsonify({ + "ok": False, + "error": "favicon not found", + "domain": tracker_cache.tracker_domain(domain), + "enabled": bool(enabled), + "cached_error": (cached or {}).get("error") if cached else None, + }), 404 + + +@bp.get("/trackers/favicon") +def tracker_favicon_query(): + # Note: Query-string alias makes cache warming easier from shell scripts where path routing/proxies may differ. + domain = str(request.args.get("domain") or "").strip() + if not domain: + return jsonify({"ok": False, "error": "domain is required"}), 400 + return tracker_favicon(domain) + @bp.get("/torrent-stats") def torrent_stats_get(): profile = preferences.active_profile() @@ -629,6 +681,8 @@ def system_status(): status["ram"] = psutil.virtual_memory().percent status["usage_source"] = "local" status["usage_available"] = True + # Notatka: REST status zwraca ostatnie rekordy bez czekania na kolejny komunikat Socket.IO. + status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0)) return ok({"status": status}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) @@ -670,6 +724,11 @@ def app_status(): status["scgi"] = rtorrent.scgi_diagnostics(profile) except Exception as exc: status["scgi"] = {"ok": False, "error": str(exc), "url": profile.get("scgi_url")} + try: + # Notatka: panel diagnostyczny pokazuje te same rekordy DL/UL co stopka. + status["speed_peaks"] = speed_peaks.current(profile["id"]) + except Exception as exc: + status["speed_peaks"] = {"error": str(exc)} try: prefs = preferences.get_preferences() status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False) diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py index 9cd4c4c..719fe53 100644 --- a/pytorrent/routes/main.py +++ b/pytorrent/routes/main.py @@ -5,15 +5,26 @@ from ..services.preferences import get_preferences, list_profiles, active_profil from ..services import auth from ..services.frontend_assets import asset_path +# for favicon +from flask import current_app, send_from_directory + bp = Blueprint("main", __name__) def _asset_url(key: str) -> str: - # Notatka: API docs korzysta z tego samego przełącznika CDN/offline co reszta aplikacji. path = asset_path(key) return path if path.startswith("http") else url_for("static", filename=path) +@bp.get("/favicon.ico") +def favicon_ico(): + response = send_from_directory( + current_app.static_folder, + "favicon.svg", + mimetype="image/svg+xml", + ) + return response + @bp.route("/login", methods=["GET", "POST"]) def login(): @@ -106,7 +117,7 @@ def openapi(): "/api/rss/feeds": {"post": {"summary": "Add RSS feed", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "RSS config"}}}}, "/api/rss/rules": {"post": {"summary": "Add RSS rule", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "RSS config"}}}}, "/api/rss/check": {"post": {"summary": "Manually check RSS feeds", "responses": {"200": {"description": "Queued matches"}}}}, - "/api/smart-queue": {"get": {"summary": "Get Smart Queue settings, exceptions and history", "parameters": [{"name": "history_limit", "in": "query", "schema": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "description": "Number of Smart Queue history rows to return"}], "responses": {"200": {"description": "Smart Queue config with history and history_total"}}}, "post": {"summary": "Save Smart Queue settings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "max_active_downloads": {"type": "integer"}, "stalled_seconds": {"type": "integer"}, "min_speed_bytes": {"type": "integer"}, "min_seeds": {"type": "integer"}, "min_peers": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Saved"}}}}, + "/api/smart-queue": {"get": {"summary": "Get Smart Queue settings, exceptions and history", "parameters": [{"name": "history_limit", "in": "query", "schema": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "description": "Number of Smart Queue history rows to return"}], "responses": {"200": {"description": "Smart Queue config with history and history_total"}}}, "post": {"summary": "Save Smart Queue settings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "max_active_downloads": {"type": "integer"}, "stalled_seconds": {"type": "integer"}, "min_speed_bytes": {"type": "integer"}, "min_seeds": {"type": "integer"}, "min_peers": {"type": "integer"}, "ignore_seed_peer": {"type": "boolean"}, "ignore_speed": {"type": "boolean"}}}}}}, "responses": {"200": {"description": "Saved"}}}}, "/api/smart-queue/check": {"post": {"summary": "Run Smart Queue immediately", "responses": {"200": {"description": "Smart Queue action result"}}}}, "/api/smart-queue/exclusion": {"post": {"summary": "Add or remove a torrent Smart Queue exception", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"hash": {"type": "string"}, "excluded": {"type": "boolean"}, "reason": {"type": "string"}}}}}}, "responses": {"200": {"description": "Exception list"}}}}, "/api/traffic/history": {"get": {"summary": "Transfer history for charts", "parameters": [{"name": "range", "in": "query", "schema": {"type": "string", "enum": ["15m", "1h", "3h", "6h", "24h", "7d", "30d", "90d"]}}], "responses": {"200": {"description": "Aggregated traffic history"}}}} diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py index 97ce593..4a3fc51 100644 --- a/pytorrent/services/frontend_assets.py +++ b/pytorrent/services/frontend_assets.py @@ -4,7 +4,6 @@ from pathlib import Path from ..config import BASE_DIR, USE_OFFLINE_LIBS -# Notatka: jeden manifest utrzymuje spójne adresy CDN i ścieżki lokalne dla trybu offline. LIBS_STATIC_DIR = "libs" LIBS_DIR = BASE_DIR / "pytorrent" / "static" / LIBS_STATIC_DIR BOOTSTRAP_VERSION = "5.3.3" @@ -84,7 +83,6 @@ def required_offline_paths() -> list[Path]: def missing_offline_paths() -> list[Path]: missing = [path for path in required_offline_paths() if not path.is_file() or path.stat().st_size <= 0] - # Notatka: sprawdzane są też zasoby referencjonowane przez CSS, np. fonty ikon i pliki flag. required_dirs = [ LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts", LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/4x3", @@ -97,7 +95,6 @@ def missing_offline_paths() -> list[Path]: def validate_offline_assets() -> None: - # Notatka: aplikacja zatrzymuje start, gdy tryb offline jest aktywny, a pliki nie są zainstalowane. if not USE_OFFLINE_LIBS: return missing = missing_offline_paths() diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index e5e9bfa..e5e81b5 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -28,7 +28,6 @@ FONT_FAMILIES = { } def bootstrap_css_url(theme: str | None) -> str: - # Notatka: zachowana funkcja zwraca aktualny adres motywu, ale źródło wybiera konfiguracja offline. from .frontend_assets import bootstrap_css_path return bootstrap_css_path(theme) @@ -167,6 +166,9 @@ def save_preferences(data: dict, user_id: int | None = None): peers_refresh_seconds = data.get("peers_refresh_seconds") port_check_enabled = data.get("port_check_enabled") footer_items_json = data.get("footer_items_json") + title_speed_enabled = data.get("title_speed_enabled") + tracker_favicons_enabled = data.get("tracker_favicons_enabled") + interface_scale = data.get("interface_scale") with connect() as conn: now = utcnow() if allowed_theme: @@ -183,6 +185,15 @@ def save_preferences(data: dict, user_id: int | None = None): conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id)) if port_check_enabled is not None: conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id)) + if title_speed_enabled is not None: + conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id)) + if tracker_favicons_enabled is not None: + conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id)) + if interface_scale is not None: + scale = int(interface_scale or 100) + if scale < 80: scale = 80 + if scale > 140: scale = 140 + conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id)) if footer_items_json is not None: # Note: Store only JSON objects so footer visibility can be extended without schema churn. value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json) diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index 4298f95..7f16502 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -372,6 +372,37 @@ def browse_path(profile: dict, path: str | None = None) -> dict: POST_CHECK_DOWNLOAD_LABEL = "To download after check" +_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60 +_POST_CHECK_WATCH_MIN_SECONDS = 2.0 +_POST_CHECK_WATCH: dict[int, dict[str, float]] = {} + + +def _mark_post_check_watch(profile_id: int, torrent_hash: str) -> None: + if not torrent_hash: + return + _POST_CHECK_WATCH.setdefault(int(profile_id), {})[str(torrent_hash)] = time.time() + + +def _clear_post_check_watch(profile_id: int, torrent_hash: str) -> None: + profile_watch = _POST_CHECK_WATCH.get(int(profile_id)) + if not profile_watch: + return + profile_watch.pop(str(torrent_hash), None) + if not profile_watch: + _POST_CHECK_WATCH.pop(int(profile_id), None) + + +def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool: + profile_watch = _POST_CHECK_WATCH.get(int(profile_id)) or {} + started_at = profile_watch.get(str(torrent_hash)) + if not started_at: + return False + age = time.time() - started_at + if age > _POST_CHECK_WATCH_TTL_SECONDS: + _clear_post_check_watch(profile_id, torrent_hash) + return False + # Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet. + return age >= _POST_CHECK_WATCH_MIN_SECONDS def _label_names(value: str) -> list[str]: @@ -387,65 +418,94 @@ def _label_value(labels: list[str]) -> str: return ", ".join([label for label in labels if str(label or "").strip()]) +def _without_post_check_download_label(value: str | None) -> str: + return _label_value([label for label in _label_names(str(value or "")) if label != POST_CHECK_DOWNLOAD_LABEL]) + + +def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, current_label: str | None = None) -> bool: + label_source = current_label + if label_source is None: + try: + label_source = str(c.call("d.custom1", str(torrent_hash or "")) or "") + except Exception: + label_source = "" + labels = _label_names(str(label_source or "")) + if POST_CHECK_DOWNLOAD_LABEL not in labels: + return False + # Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue. + c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL])) + return True + + +def _message_indicates_active_check(message: str) -> bool: + msg = str(message or "").lower() + if not msg: + return False + finished_markers = ("complete", "completed", "finished", "success", "succeeded", "failed", "done") + if any(marker in msg for marker in finished_markers): + return False + active_markers = ("checking", "hashing", "hash check queued", "hash check scheduled", "check hash queued", "recheck queued", "rechecking") + return any(marker in msg for marker in active_markers) + + def _row_progress_complete(row: dict) -> bool: size = int(row.get("size") or 0) completed = int(row.get("completed_bytes") or 0) return bool(row.get("complete")) or (size > 0 and completed >= size) or float(row.get("progress") or 0) >= 100.0 -def _remove_post_check_label_if_finished(c: ScgiRtorrentClient, row: dict) -> bool: +def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool: labels = _label_names(str(row.get("label") or "")) if POST_CHECK_DOWNLOAD_LABEL not in labels: return False status = str(row.get("status") or "").lower() - if not (_row_progress_complete(row) or status == "seeding"): + started_after_wait = bool(int(row.get("state") or 0)) and status != "checking" + if not (_row_progress_complete(row) or status == "seeding" or started_after_wait): return False - labels = [label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL] - value = _label_value(labels) - # Note: Clean the temporary label after reaching 100% or entering seeding, even when the state no longer comes directly from recheck. - c.call("d.custom1.set", str(row.get("hash") or ""), value) - row["label"] = value + # Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding. + clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or "")) + row["label"] = _without_post_check_download_label(str(row.get("label") or "")) return True def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict[str, dict] | None = None) -> list[dict]: - """Start complete torrents after check; pause and label incomplete ones.""" + """Start complete torrents after check; stop and label incomplete ones for Smart Queue.""" previous_rows = previous_rows or {} + profile_id = int(profile.get("id") or 0) c = client_for(profile) changes: list[dict] = [] for row in rows: h = str(row.get("hash") or "") prev = previous_rows.get(h) or {} try: - if h and _remove_post_check_label_if_finished(c, row): - changes.append({"hash": h, "action": "remove_post_check_label", "complete": True}) + if h and _cleanup_post_check_label_if_ready(c, row): + changes.append({"hash": h, "action": "remove_post_check_label"}) except Exception as exc: changes.append({"hash": h, "action": "remove_post_check_label_failed", "error": str(exc)}) was_checking = str(prev.get("status") or "") == "Checking" or int(prev.get("hashing") or 0) > 0 + watched_recheck = _is_post_check_watched(profile_id, h) is_checking = str(row.get("status") or "") == "Checking" or int(row.get("hashing") or 0) > 0 - if not h or not was_checking or is_checking: + if not h or not (was_checking or watched_recheck) or is_checking: continue complete = _row_progress_complete(row) try: if complete: - # Note: After a completed check, a complete torrent is started automatically so it can seed immediately. - c.call("d.start", h) - labels = [label for label in _label_names(str(row.get("label") or "")) if label != POST_CHECK_DOWNLOAD_LABEL] - if _label_value(labels) != str(row.get("label") or ""): - c.call("d.custom1.set", h, _label_value(labels)) - row["label"] = _label_value(labels) - row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding"}) - changes.append({"hash": h, "action": "start", "complete": True}) + # Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately. + start_result = start_or_resume_hash(c, h) + clear_post_check_download_label(c, h, str(row.get("label") or "")) + row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))}) + changes.append({"hash": h, "action": "start_seed_after_check", "complete": True, "result": start_result}) else: - # Note: After check, an incomplete torrent is paused and labeled to show that it needs more downloading. - c.call("d.start", h) - c.call("d.pause", h) labels = _label_names(str(row.get("label") or "")) if POST_CHECK_DOWNLOAD_LABEL not in labels: labels.append(POST_CHECK_DOWNLOAD_LABEL) - c.call("d.custom1.set", h, _label_value(labels)) - row.update({"state": 1, "active": 0, "paused": True, "status": "Paused", "label": _label_value(labels)}) - changes.append({"hash": h, "action": "pause_and_label", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL}) + label_value = _label_value(labels) + # Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit. + c.call("d.stop", h) + c.call("d.custom1.set", h, label_value) + row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value}) + changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL}) + _clear_post_check_watch(profile_id, h) except Exception as exc: changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)}) return changes @@ -489,7 +549,8 @@ def normalize_row(row: list) -> dict: is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0) state = int(row[2] or 0) complete = int(row[3] or 0) - is_checking = bool(hashing) or ("hash" in msg_l and ("check" in msg_l or "checking" in msg_l)) or "recheck" in msg_l + # Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever. + is_checking = bool(hashing) or _message_indicates_active_check(msg_l) is_paused = bool(state) and not bool(is_active) and not is_checking status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped" return { @@ -861,6 +922,49 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d raise RuntimeError("; ".join(errors)) + +def _tracker_domain(url: str) -> str: + raw = str(url or '').strip() + if not raw: + return '' + parsed = urlparse(raw if '://' in raw else f'http://{raw}') + host = (parsed.hostname or '').lower().strip('.') + if host.startswith('www.'): + host = host[4:] + return host + + +def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict: + """Return tracker domains grouped by torrent for the sidebar filter.""" + # Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list. + hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()] + if not hashes: + hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')] + hashes = hashes[:max(1, int(limit or 1000))] + by_hash: dict[str, list[dict]] = {} + counts: dict[str, dict] = {} + errors = [] + for h in hashes: + try: + items = [] + seen = set() + for tr in torrent_trackers(profile, h): + url = str(tr.get('url') or '') + domain = _tracker_domain(url) + if not domain or domain in seen: + continue + seen.add(domain) + item = {'domain': domain, 'url': url} + items.append(item) + row = counts.setdefault(domain, {'domain': domain, 'url': url, 'count': 0}) + row['count'] += 1 + by_hash[h] = items + except Exception as exc: + errors.append({'hash': h, 'error': str(exc)}) + by_hash[h] = [] + trackers = sorted(counts.values(), key=lambda x: (-int(x.get('count') or 0), str(x.get('domain') or ''))) + return {'hashes': by_hash, 'trackers': trackers, 'errors': errors, 'scanned': len(hashes)} + def _safe_tracker_call(c: ScgiRtorrentClient, method: str, target: str, default=None): try: return c.call(method, target) @@ -880,9 +984,39 @@ def _tracker_int(value, default=None): return default +def _tracker_rows(c: ScgiRtorrentClient, torrent_hash: str) -> list[list]: + fields = ("t.url=", "t.is_enabled=", "t.scrape_complete=", "t.scrape_incomplete=", "t.scrape_downloaded=") + errors: list[str] = [] + for args in ((torrent_hash, "", *fields), ("", torrent_hash, *fields)): + try: + rows = c.call("t.multicall", *args) + return [list(r) for r in (rows or [])] + except Exception as exc: + errors.append(f"t.multicall{args[:2]}: {exc}") + # Note: Fallback keeps the sidebar tracker filter usable on rTorrent builds without t.multicall scrape fields. + total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or 0 + rows: list[list] = [] + for index in range(max(0, total)): + target = _tracker_target(torrent_hash, index) + url = _safe_tracker_call(c, "t.url", target, "") + if not url: + for args in ((torrent_hash, index), ("", torrent_hash, index)): + try: + url = c.call("t.url", *args) + break + except Exception: + continue + if url: + enabled = _safe_tracker_call(c, "t.is_enabled", target, 1) + rows.append([url, enabled, None, None, None]) + if rows: + return rows + raise RuntimeError("Cannot read trackers: " + "; ".join(errors)) + + def torrent_trackers(profile: dict, torrent_hash: str) -> list[dict]: c = client_for(profile) - rows = c.t.multicall(torrent_hash, "", "t.url=", "t.is_enabled=", "t.scrape_complete=", "t.scrape_incomplete=", "t.scrape_downloaded=") + rows = _tracker_rows(c, torrent_hash) trackers = [] for idx, r in enumerate(rows): target = _tracker_target(torrent_hash, idx) @@ -1216,6 +1350,27 @@ def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: return result +def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: + """Stop an active rTorrent item without using pause semantics.""" + h = str(torrent_hash or '') + if not h: + return {'hash': h, 'ok': False, 'error': 'missing hash'} + before = _download_runtime_state(c, h) + result = {'hash': h, 'before': before, 'commands': []} + if before.get('stopped'): + result.update({'ok': True, 'skipped': 'already_stopped', 'after': before}) + return result + try: + # Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched. + c.call('d.stop', h) + result['commands'].append('d.stop') + result['after'] = _download_runtime_state(c, h) + result['ok'] = True + except Exception as exc: + result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)}) + return result + + def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: """Resume only a paused rTorrent item; never convert it through stop/start.""" h = str(torrent_hash or '') @@ -1377,6 +1532,9 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | if remove_data: results.append(_remove_torrent_data(c, h)) c.call(method, h) + if name == "recheck": + # Note: Recheck is tracked so even very fast checks still receive the after-check start/stop policy. + _mark_post_check_watch(int(profile.get("id") or 0), h) return {"ok": True, "count": len(torrent_hashes), "remove_data": remove_data, "results": results} def add_magnet(profile: dict, uri: str, start: bool = True, directory: str = "", label: str = "") -> dict: diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index c1558f6..84aacc6 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -38,7 +38,9 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: 'min_speed_bytes': 1024, 'min_seeds': 1, 'min_peers': 0, - 'manage_stopped': 0, + 'ignore_seed_peer': 0, + 'ignore_speed': 0, + 'manage_stopped': 1, 'updated_at': utcnow(), } @@ -64,14 +66,18 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N 'min_seeds': _int_setting(data, current, 'min_seeds', 0, 0), # Note: Min peers is optional; when set, stalled detection requires low speed, low seeds and low peers. 'min_peers': _int_setting(data, current, 'min_peers', 0, 0), - # Note: This switch protects fully stopped torrents from automatic starts; by default Smart Queue manages only paused items. - 'manage_stopped': 1 if data.get('manage_stopped', current.get('manage_stopped')) else 0, + # Note: Ignore seed/peer removes source counts from stalled detection, useful when sources appear rarely. + 'ignore_seed_peer': 1 if data.get('ignore_seed_peer', current.get('ignore_seed_peer')) else 0, + # Note: Ignore speed removes low transfer rate from stalled detection; with both ignores enabled only stalled_seconds matters. + 'ignore_speed': 1 if data.get('ignore_speed', current.get('ignore_speed')) else 0, + # Note: Compatibility field retained; enabled Smart Queue always manages stopped torrents and never manages user-paused torrents. + 'manage_stopped': 1, } now = utcnow() with connect() as conn: conn.execute( - '''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,manage_stopped,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?) + '''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id, profile_id) DO UPDATE SET enabled=excluded.enabled, max_active_downloads=excluded.max_active_downloads, @@ -79,9 +85,11 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N min_speed_bytes=excluded.min_speed_bytes, min_seeds=excluded.min_seeds, min_peers=excluded.min_peers, + ignore_seed_peer=excluded.ignore_seed_peer, + ignore_speed=excluded.ignore_speed, manage_stopped=excluded.manage_stopped, updated_at=excluded.updated_at''', - (user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['manage_stopped'], now), + (user_id, 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'], now), ) return get_settings(profile_id, user_id) @@ -241,7 +249,7 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current except Exception: return False try: - # Note: Starting a torrent removes only Smart Queue's technical marker, so labels added while paused stay untouched. + # Note: Starting a torrent removes only Smart Queue's technical marker, so labels added while stopped stay untouched. if _has_smart_queue_label(live_label): client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(live_label)) conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) @@ -291,19 +299,20 @@ def _ensure_rtorrent_download_cap(client: Any, max_active: int) -> dict[str, Any def _start_download(client: Any, torrent: dict[str, Any]) -> dict[str, Any]: - """Resume paused torrents through rTorrent's pause model.""" + """Start only stopped Smart Queue candidates; paused torrents are a user decision.""" h = str(torrent.get('hash') or '') if not h: return {'hash': h, 'ok': False, 'error': 'missing hash'} - if bool(torrent.get('paused')) or str(torrent.get('status') or '').lower() == 'paused' or int(torrent.get('state') or 0): - # Note: Smart Queue candidates paused with d.pause must be resumed with d.resume, without d.start/d.stop. - return rtorrent.resume_paused_hash(client, h) - # Note: Only optional manage_stopped uses the start path for fully stopped torrents. + if _is_user_paused(torrent): + # Note: Smart Queue never unpauses user-paused torrents; it manages only stopped items. + return {'hash': h, 'ok': False, 'skipped': 'user_paused'} + # Note: This is the same helper used by the manual Start action, so queue starts follow the UI path. + # Note: Smart Queue uses the same helper as the manual Start action, so start behavior stays identical. return rtorrent.start_or_resume_hash(client, h) def _verify_started_downloads(client: Any, hashes: list[str], attempts: int = 10, delay: float = 0.5) -> tuple[list[str], list[dict[str, Any]]]: - """Verify starts after rTorrent has time to process resume/start commands.""" + """Verify starts after rTorrent has time to process manual-equivalent start commands.""" pending = [h for h in hashes if h] started: list[str] = [] no_effect: list[dict[str, Any]] = [] @@ -342,11 +351,17 @@ def _read_live_start_state(client: Any, torrent_hash: str) -> dict[str, Any]: result[key] = int(value or 0) if key in {'state', 'active', 'open', 'priority'} else str(value or '') except Exception as exc: result[f'{key}_error'] = str(exc) - # Note: Do not treat d.is_open or state=1 as resumed; Paused can also have those values. - # Smart Queue counts a start only after d.is_active=1, meaning the pause was actually removed. - result['started'] = bool(int(result.get('active') or 0)) + # Note: Manual Start in rTorrent is successful when d.state becomes 1. + # d.is_active can stay 0 for queued/idle downloads, so it must not be used as the only success check. + result['started'] = bool(int(result.get('state') or 0) or int(result.get('active') or 0)) return result + +def _is_user_paused(torrent: dict[str, Any]) -> bool: + """Return True for torrents paused by the user; Smart Queue must not touch them.""" + status = str(torrent.get('status') or '').lower() + return bool(torrent.get('paused')) or status == 'paused' + def _set_smart_queue_label(client: Any, torrent_hash: str, current_label: str = '', attempts: int = 3) -> bool: labels = _label_names(current_label) if SMART_QUEUE_LABEL in labels: @@ -364,7 +379,7 @@ def _set_smart_queue_label(client: Any, torrent_hash: str, current_label: str = return False -def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) -> bool: +def _mark_auto_stopped(client: Any, profile_id: int, torrent: dict[str, Any]) -> bool: torrent_hash = str(torrent.get('hash') or '') if not torrent_hash: return False @@ -374,20 +389,31 @@ def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) -> return _set_smart_queue_label(client, torrent_hash, previous) +def _is_started_download_slot(torrent: dict[str, Any] | None) -> bool: + """Return True for incomplete torrents already started in rTorrent, including manual starts.""" + if not torrent or int(torrent.get('complete') or 0): + return False + status = str(torrent.get('status') or '').lower() + if status == 'checking': + return False + # Note: Manual Start changes d.state first; d.is_active may stay 0 while rTorrent is queued or idle. + return bool(int(torrent.get('state') or 0) or int(torrent.get('active') or 0)) + + def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = True) -> bool: if not torrent or int(torrent.get('complete') or 0): return False + if _is_started_download_slot(torrent): + # Note: A manual start can leave the Smart Queue label behind; started items are active slots, not holds. + return False if _has_stalled_label(str(torrent.get('label') or '')): return False + if _is_user_paused(torrent): + # Note: Paused torrents are always treated as user-controlled and are not Smart Queue holds. + return False if _has_smart_queue_label(str(torrent.get('label') or '')): return True - # Note: Paused in rTorrent usually has state=1 and active=0, so state=0 must not be required. - # This lets Smart Queue treat paused torrents as pending and fill the queue target later. - if bool(torrent.get('paused')): - return True - # Note: Fully stopped items are managed only when Use stopped torrents is enabled. - if not manage_stopped: - return False + # Note: Smart Queue manages stopped torrents by default; the old manage_stopped flag is ignored for compatibility. return not int(torrent.get('state') or 0) @@ -432,31 +458,50 @@ def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str, def _is_running_download_slot(t: dict[str, Any]) -> bool: """Return True for incomplete torrents that already occupy a Smart Queue slot.""" - # Note: The Smart Queue limit means the target number of actually active slots. - # Paused can have state=1/open=1, so a slot is counted only after d.is_active=1. - if int(t.get('complete') or 0): - return False - if _has_smart_queue_label(str(t.get('label') or '')) or _has_stalled_label(str(t.get('label') or '')): - return False - status = str(t.get('status') or '').lower() - if status == 'checking' or status == 'paused' or bool(t.get('paused')): - return False - return bool(int(t.get('active') or 0)) + # Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels, + # and those torrents still must count toward the global Smart Queue limit. + return _is_started_download_slot(t) + + +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: Each ignore switch only removes its own criterion; the stalled timer is still respected 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 + + +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: Changing ignore switches or thresholds restarts existing stalled timers instead of reusing old rows. + return f"v2|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, ignore_seed_peer: bool = False, ignore_speed: bool = False) -> bool: + """Return True when a started torrent is weak and should be stopped first.""" + # 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)) + return low_speed or low_seeds or low_peers def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> bool: - """Return True for paused/held torrents Smart Queue may resume later.""" + """Return True for stopped torrents Smart Queue may start later.""" if int(t.get('complete') or 0): return False + if str(t.get('status') or '').lower() == 'checking': + # Note: Torrents still being checked must finish post-check handling before Smart Queue may start them. + return False if _has_stalled_label(str(t.get('label') or '')): return False + if _is_user_paused(t): + # Note: User-paused torrents are never candidates, even when they have no Smart Queue label. + return False if _has_smart_queue_label(str(t.get('label') or '')): return True - # Note: Paused items are the primary source for filling the queue, regardless of manage_stopped. - if bool(t.get('paused')) or str(t.get('status') or '').lower() == 'paused': - return True - # Note: Stopped items are added only when the user enabled Use stopped torrents. - return bool(manage_stopped) and not int(t.get('state') or 0) + # Note: Enabled Smart Queue manages all stopped torrents; no separate stopped-torrent switch is needed. + return not int(t.get('state') or 0) def check(profile: dict | None = None, user_id: int | None = None, force: bool = False) -> dict[str, Any]: @@ -471,58 +516,74 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = try: # Note: When Smart Queue is disabled, only technical labels are cleaned up, without starting or pausing torrents. torrents = rtorrent.list_torrents(profile) - restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), bool(settings.get('manage_stopped'))) + restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), True) except Exception: restored = [] add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False, 'labels_restored': restored}, user_id) - return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'} + return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'} torrents = rtorrent.list_torrents(profile) - # Note: Torrents marked as Stalled are treated as queue-blocked even when there are no other pending downloads. + # Note: Stalled labels block automatic starting only; a manually started Stalled item still counts as a running slot. stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')} - excluded = _excluded_hashes(profile_id, user_id) | stalled_label_hashes - manage_stopped = bool(settings.get('manage_stopped')) - def is_managed_hold(t: dict[str, Any]) -> bool: - return _has_smart_queue_label(str(t.get('label') or '')) + user_excluded = _excluded_hashes(profile_id, user_id) + manage_stopped = True - # Note: Count Smart Queue slots by d.is_active because Paused can have state=1/open=1 and must not occupy the limit. + # Note: Count every started incomplete torrent, including items started manually and items with old Smart Queue labels. downloading = [ t for t in torrents if _is_running_download_slot(t) - and not is_managed_hold(t) - and t.get('hash') not in excluded + and str(t.get('hash') or '') not in user_excluded ] - # Note: Candidates also include regular Paused items without a label. Otherwise the queue sees only one or two items - # and cannot fill the configured target of 100. + # Note: Waiting candidates are stopped queue holds only; Stalled labels are not auto-started again. stopped = [ t for t in torrents - if t.get('hash') not in excluded + if str(t.get('hash') or '') not in user_excluded + and str(t.get('hash') or '') not in stalled_label_hashes and _is_waiting_download_candidate(t, manage_stopped) and not _is_running_download_slot(t) ] + manual_labeled_running = [ + str(t.get('hash') or '') for t in downloading + if str(t.get('hash') or '') and _has_smart_queue_label(str(t.get('label') or '')) + ] min_speed = int(settings.get('min_speed_bytes') or 0) min_seeds = int(settings.get('min_seeds') or 0) min_peers = int(settings.get('min_peers') or 0) + ignore_seed_peer = bool(int(settings.get('ignore_seed_peer') or 0)) + ignore_speed = bool(int(settings.get('ignore_speed') or 0)) stalled_seconds = int(settings.get('stalled_seconds') or 300) + timer_key = _stalled_timer_key(min_speed, min_seeds, min_peers, stalled_seconds, ignore_seed_peer, ignore_speed) now = utcnow() now_ts = datetime.now(timezone.utc).timestamp() stalled: list[dict[str, Any]] = [] + stop_eligible: list[dict[str, Any]] = [] + # Note: Toast diagnostics count active torrents whose ignored criteria would otherwise match during this check. + ignored_seed_peer_count = 0 + ignored_speed_count = 0 with connect() as conn: for t in downloading: - # Note: Stalled detection requires low speed plus low seeds and, when configured, low peers. - is_stalled = int(t.get('down_rate') or 0) <= min_speed and int(t.get('seeds') or 0) <= min_seeds and (min_peers <= 0 or int(t.get('peers') or 0) <= min_peers) + # Note: Stalled detection respects seed/peer and speed ignore switches before starting the timer. + if ignore_seed_peer and (int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) or (min_peers > 0 and int(t.get('peers') or 0) <= max(0, int(min_peers or 0)))): + 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, ignore_seed_peer, ignore_speed) + # Note: Hard-limit enforcement respects the same ignore switches before choosing weak items. + if _is_low_activity_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed): + stop_eligible.append(t) h = t.get('hash') if not h: continue if is_stalled: - row = conn.execute('SELECT first_stalled_at FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)).fetchone() - if row: + row = conn.execute('SELECT first_stalled_at, timer_key FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)).fetchone() + if row and str(row.get('timer_key') or '') == timer_key: conn.execute('UPDATE smart_queue_stalled SET updated_at=? WHERE profile_id=? AND torrent_hash=?', (now, profile_id, h)) first = row['first_stalled_at'] else: + # Note: A changed stalled rule starts a fresh timer, so old rows cannot instantly mark torrents as Stalled. first = now - conn.execute('INSERT OR REPLACE INTO smart_queue_stalled(profile_id,torrent_hash,first_stalled_at,updated_at) VALUES(?,?,?,?)', (profile_id, h, first, now)) + conn.execute('INSERT OR REPLACE INTO smart_queue_stalled(profile_id,torrent_hash,first_stalled_at,updated_at,timer_key) VALUES(?,?,?,?,?)', (profile_id, h, first, now, timer_key)) if now_ts - _ts(first) >= stalled_seconds: stalled.append(t) else: @@ -537,95 +598,106 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = max_active = max(1, int(settings.get('max_active_downloads') or 5)) stalled_hashes = {str(t.get('hash') or '') for t in stalled} - # Enforce the hard active-download cap first. The previous logic only limited - # newly resumed torrents, so already-active downloads could stay above the limit. - pause_rank = sorted( + # Enforce the hard active-download cap across the whole started queue, including manual starts. + # Note: Weak/no-source torrents are stopped first, but the cap is still enforced when the overflow is larger. + over_limit = max(0, len(downloading) - max_active) + stop_eligible_hashes = {str(t.get('hash') or '') for t in stop_eligible} + stop_rank = sorted( downloading, key=lambda t: ( 0 if str(t.get('hash') or '') in stalled_hashes else 1, + 0 if str(t.get('hash') or '') in stop_eligible_hashes else 1, int(t.get('down_rate') or 0), int(t.get('seeds') or 0), int(t.get('peers') or 0), ), ) - to_pause: list[dict[str, Any]] = pause_rank[:max(0, len(downloading) - max_active)] - pause_hashes = {str(t.get('hash') or '') for t in to_pause} + to_stop: list[dict[str, Any]] = stop_rank[:over_limit] + stop_hashes = {str(t.get('hash') or '') for t in to_stop} # Note: Confirmed stalled downloads are removed from the active queue immediately, then new candidates can fill those slots. for t in stalled: h = str(t.get('hash') or '') - if h and h not in pause_hashes: - to_pause.append(t) - pause_hashes.add(h) - - active_after_pause = max(0, len(downloading) - len(to_pause)) - available_slots = max(0, max_active - active_after_pause) - to_resume = candidates[:available_slots] - # Note: Items outside the current start batch are explicitly marked as pending Smart Queue items. - to_label_waiting = candidates[available_slots:] + if h and h not in stop_hashes: + to_stop.append(t) + stop_hashes.add(h) c = rtorrent.client_for(profile) rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active) - paused: list[str] = [] - resumed: list[str] = [] + stopped_by_queue: list[str] = [] + started_by_queue: list[str] = [] label_failed: list[str] = [] stalled_labeled: list[str] = [] + stop_failed: list[dict[str, str]] = [] start_failed: list[dict[str, str]] = [] start_no_effect: list[dict[str, Any]] = [] - resume_requested: list[str] = [] + start_requested: list[str] = [] start_results: list[dict[str, Any]] = [] - for t in to_pause: + for t in to_stop: + h = str(t.get('hash') or '') try: - h = str(t.get('hash') or '') - pause_result = rtorrent.pause_hash(c, h) - if not pause_result.get('ok'): - raise RuntimeError(pause_result.get('error') or 'pause failed') + # Note: Smart Queue stops with the same low-level d.stop command used by the manual Stop action. + # This avoids extra pre-check RPCs and keeps large queues from failing after only a few items. + c.call('d.stop', h) if h in stalled_hashes: if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))): stalled_labeled.append(h) else: label_failed.append(h) - elif not _mark_auto_paused(c, profile_id, t): + elif not _mark_auto_stopped(c, profile_id, t): label_failed.append(h) - paused.append(h) - except Exception: - pass + stopped_by_queue.append(h) + except Exception as exc: + # Note: Stop failures are stored in history instead of being swallowed, so queue drift is visible. + stop_failed.append({'hash': h, 'error': str(exc)}) + + active_after_stop = max(0, len(downloading) - len(stopped_by_queue)) + # Note: Starts are planned only after confirmed stops, so failed stops cannot push the queue above the cap. + available_slots = max(0, max_active - active_after_stop) + to_start = candidates[:available_slots] + # Note: Items outside the current start batch are explicitly marked as pending Smart Queue items. + to_label_waiting = candidates[available_slots:] for t in to_label_waiting: h = str(t.get('hash') or '') - if not h or h in pause_hashes: + if not h or h in stop_hashes: continue try: - if not _mark_auto_paused(c, profile_id, t): + if not _mark_auto_stopped(c, profile_id, t): label_failed.append(h) except Exception: label_failed.append(h) # Note: Start the whole candidate batch in one round. Remove the label after an accepted RPC, # because rTorrent may keep some items in its own queue with active=0 despite a valid d.start/d.resume. - for t in to_resume: + for t in to_start: h = str(t.get('hash') or '') if not h: continue try: result = _start_download(c, t) start_results.append(result) - resume_requested.append(h) + start_requested.append(h) except Exception as exc: start_failed.append({'hash': h, 'error': str(exc)}) - active_verified, start_no_effect = _verify_started_downloads(c, resume_requested) + active_verified, start_no_effect = _verify_started_downloads(c, start_requested) for h in active_verified: _restore_auto_label(c, profile_id, h, None) - # Note: History shows only torrents actually unpaused, not just the number of sent commands. - resumed = list(active_verified) + try: + # Note: Once Smart Queue starts a post-check torrent, its temporary download-after-check label is no longer needed. + rtorrent.clear_post_check_download_label(c, h, None) + except Exception: + label_failed.append(h) + # Note: History shows only torrents actually started, not just the number of sent commands. + started_by_queue = list(active_verified) keep_labels = ( - set(paused) + set(stopped_by_queue) | {str(t.get('hash') or '') for t in to_label_waiting} - | {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(resumed)} + | {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, manage_stopped) - details = {'excluded': len(excluded), 'excluded_stalled': len(stalled_label_hashes), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'resume_requested': resume_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': manage_stopped, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_pause + len(resumed), 'paused_planned': len(to_pause), 'resumed_planned': len(to_resume), 'rtorrent_cap': rtorrent_cap} - add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), details, user_id) - return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings} + details = {'excluded': len(user_excluded), 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'manual_labeled_running_hashes': manual_labeled_running[:100], 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'start_requested': start_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': True, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'active_after_expected': active_after_stop + len(started_by_queue), 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), '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, 'healthy_active_protected': 0, 'stopped_planned': len(to_stop), 'started_planned': len(to_start), 'paused_planned': len(to_stop), 'resumed_planned': len(to_start), 'rtorrent_cap': rtorrent_cap} + add_history(profile_id, 'force_check' if force else 'auto_check', stopped_by_queue, started_by_queue, len(torrents), {**details, 'stopped': stopped_by_queue, 'started': started_by_queue}, user_id) + 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, '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, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), '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, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings} diff --git a/pytorrent/services/speed_peaks.py b/pytorrent/services/speed_peaks.py new file mode 100644 index 0000000..0b1b50d --- /dev/null +++ b/pytorrent/services/speed_peaks.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import threading +from typing import Any + +from ..db import connect, utcnow +from .rtorrent import human_rate + +_SESSION_STARTED_AT = utcnow() +_CACHE: dict[int, dict[str, Any]] = {} +_LOADED = False +_LOCK = threading.Lock() + + +def _empty_peak(profile_id: int, all_time: dict[str, Any] | None = None) -> dict[str, Any]: + # Notatka: jedna struktura w pamięci trzyma bieżącą sesję i rekord ogólny dla profilu rTorrent. + all_time = all_time or {} + return { + "profile_id": int(profile_id), + "session_started_at": _SESSION_STARTED_AT, + "session_down_peak": 0, + "session_up_peak": 0, + "session_down_peak_at": None, + "session_up_peak_at": None, + "all_time_down_peak": int(all_time.get("all_time_down_peak") or 0), + "all_time_up_peak": int(all_time.get("all_time_up_peak") or 0), + "all_time_down_peak_at": all_time.get("all_time_down_peak_at"), + "all_time_up_peak_at": all_time.get("all_time_up_peak_at"), + } + + +def load_cache() -> None: + # Notatka: rekordy ogólne są ładowane przy starcie aplikacji, a rekord sesji zaczyna się od zera. + global _LOADED + with _LOCK: + if _LOADED: + return + with connect() as conn: + rows = conn.execute("SELECT * FROM transfer_speed_peaks").fetchall() + for row in rows: + profile_id = int(row.get("profile_id") or 0) + if profile_id: + _CACHE[profile_id] = _empty_peak(profile_id, row) + _LOADED = True + + +def _ensure_profile(profile_id: int) -> dict[str, Any]: + # Notatka: leniwe ładowanie chroni nowe profile dodane po starcie przed pustymi rekordami. + profile_id = int(profile_id) + item = _CACHE.get(profile_id) + if item: + return item + with connect() as conn: + row = conn.execute("SELECT * FROM transfer_speed_peaks WHERE profile_id=?", (profile_id,)).fetchone() + item = _empty_peak(profile_id, row) + _CACHE[profile_id] = item + return item + + +def _persist(item: dict[str, Any]) -> None: + # Notatka: SQLite dostaje zapis tylko wtedy, gdy pojawił się nowy rekord sesji lub rekord ogólny. + now = utcnow() + with connect() as conn: + conn.execute( + """ + INSERT INTO transfer_speed_peaks( + profile_id, session_started_at, session_down_peak, session_up_peak, + session_down_peak_at, session_up_peak_at, all_time_down_peak, + all_time_up_peak, all_time_down_peak_at, all_time_up_peak_at, + created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(profile_id) DO UPDATE SET + session_started_at=excluded.session_started_at, + session_down_peak=excluded.session_down_peak, + session_up_peak=excluded.session_up_peak, + session_down_peak_at=excluded.session_down_peak_at, + session_up_peak_at=excluded.session_up_peak_at, + all_time_down_peak=excluded.all_time_down_peak, + all_time_up_peak=excluded.all_time_up_peak, + all_time_down_peak_at=excluded.all_time_down_peak_at, + all_time_up_peak_at=excluded.all_time_up_peak_at, + updated_at=excluded.updated_at + """, + ( + int(item["profile_id"]), + item["session_started_at"], + int(item["session_down_peak"]), + int(item["session_up_peak"]), + item.get("session_down_peak_at"), + item.get("session_up_peak_at"), + int(item["all_time_down_peak"]), + int(item["all_time_up_peak"]), + item.get("all_time_down_peak_at"), + item.get("all_time_up_peak_at"), + now, + now, + ), + ) + + +def _public(item: dict[str, Any]) -> dict[str, Any]: + # Notatka: frontend dostaje zarówno bajty/s, jak i gotowe etykiety w stylu istniejących prędkości. + return { + "session_started_at": item["session_started_at"], + "session": { + "down": int(item["session_down_peak"]), + "up": int(item["session_up_peak"]), + "down_h": human_rate(int(item["session_down_peak"])), + "up_h": human_rate(int(item["session_up_peak"])), + "down_at": item.get("session_down_peak_at"), + "up_at": item.get("session_up_peak_at"), + }, + "all_time": { + "down": int(item["all_time_down_peak"]), + "up": int(item["all_time_up_peak"]), + "down_h": human_rate(int(item["all_time_down_peak"])), + "up_h": human_rate(int(item["all_time_up_peak"])), + "down_at": item.get("all_time_down_peak_at"), + "up_at": item.get("all_time_up_peak_at"), + }, + } + + +def record(profile_id: int, down_rate: int = 0, up_rate: int = 0) -> dict[str, Any]: + # Notatka: poller wywołuje tę funkcję w tle; baza jest aktualizowana tylko po przebiciu rekordu. + load_cache() + down_rate = max(0, int(down_rate or 0)) + up_rate = max(0, int(up_rate or 0)) + measured_at = utcnow() + changed = False + with _LOCK: + item = _ensure_profile(int(profile_id)) + if down_rate > int(item["session_down_peak"]): + item["session_down_peak"] = down_rate + item["session_down_peak_at"] = measured_at + changed = True + if up_rate > int(item["session_up_peak"]): + item["session_up_peak"] = up_rate + item["session_up_peak_at"] = measured_at + changed = True + if down_rate > int(item["all_time_down_peak"]): + item["all_time_down_peak"] = down_rate + item["all_time_down_peak_at"] = measured_at + changed = True + if up_rate > int(item["all_time_up_peak"]): + item["all_time_up_peak"] = up_rate + item["all_time_up_peak_at"] = measured_at + changed = True + result = _public(item) + if changed: + _persist(item) + return result + + +def current(profile_id: int) -> dict[str, Any]: + # Notatka: REST API może pokazać ostatni znany rekord bez wymuszania nowego pomiaru. + load_cache() + with _LOCK: + return _public(_ensure_profile(int(profile_id))) diff --git a/pytorrent/services/tracker_cache.py b/pytorrent/services/tracker_cache.py new file mode 100644 index 0000000..deaca74 --- /dev/null +++ b/pytorrent/services/tracker_cache.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +import json +import mimetypes +import re +import time +import threading +import ssl +import urllib.error +import urllib.parse +import urllib.request +from html.parser import HTMLParser +from pathlib import Path + +from ..config import BASE_DIR +from ..db import connect, utcnow + +TRACKER_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 +FAVICON_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 +TRACKER_SCAN_LIMIT = 80 +FAVICON_DIR = BASE_DIR / "data" / "tracker_favicons" +PUBLIC_FAVICON_BASE = "/static/tracker_favicons" +_TRACKER_SCAN_LOCKS: dict[int, threading.Lock] = {} +_TRACKER_SCAN_LOCKS_GUARD = threading.Lock() + + +class _IconParser(HTMLParser): + def __init__(self): + super().__init__() + self.icons: list[str] = [] + + def handle_starttag(self, tag: str, attrs): + if tag.lower() != "link": + return + data = {str(k).lower(): str(v or "") for k, v in attrs} + rel = re.sub(r"\s+", " ", data.get("rel", "").lower()).strip() + href = data.get("href", "").strip() + if href and "icon" in rel: + self.icons.append(href) + + +def _now_epoch() -> float: + return time.time() + + +def tracker_domain(url: str) -> str: + raw = str(url or "").strip() + if not raw: + return "" + parsed = urllib.parse.urlparse(raw if "://" in raw else f"http://{raw}") + host = (parsed.hostname or "").lower().strip(".") + if host.startswith("www."): + host = host[4:] + return host + + +def _root_domain(domain: str) -> str: + parts = [p for p in str(domain or "").lower().strip(".").split(".") if p] + if len(parts) <= 2: + return ".".join(parts) + # Note: Tracker favicon discovery needs the real main site first; for t.pte.nu that is pte.nu, not t.pte.nu. + known_second_level_suffixes = {"co", "com", "net", "org", "gov", "edu", "ac"} + if len(parts[-1]) == 2 and parts[-2] in known_second_level_suffixes and len(parts) >= 3: + return ".".join(parts[-3:]) + return ".".join(parts[-2:]) + + +def _safe_filename(domain: str) -> str: + return re.sub(r"[^a-z0-9_.-]+", "_", domain.lower()).strip("._") or "tracker" + + +def _read_cached(profile_id: int, hashes: list[str], ttl: int) -> tuple[dict[str, list[dict]], set[str]]: + if not hashes: + return {}, set() + now = _now_epoch() + cached: dict[str, list[dict]] = {} + fresh: set[str] = set() + with connect() as conn: + for start in range(0, len(hashes), 900): + chunk = hashes[start:start + 900] + placeholders = ",".join("?" for _ in chunk) + rows = conn.execute( + f"SELECT torrent_hash, trackers_json, updated_epoch FROM tracker_summary_cache WHERE profile_id=? AND torrent_hash IN ({placeholders})", + (profile_id, *chunk), + ).fetchall() + for row in rows: + h = str(row.get("torrent_hash") or "") + try: + items = json.loads(row.get("trackers_json") or "[]") + except Exception: + items = [] + cached[h] = items if isinstance(items, list) else [] + if now - float(row.get("updated_epoch") or 0) < ttl: + fresh.add(h) + return cached, fresh + + +def _store(profile_id: int, torrent_hash: str, trackers: list[dict]) -> None: + now = utcnow() + epoch = _now_epoch() + compact = [] + seen = set() + for item in trackers: + domain = tracker_domain(str(item.get("url") or item.get("domain") or "")) or str(item.get("domain") or "") + if not domain or domain in seen: + continue + seen.add(domain) + compact.append({"domain": domain, "url": str(item.get("url") or "")}) + with connect() as conn: + conn.execute( + """ + INSERT INTO tracker_summary_cache(profile_id, torrent_hash, trackers_json, updated_at, updated_epoch) + VALUES(?, ?, ?, ?, ?) + ON CONFLICT(profile_id, torrent_hash) DO UPDATE SET + trackers_json=excluded.trackers_json, + updated_at=excluded.updated_at, + updated_epoch=excluded.updated_epoch + """, + (profile_id, torrent_hash, json.dumps(compact), now, epoch), + ) + + +def summary(profile: dict, hashes: list[str], loader, scan_limit: int = TRACKER_SCAN_LIMIT, include_favicons: bool = False) -> dict: + """Build tracker sidebar data from disk cache and refresh a small batch per request.""" + # Note: Tracker data is cached per torrent hash, so huge rTorrent libraries are never scanned in one UI request. + profile_id = int(profile.get("id") or 0) + clean_hashes = [str(h or "").strip() for h in hashes if str(h or "").strip()] + cached, fresh = _read_cached(profile_id, clean_hashes, TRACKER_CACHE_TTL_SECONDS) + missing = [h for h in clean_hashes if h not in fresh] + errors: list[dict] = [] + scanned_now = 0 + for h in missing[:max(0, int(scan_limit or 0))]: + try: + trackers = loader(h) + _store(profile_id, h, trackers) + cached[h] = [{"domain": tracker_domain(t.get("url") or t.get("domain") or ""), "url": str(t.get("url") or "")} for t in trackers] + fresh.add(h) + scanned_now += 1 + except Exception as exc: + errors.append({"hash": h, "error": str(exc)}) + by_hash: dict[str, list[dict]] = {} + counts: dict[str, dict] = {} + for h in clean_hashes: + items = [] + seen = set() + for item in cached.get(h, []): + domain = tracker_domain(str(item.get("url") or item.get("domain") or "")) or str(item.get("domain") or "") + if not domain or domain in seen: + continue + seen.add(domain) + row = {"domain": domain, "url": str(item.get("url") or "")} + items.append(row) + bucket = counts.setdefault(domain, {"domain": domain, "url": row["url"], "count": 0}) + bucket["count"] += 1 + if not bucket.get("url") and row["url"]: + bucket["url"] = row["url"] + by_hash[h] = items + trackers = sorted(counts.values(), key=lambda x: (-int(x.get("count") or 0), str(x.get("domain") or ""))) + if include_favicons: + # Note: Summary returns only already cached static favicon URLs; network favicon discovery stays outside the hot tracker count path. + for item in trackers: + item["favicon_url"] = favicon_public_url(str(item.get("domain") or ""), enabled=True, create=False) + pending = max(0, len([h for h in clean_hashes if h not in fresh])) + return {"hashes": by_hash, "trackers": trackers, "errors": errors[:25], "scanned": len(clean_hashes), "scanned_now": scanned_now, "pending": pending, "cached": len(clean_hashes) - pending} + + + +def _scan_lock(profile_id: int) -> threading.Lock: + with _TRACKER_SCAN_LOCKS_GUARD: + if profile_id not in _TRACKER_SCAN_LOCKS: + _TRACKER_SCAN_LOCKS[profile_id] = threading.Lock() + return _TRACKER_SCAN_LOCKS[profile_id] + + +def warm_summary_cache(profile: dict, hashes: list[str], loader, batch_size: int = TRACKER_SCAN_LIMIT) -> bool: + """Start a non-blocking tracker cache warmup for large libraries.""" + # Note: Tracker cache warming runs in one background thread per profile, so F5 returns cached data immediately instead of waiting for rTorrent scans. + profile_id = int(profile.get("id") or 0) + clean_hashes = [str(h or "").strip() for h in hashes if str(h or "").strip()] + if not profile_id or not clean_hashes: + return False + lock = _scan_lock(profile_id) + if lock.locked(): + return False + + def _worker(): + if not lock.acquire(blocking=False): + return + try: + while True: + result = summary(profile, clean_hashes, loader, scan_limit=max(1, int(batch_size or TRACKER_SCAN_LIMIT)), include_favicons=False) + if int(result.get("pending") or 0) <= 0 or int(result.get("scanned_now") or 0) <= 0: + break + time.sleep(0.05) + finally: + lock.release() + + threading.Thread(target=_worker, name=f"tracker-cache-warm-{profile_id}", daemon=True).start() + return True + + +def favicon_public_url(domain: str, enabled: bool = True, create: bool = False, force: bool = False) -> str: + """Return the static URL for a cached tracker favicon, optionally creating or refreshing it first.""" + # Note: Favicon files stay in data/tracker_favicons, but the browser loads them via the static/tracker_favicons symlink. + clean = tracker_domain(domain) + if not enabled or not clean: + return "" + if create: + favicon_path(clean, enabled=True, force=force) + cached = _cached_favicon(clean) + now = _now_epoch() + if not cached or now - float(cached.get("updated_epoch") or 0) >= FAVICON_CACHE_TTL_SECONDS: + return "" + path = Path(str(cached.get("file_path") or "")) + if not path.exists() or not path.is_file(): + return "" + try: + rel = path.resolve().relative_to(FAVICON_DIR.resolve()) + except Exception: + rel = Path(path.name) + return f"{PUBLIC_FAVICON_BASE}/{urllib.parse.quote(str(rel).replace(chr(92), '/'))}" + +def _fetch(url: str, limit: int = 262144) -> tuple[bytes, str, str]: + # Note: Favicon discovery uses browser-like headers and a certificate fallback, because tracker login pages/CDNs often reject minimal Python requests. + req = urllib.request.Request( + url, + headers={ + "User-Agent": "Mozilla/5.0 (compatible; pyTorrent favicon fetcher)", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8", + "Connection": "close", + }, + ) + + def _read(context=None): + with urllib.request.urlopen(req, timeout=8, context=context) as resp: + data = resp.read(limit + 1) + if len(data) > limit: + data = data[:limit] + content_type = str(resp.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + final_url = str(resp.geturl() or url) + return data, content_type, final_url + + try: + return _read() + except urllib.error.URLError as exc: + reason = getattr(exc, "reason", None) + if isinstance(reason, ssl.SSLError) or "CERTIFICATE_VERIFY_FAILED" in str(exc): + return _read(ssl._create_unverified_context()) + raise + + +def _is_icon(data: bytes, content_type: str, url: str) -> bool: + """Validate that downloaded bytes are a browser-readable image, not only an image-like HTTP header.""" + # Note: Some trackers serve a broken /favicon.ico with image/vnd.microsoft.icon; pyTorrent now validates bytes before caching it. + if not data or len(data) < 16: + return False + head = data[:32] + lower = data[:512].lstrip().lower() + if head.startswith(b"\x00\x00\x01\x00") or head.startswith(b"\x00\x00\x02\x00"): + try: + count = int.from_bytes(data[4:6], "little") + except Exception: + count = 0 + return 0 < count <= 256 and len(data) >= 6 + (16 * count) + if head.startswith(b"\x89PNG\r\n\x1a\n"): + return True + if head.startswith(b"\xff\xd8\xff"): + return True + if head.startswith((b"GIF87a", b"GIF89a")): + return True + if head.startswith(b"RIFF") and data[8:12] == b"WEBP": + return True + if lower.startswith(b" str: + # Note: Accept quoted and unquoted HTML attributes so favicon discovery works with compact/minified tracker pages. + match = re.search(rf"\b{name}\s*=\s*(['\"])(.*?)\1", tag, re.I | re.S) + if match: + return match.group(2).strip() + match = re.search(rf"\b{name}\s*=\s*([^\s>]+)", tag, re.I | re.S) + return match.group(1).strip().strip("'\"") if match else "" + + +def _extract_icon_hrefs(html: str) -> list[str]: + # Note: Read any order, including shortcut icon and relative CDN paths. + hrefs: list[str] = [] + parser = _IconParser() + try: + parser.feed(html) + hrefs.extend(parser.icons) + except Exception: + pass + for match in re.finditer(r"]*>", html, re.I | re.S): + tag = match.group(0) + rel = _attr_value(tag, "rel").lower() + href = _attr_value(tag, "href") + if href and "icon" in rel: + hrefs.append(href) + clean = [] + seen = set() + for href in hrefs: + href = str(href or "").strip() + if href and href not in seen: + seen.add(href) + clean.append(href) + return clean + + +def _tracker_icon_hosts(domain: str) -> list[str]: + host = tracker_domain(domain) + root = _root_domain(host) + # Note: Direct favicon fallback checks the tracker host first, then the main domain. + return [h for h in dict.fromkeys([host, root]) if h] + + +def _tracker_html_hosts(domain: str) -> list[str]: + host = tracker_domain(domain) + root = _root_domain(host) + # Note: HTML discovery checks the main site first, because tracker announce hosts often return text/plain. + return [h for h in dict.fromkeys([root, host]) if h] + + +def _favicon_candidates(domain: str) -> list[str]: + candidates = [] + for h in _tracker_icon_hosts(domain): + candidates.extend([f"https://{h}/favicon.ico", f"http://{h}/favicon.ico"]) + return list(dict.fromkeys(candidates)) + + +def _html_icon_candidates(domain: str, errors: list[str] | None = None) -> list[str]: + urls = [] + for h in _tracker_html_hosts(domain): + for scheme in ("https", "http"): + base = f"{scheme}://{h}/" + try: + data, ctype, final_url = _fetch(base, limit=524288) + except Exception as exc: + if errors is not None: + errors.append(f"{base}: {exc}") + continue + lower = data[:4096].lower() + if "html" not in ctype and b" tuple[Path | None, str | None]: + clean = tracker_domain(domain) + if not enabled or not clean: + return None, None + cached = _cached_favicon(clean) + now = _now_epoch() + if cached and not force and now - float(cached.get("updated_epoch") or 0) < FAVICON_CACHE_TTL_SECONDS: + path = Path(str(cached.get("file_path") or "")) + mime = str(cached.get("mime_type") or mimetypes.guess_type(path.name)[0] or "image/x-icon") + if path.exists() and path.is_file(): + try: + if _is_icon(path.read_bytes()[:524288], mime, str(cached.get("source_url") or path.name)): + return path, mime + except Exception: + pass + if cached.get("error"): + return None, None + # Note: Favicon lookup checks the main-domain HTML first, then tracker HTML, then direct /favicon.ico fallbacks. + FAVICON_DIR.mkdir(parents=True, exist_ok=True) + errors = [] + candidates = _html_icon_candidates(clean, errors) + _favicon_candidates(clean) + candidates = list(dict.fromkeys(candidates)) + idx = 0 + while idx < len(candidates): + url = candidates[idx] + idx += 1 + try: + data, ctype, final_url = _fetch(url, limit=524288) + if not _is_icon(data, ctype, final_url): + errors.append(f"{url}: invalid icon ({ctype or 'unknown content-type'}, {len(data)} bytes)") + continue + ext = Path(urllib.parse.urlparse(final_url).path).suffix.lower() or mimetypes.guess_extension(ctype) or ".ico" + if ext not in {".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp"}: + ext = ".ico" + path = FAVICON_DIR / f"{_safe_filename(clean)}{ext}" + path.write_bytes(data) + mime = ctype if ctype.startswith("image/") else (mimetypes.guess_type(path.name)[0] or "image/x-icon") + with connect() as conn: + conn.execute( + """ + INSERT INTO tracker_favicon_cache(domain, source_url, file_path, mime_type, updated_at, updated_epoch, error) + VALUES(?, ?, ?, ?, ?, ?, NULL) + ON CONFLICT(domain) DO UPDATE SET + source_url=excluded.source_url, + file_path=excluded.file_path, + mime_type=excluded.mime_type, + updated_at=excluded.updated_at, + updated_epoch=excluded.updated_epoch, + error=NULL + """, + (clean, final_url, str(path), mime, utcnow(), now), + ) + return path, mime + except Exception as exc: + errors.append(f"{url}: {exc}") + # HTML is checked once before direct /favicon.ico probes; do not guess cdn/static/www hosts unless HTML points there. + with connect() as conn: + conn.execute( + """ + INSERT INTO tracker_favicon_cache(domain, source_url, file_path, mime_type, updated_at, updated_epoch, error) + VALUES(?, '', '', '', ?, ?, ?) + ON CONFLICT(domain) DO UPDATE SET + updated_at=excluded.updated_at, + updated_epoch=excluded.updated_epoch, + error=excluded.error + """, + (clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"), + ) + return None, None diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index a2a3ada..830ce8f 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -7,7 +7,7 @@ from ..config import POLL_INTERVAL from .preferences import active_profile, get_profile from .torrent_cache import torrent_cache from .torrent_summary import cached_summary -from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth +from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks def _profile_room(profile_id: int) -> str: @@ -59,6 +59,8 @@ def register_socketio_handlers(socketio): status["usage_available"] = True status["profile_id"] = pid traffic_history.record(pid, status.get("down_rate", 0), status.get("up_rate", 0), status.get("total_down", 0), status.get("total_up", 0)) + # Notatka: najwyższe DL/UL są liczone w tle razem z istniejącym pollerem i zapisywane tylko po przebiciu rekordu. + status["speed_peaks"] = speed_peaks.record(pid, status.get("down_rate", 0), status.get("up_rate", 0)) _emit_profile(socketio, "system_stats", status, pid) heartbeat["ok"] = True except Exception as exc: @@ -73,8 +75,8 @@ def register_socketio_handlers(socketio): result = smart_queue.check(profile, force=False) if result.get("enabled"): _emit_profile(socketio, "smart_queue_update", result, pid) - if result.get("paused") or result.get("resumed") or result.get("resume_requested"): - # Note: After Smart Queue changes, refresh cache immediately so the Downloading list does not wait for the next poller cycle. + if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"): + # Note: Note: After Smart Queue STOP/START changes, refresh cache immediately so the Downloading list does not wait for the next poller cycle. queue_diff = torrent_cache.refresh(profile) if queue_diff.get("ok"): _emit_profile(socketio, "torrent_patch", {**queue_diff, "summary": cached_summary(pid, torrent_cache.snapshot(pid), force=True)}, pid) diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 58acfd7..05bc0de 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -5,6 +5,15 @@ const torrents = new Map(); let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = "all"; let sortState = {key: "name", dir: 1}, renderPending = false, renderVersion = 0, lastRenderSignature = ""; + const MOBILE_SORT_STEPS = [ + {key:"down_rate", dir:-1, label:"DL"}, + {key:"up_rate", dir:-1, label:"UL"}, + {key:"progress", dir:-1, label:"Progress"}, + {key:"ratio", dir:-1, label:"Ratio"}, + {key:"size", dir:-1, label:"Size"}, + {key:"seeds", dir:-1, label:"Seeds"}, + {key:"name", dir:1, label:"Name"} + ]; let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = "/"; const traffic = [], systemUsage = []; const socket = io({transports:["polling"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}); @@ -12,15 +21,28 @@ let hiddenColumns = new Set((window.PYTORRENT?.tableColumns?.hidden || [])); let knownLabels = []; let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false; + let automationSmartQueueStats = null; let peersRefreshTimer = null; let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0); let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0); let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || "default"; let fontFamily = window.PYTORRENT?.fontFamily || "default"; + let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100); + let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0); + let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0); + let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]}; + let trackerSummaryStatus = 'idle'; + let trackerSummarySignature = ""; + let trackerSummaryTimer = null; + let lastLabelFiltersSignature = ""; + let lastTrackerFiltersSignature = ""; + let lastMobileFiltersSignature = ""; + const BASE_TITLE = document.title || "pyTorrent"; + const lastBrowserSpeed = {down: "0 B/s", up: "0 B/s"}; const FOOTER_ITEM_DEFS = [ ["cpu", "CPU"], ["ram", "RAM"], ["usage_chart", "CPU/RAM chart"], ["disk", "Disk"], ["version", "rTorrent version"], ["speed_down", "Download speed"], ["speed_up", "Upload speed"], - ["limits", "Speed limits"], ["totals", "Total transfer"], ["port_check", "Port check"], + ["speed_peaks", "Peak speeds"], ["limits", "Speed limits"], ["totals", "Total transfer"], ["port_check", "Port check"], ["clock", "Clock"], ["sockets", "Open sockets"], ["shown", "Shown torrents"], ["selected", "Selected torrents"], ["docs", "API docs"] ]; let footerItems = {...Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, true])), ...(window.PYTORRENT?.footerItems || {})}; @@ -188,11 +210,16 @@ function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); } function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); } function rowHasLabel(t,label){ return labelNames(t.label).includes(label); } + function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; } + function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); } function torrentHasError(t){ return !!torrentWarning(t); } function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; } - function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && ![t.name,t.path,t.label,t.hash,t.ratio_group].join(' ').toLowerCase().includes(q)) return false; if(activeFilter==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return isChecking(t); if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state && !isChecking(t); if(activeFilter==='moving') { const op=activeOperationFor(t); return op?.action==='move' && op?.state==='running'; } if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); return true; } + function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && ![t.name,t.path,t.label,t.hash,t.ratio_group].join(' ').toLowerCase().includes(q)) return false; if(activeFilter==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return isChecking(t); if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state && !isChecking(t); if(activeFilter==='moving') { const op=activeOperationFor(t); return op?.action==='move' && op?.state==='running'; } if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('tracker:')) return rowHasTracker(t,activeFilter.slice(8)); return true; } function compareRows(a,b){ const k=sortState.key; let av=a[k], bv=b[k]; if(typeof av==='string'||typeof bv==='string') return String(av||'').localeCompare(String(bv||''))*sortState.dir; return ((Number(av||0)>Number(bv||0))?1:(Number(av||0)0?" ":" "; } + function mobileSortDef(){ return MOBILE_SORT_STEPS.find(x=>x.key===sortState.key && x.dir===sortState.dir) || MOBILE_SORT_STEPS.find(x=>x.key===sortState.key) || MOBILE_SORT_STEPS[0]; } + function mobileSortLabel(){ const def=mobileSortDef(); return `${def.label} ${sortState.dir>0?'↑':'↓'}`; } + function cycleMobileSort(){ const current=MOBILE_SORT_STEPS.findIndex(x=>x.key===sortState.key && x.dir===sortState.dir); const next=MOBILE_SORT_STEPS[(current+1) % MOBILE_SORT_STEPS.length]; sortState={key:next.key, dir:next.dir}; if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); } function updateSortHeaders(){ document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{ const base=th.dataset.baseText||th.textContent.trim(); th.dataset.baseText=base; th.innerHTML=`${esc(base)}${sortIcon(th.dataset.sort)}`; th.classList.toggle('sorted',sortState.key===th.dataset.sort); }); } // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation. function syncFilterButtons(){ @@ -206,7 +233,91 @@ Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary); $('statSelected').textContent=selected.size; } - function renderLabelFilters(){ const box=$('labelFilters'); if(!box) return; const counts=new Map(); [...torrents.values()].forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b)); if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))) activeFilter='all'; box.innerHTML=labels.length?`
Labels
${labels.map(l=>``).join('')}`:''; box.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); } + function bindSidebarFilterClicks(root){ + root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{ + document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); + b.classList.add('active'); + activeFilter=b.dataset.filter; + if($('tableWrap')) $('tableWrap').scrollTop=0; + scheduleRender(true); + })); + } + function renderLabelFilters(force=false){ + const box=$('labelFilters'); + if(!box) return; + const counts=new Map(); + [...torrents.values()].forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); + const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b)); + if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))) activeFilter='all'; + const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|'); + if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; } + lastLabelFiltersSignature=sig; + box.innerHTML=labels.length?`
Labels
${labels.map(l=>``).join('')}`:''; + bindSidebarFilterClicks(box); + } + function trackerFavicon(tracker){ + const domain=typeof tracker==='string'?tracker:(tracker?.domain||''); + if(!trackerFaviconsEnabled || !domain) return ''; + // Note: Normal rendering must use cached/static URLs only. Avoid refresh=1 here, otherwise scroll-triggered paints can re-warm icons repeatedly. + const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}`; + const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback; + return ``; + } + function trackerFilterPlaceholder(){ + if(trackerSummaryStatus==='loading') return '
Loading cached trackers...
'; + if(trackerSummaryStatus==='error') return '
Tracker list unavailable
'; + if(Number(trackerSummary.pending||0)) return `
Tracker cache: ${esc(trackerSummary.cached||0)}/${esc(trackerSummary.scanned||0)}
`; + if(hasTorrentSnapshot && torrents.size) return '
No trackers found
'; + return '
Waiting for torrents...
'; + } + function renderTrackerFilters(force=false){ + const box=$('trackerFilters'); + if(!box) return; + const trackers=trackerSummary.trackers || []; + if(activeFilter.startsWith('tracker:') && !trackers.some(t=>t.domain===activeFilter.slice(8))) activeFilter='all'; + const sig=[ + trackerSummaryStatus, + trackerFaviconsEnabled ? 1 : 0, + trackerSummary.pending || 0, + trackerSummary.cached || 0, + trackerSummary.scanned || 0, + trackers.map(t=>`${t.domain}:${t.count||0}:${t.favicon_url||''}`).join('|') + ].join('::'); + if(!force && sig===lastTrackerFiltersSignature){ syncFilterButtons(); return; } + lastTrackerFiltersSignature=sig; + // Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature. + const rows=trackers.length + ? trackers.map(t=>``).join('') + : trackerFilterPlaceholder(); + box.innerHTML=`
Trackers
${rows}`; + bindSidebarFilterClicks(box); + } + async function refreshTrackerSummary(force=false){ + const hashes=[...torrents.keys()].sort(); + const sig=`${hashes.length}:${hashes[0]||''}:${hashes[hashes.length-1]||''}:${trackerFaviconsEnabled?1:0}`; + if(!force && sig===trackerSummarySignature && !Number(trackerSummary.pending||0)) return; + trackerSummarySignature=sig; + if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; } + trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'loading'; + renderTrackerFilters(); + try{ + // Note: Nie wysyłamy 13k hashy w URL; backend bierze lokalny snapshot i doczytuje cache małymi porcjami. + const j=await (await fetch('/api/trackers/summary?scan_limit=0&warm=1&bg_limit=80')).json(); + if(!j.ok && !j.summary) throw new Error(j.error||'Tracker summary failed'); + trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; + trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':Number(trackerSummary.pending||0)?'empty':'empty'; + renderTrackerFilters(); + scheduleRender(true); + if(Number(trackerSummary.pending||0)>0){ + clearTimeout(trackerSummaryTimer); + trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(true).catch(()=>{}), 5000); + } + }catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); } + } + function scheduleTrackerSummary(force=false){ + clearTimeout(trackerSummaryTimer); + trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(force).catch(()=>{}), force?50:600); + } function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; } function applyColumnVisibility(){ document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col))); } function actionLabel(action){ @@ -248,9 +359,22 @@ function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; } function torrentNameIcon(t){ const m=statusMeta(t); return ``; } function renderRow(t){ const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' '); const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\n'); return `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}${statusBadge(t)}${esc(t.size_h)}${progress(t)}${esc(t.down_rate_h)}${esc(t.up_rate_h)}${esc(t.seeds)}${esc(t.peers)}${esc(t.ratio)}${esc(t.path)}${labels||'-'}${esc(t.ratio_group||'')}`; } - function mobileFilterDefs(){ const arr=[...torrents.values()]; const f=torrentSummary?.filters||{}; const defs=[['all','All',f.all?.count??0],['downloading','Downloading',f.downloading?.count??0],['seeding','Seeding',f.seeding?.count??0],['paused','Paused',f.paused?.count??0],['checking','Checking',f.checking?.count??0],['error','With error',f.error?.count??0],['stopped','Stopped',f.stopped?.count??0]]; const movingCount=movingFilterCount(); if(movingCount) defs.push(['moving','Moving',movingCount]); const counts=new Map(); arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label'])); return defs; } - function renderMobileFilters(){ const bar=$('mobileFilterBar'); if(!bar) return; const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); const someVisible=visibleRows.some(t=>selected.has(t.hash)); const opts=mobileFilterDefs().map(([key,label,count,type])=>``).join(''); bar.innerHTML=`
${selected.size} selected
`; } - function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); return `
${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
${statusBadge(t)} · ${esc(t.progress)}% · Ratio ${esc(t.ratio)}
DL ${esc(t.down_rate_h)} / UL ${esc(t.up_rate_h)}
${esc(t.path)}
${progress(t)}
`; }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...')); } + function mobileFilterDefs(){ const arr=[...torrents.values()]; const f=torrentSummary?.filters||{}; const defs=[['all','All',f.all?.count??0],['downloading','Downloading',f.downloading?.count??0],['seeding','Seeding',f.seeding?.count??0],['paused','Paused',f.paused?.count??0],['checking','Checking',f.checking?.count??0],['error','With error',f.error?.count??0],['stopped','Stopped',f.stopped?.count??0]]; const movingCount=movingFilterCount(); if(movingCount) defs.push(['moving','Moving',movingCount]); const counts=new Map(); arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label'])); (trackerSummary.trackers||[]).forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker'])); return defs; } + function renderMobileFilters(){ + const bar=$('mobileFilterBar'); + if(!bar) return; + const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); + const someVisible=visibleRows.some(t=>selected.has(t.hash)); + const defs=mobileFilterDefs(); + const sig=[activeFilter, sortState.key, sortState.dir, selected.size, allVisible ? 1 : 0, someVisible ? 1 : 0, defs.map(d=>`${d[0]}:${d[2]}`).join('|')].join('::'); + if(sig===lastMobileFiltersSignature) return; + lastMobileFiltersSignature=sig; + const opts=defs.map(([key,label,count,type])=>``).join(''); + const bulk=selected.size?``:''; + // Note: Mobile bulk actions reuse the existing label modal and move picker, so desktop behavior stays unchanged. + bar.innerHTML=`
${bulk}${selected.size} selected
`; + } + function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); return `
${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
${statusBadge(t)} · ${esc(t.progress)}% · Ratio ${esc(t.ratio)}
DL ${esc(t.down_rate_h)} / UL ${esc(t.up_rate_h)}
${esc(t.path)}
${progress(t)}
`; }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...')); } function renderTable(){ updateBulkBar(); renderCounts(); renderLabelFilters(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?'No torrents for this filter.':loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/ROW_HEIGHT)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/ROW_HEIGHT)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*ROW_HEIGHT,bottom=Math.max(0,(visibleRows.length-end)*ROW_HEIGHT); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); } function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); } function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); } @@ -261,7 +385,8 @@ async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; } - function table(headers,rows){ return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; } + function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; } + function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; } function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); } function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>` ${esc(l)}`).join(' '):''; $('detailPane').innerHTML=t?`
Name${esc(t.name)}
Hash${esc(t.hash)}
Path${esc(t.path)}
Size${esc(t.size_h)}
Progress${esc(t.progress)}%
Ratio${esc(t.ratio)}
Downloaded${esc(t.down_total_h)}
Uploaded${esc(t.up_total_h)}
Labels${labels||'-'}
Ratio group${esc(t.ratio_group||'')}
`:'Select a torrent.'; } const FILE_PRIORITY_LABELS = {0: "Skip", 1: "Normal", 2: "High"}; @@ -377,7 +502,7 @@ if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; - box.innerHTML=table( + box.innerHTML=responsiveTable( ['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'], rows.map(r=>[ `${esc(r.status)}`, @@ -390,9 +515,9 @@ humanDateCell(r.finished_at), compactCell(r.error||'',140), jobActions(r), - ]) + ]), + 'jobs-table' ); - box.querySelector('table')?.classList.add('jobs-table'); renderJobsPager(); } function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); } @@ -415,9 +540,75 @@ $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value}); loadRatios(); }); async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[]; if($('rssManager')) $('rssManager').innerHTML=`
Feeds
${table(['Name','URL','Last error'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.last_error||'')]))}
Rules
${table(['Name','Pattern','Path','Label'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.save_path),esc(r.label)]))}`; } - async function loadSmartQueue(){ if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); const historyLimit=smartHistoryExpanded?100:10; const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); if(!j.ok) return; const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; const totalHistory=Number(j.history_total ?? hist.length); if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; if($('smartManageStopped')) $('smartManageStopped').checked=!!st.manage_stopped; if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; if($('smartManager')) $('smartManager').innerHTML=ex.length?table(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``])):'
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
'; if($('smartHistory')) { const body=hist.length?table(['Time','Event','Checked','Paused','Resumed'],hist.map(h=>[dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(h.paused_count||0),esc(h.resumed_count||0)])):'
No Smart Queue operations yet.
'; const canToggle=totalHistory>10; const toggle=canToggle?``:''; $('smartHistory').innerHTML=`${body}${toggle}`; } } + function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } } + function smartQueueToastMessage(r){ const noEffect=r.start_no_effect?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSeedPeer=(r.ignore_seed_peer||r.settings?.ignore_seed_peer)?Number(r.ignored_seed_peer_count||0):0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSeedTail=(r.ignore_seed_peer||r.settings?.ignore_seed_peer)?`, ignored missing seeds/peers ${ignoredSeedPeer}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=stopFailed?`, stop failed ${stopFailed}`:''; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSeedTail}${ignoredSpeedTail}${failTail}${cap}`; } + function buildSmartQueueNerdStats(hist=[], totalHistory=0){ + // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior. + const stats=hist.reduce((acc,h)=>{ + const details=smartHistoryDetails(h); + const stopped=Number(h.paused_count||0); + const started=Number(h.resumed_count||0); + const checked=Number(h.checked_count||0); + const over=Number(details.over_limit||0); + const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0; + acc.checked += checked; + acc.stopped += stopped; + acc.started += started; + acc.overLimit += over; + acc.stopFailed += stopFailed; + if(over>0) acc.overEvents += 1; + return acc; + },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0}); + const latest=hist[0]||null; + return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:latest?.event||'-',latestAt:latest?.created_at||''}; + } + + function renderSmartQueueNerdStats(stats){ + // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table. + if(!stats) return '
No Smart Queue stats yet.
'; + const cards=[ + ['Runs',stats.total,`${stats.sample} loaded`], + ['Checked',stats.checked,'torrent scans'], + ['Stopped',stats.stopped,'queue trims'], + ['Started',stats.started,'queue fills'], + ['Over limit',stats.overEvents,`${stats.overLimit} total over`], + ['Stop failed',stats.stopFailed,'rTorrent rejects'], + ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'], + ]; + return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`; + } + async function loadSmartQueue(){ + if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); + if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); + const historyLimit=smartHistoryExpanded?100:10; + const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); + if(!j.ok) return; + const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; + const totalHistory=Number(j.history_total ?? hist.length); + if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; + if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; + if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; + if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); + if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; + if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; + if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer; + if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed; + if($('smartManager')){ + $('smartManager').innerHTML=ex.length + ? responsiveTable(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table') + : '
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
'; + } + if($('smartHistory')){ + const body=hist.length + ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Started','Stop failed'],hist.map(h=>{ const d=smartHistoryDetails(h); return [dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(d.active_before??'-'),esc(d.max_active_downloads??'-'),esc(d.over_limit??0),esc(h.paused_count||0),esc(h.resumed_count||0),esc((d.stop_failed||[]).length||0)]; }),'smart-history-table') + : '
No Smart Queue operations yet.
'; + const canToggle=totalHistory>10; + const toggle=canToggle?``:''; + $('smartHistory').innerHTML=`${body}${toggle}`; + } + } async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } - async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,manage_stopped:$('smartManageStopped')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value}); toast('Smart Queue saved','success'); await loadSmartQueue(); } + async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked}); toast('Smart Queue saved','success'); await loadSmartQueue(); } async function loadAuthUsers(){ if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return; @@ -551,7 +742,10 @@ function bootstrapThemeUrl(theme){ /* Notatka: motywy korzystają z mapy URL wygenerowanej przez backend, więc działają także offline. */ const key=theme||"default"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || ""; } function applyBootstrapTheme(theme){ bootstrapTheme = theme || "default"; const link=$("bootstrapThemeStylesheet"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($("bootstrapThemeSelect")) $("bootstrapThemeSelect").value = bootstrapTheme; } function applyFontFamily(font){ fontFamily = font || "default"; document.documentElement.dataset.appFont = fontFamily; if($("fontFamilySelect")) $("fontFamilySelect").value = fontFamily; } - async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } } + function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); } + 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); } + async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); applyInterfaceScale($("interfaceScaleRange")?.value || interfaceScale); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } } + if($("titleSpeedEnabled")) $("titleSpeedEnabled").checked=titleSpeedEnabled; function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } } function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia("(max-width: 900px)").matches; document.body.classList.toggle("mobile-mode", auto || document.body.classList.contains("mobile-mode-manual")); scheduleRender(true); } @@ -672,8 +866,8 @@ if(!$('automationHistory')) return; const toolbar='
'; const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]); - // Note: Automation history time is now human-readable and wider, while the table still wraps on small screens. - const body=hist.length?`
${table(['Time','Rule','Torrent / batch','Actions'],rows).replace('detail-table','detail-table automation-history-table')}
`:'
No automation history yet.
'; + // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals. + const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
'; $('automationHistory').innerHTML=toolbar+body; } @@ -698,7 +892,7 @@ } async function loadAutomations(){ - const j=await (await fetch('/api/automations')).json(); + const j=await fetch('/api/automations').then(r=>r.json()); const rules=j.rules||[], hist=j.history||[]; automationRulesCache=rules; if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{ @@ -796,6 +990,53 @@ try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); } catch(e){ toast(e.message,'danger'); } } + function compactSpeedText(value){ + // Notatka: stopka ma ograniczone miejsce, więc usuwa spację tylko z etykiet prędkości. + return String(value || '0 B/s').replace(/\s+(?=[KMGT]?i?B\/s$|B\/s$)/, ''); + } + function speedPairText(down, up){ + // Notatka: spójny zapis pary DL/UL jest używany w stopce i diagnostyce. + return `${compactSpeedText(down)} / ${compactSpeedText(up)}`; + } + function peakDateText(value){ + // Notatka: skraca ISO timestamp z bazy do czytelnej etykiety w podpowiedzi. + return value ? String(value).replace('T',' ').replace(/\+00:00$/, ' UTC') : '-'; + } + function updateSpeedPeaks(peaks={}){ + // Notatka: prezentuje rekord sesji i rekord ogólny obok bieżących prędkości w stopce. + const session=peaks.session||{}; + const allTime=peaks.all_time||{}; + const sessionText=speedPairText(session.down_h, session.up_h); + const allTimeText=speedPairText(allTime.down_h, allTime.up_h); + if($('statPeakSession')) $('statPeakSession').textContent=sessionText; + if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText; + const box=$('statusSpeedPeaks'); + if(box){ + box.title=`Peak speed DL/UL\nSession: ${sessionText}\nSession DL at: ${peakDateText(session.down_at)}\nSession UL at: ${peakDateText(session.up_at)}\nAll-time: ${allTimeText}\nAll-time DL at: ${peakDateText(allTime.down_at)}\nAll-time UL at: ${peakDateText(allTime.up_at)}`; + } + } + function updateBrowserSpeedTitle(downH, upH){ + // Notatka: w stylu ruTorrent pokazuje DL/UL w tytule karty; window.status jest próbą dla starszych przeglądarek. + if(downH != null) lastBrowserSpeed.down=downH || '0 B/s'; + if(upH != null) lastBrowserSpeed.up=upH || '0 B/s'; + const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`; + document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE; + try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){} + } + async function saveTitleSpeedPreference(){ + // Notatka: zmiana działa od razu i jest zapisywana jako preferencja użytkownika. + titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked; + updateBrowserSpeedTitle(); + try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); } + catch(e){ toast(e.message,'danger'); } + } + async function saveTrackerFaviconsPreference(){ + // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched. + trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked; + renderTrackerFilters(); + try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); } + catch(e){ toast(e.message,'danger'); } + } function updateFooterClock(){ const el=$('statClock'); if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); @@ -828,29 +1069,36 @@ } if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id="statusPortCheckBadge" ',true); } - async function loadPreferences(){ if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); } + async function loadPreferences(){ if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); } async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } } async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } } async function loadAppStatus(){ const box=$('appStatusManager'); if(!box) return; box.innerHTML=' Loading diagnostics...'; try{ - const j=await (await fetch('/api/app/status')).json(); + const [j,smart]=await Promise.all([ + fetch('/api/app/status').then(r=>r.json()), + fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})) + ]); if(!j.ok) throw new Error(j.error||'Failed to load diagnostics'); const st=j.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}; + const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{}; + const smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null; const cards=[ diagCard('pyTorrent PID', py.pid), diagCard('pyTorrent uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Jobs total', py.jobs_total), diagCard('Worker threads', py.worker_threads), diagCard('Python', py.python||'-'), diagCard('DB size', db.size_h||'-'), diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), + diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h)), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-'), diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-') ]; - box.innerHTML=`
${cards.join('')}
${scgi.error?`
${esc(scgi.error)}
`:''}`; - }catch(e){ box.innerHTML=`
${esc(e.message)}
`; } + const smartBlock=`
Smart Queue statistics
${renderSmartQueueNerdStats(smartStats)}`; + box.innerHTML=`
${cards.join('')}
${smartBlock}${scgi.error?`
${esc(scgi.error)}
`:''}`; + }catch(e){ box.innerHTML=`
${esc(e.message)}
`; } } function torrentStatsCard(label, value, note=''){ @@ -885,7 +1133,7 @@ }catch(e){ box.innerHTML=`
${esc(e.message)}
`; } } - $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();loadAuthUsers();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences(); if(tool==='users') loadAuthUsers();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(del && confirm('Delete user?')){ await fetch(`/api/auth/users/${del.dataset.id}`,{method:'DELETE'}); loadAuthUsers(); } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); const noEffect=r.start_no_effect?.length||0; const requested=r.resume_requested?.length||0; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}${tail}${waitTail}${stalledTail}${cap}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');}); + $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();loadAuthUsers();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences(); if(tool==='users') loadAuthUsers();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(del && confirm('Delete user?')){ await fetch(`/api/auth/users/${del.dataset.id}`,{method:'DELETE'}); loadAuthUsers(); } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');}); $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toast(`Automations applied ${torrents} torrent(s) in ${batches} batch(es)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); }); document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} }); $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);}); @@ -895,9 +1143,9 @@ document.addEventListener('change',e=>{ const sel=e.target.closest('#mobileFilterSelect'); if(!sel)return; activeFilter=sel.value; document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter===activeFilter)); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); }); function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); } - document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.filter').forEach(x=>{ if(x.dataset.filter===mobileFilter.dataset.filter) x.classList.add('active'); }); activeFilter=mobileFilter.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=h; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); }); + document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.filter').forEach(x=>{ if(x.dataset.filter===mobileFilter.dataset.filter) x.classList.add('active'); }); activeFilter=mobileFilter.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=h; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); }); document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; }); - document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences); + document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences); document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s')runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); }); $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();}); $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true)); @@ -1057,6 +1305,32 @@ ${disk.error}`:''}`; b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary"); loadTrafficHistory(b.dataset.range||"7d"); })); - socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);hideInitialLoader();}); socket.on('torrent_patch',patchRows); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ const noEffect=msg.start_no_effect?.length||0; const requested=msg.resume_requested?.length||0; const cap=msg.rtorrent_cap?.updated?`, cap ${msg.rtorrent_cap.current}->${msg.rtorrent_cap.new}`:''; const waiting=msg.waiting_labeled||0; const stalled=msg.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: paused ${msg.paused?.length||0}, resumed ${msg.resumed?.length||0}${tail}${waitTail}${stalledTail}${cap}`,'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{ const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined; $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);$('statRamBox')?.classList.toggle('d-none',!usageAvailable);$('systemChart')?.classList.toggle('d-none',!usageAvailable); if(usageAvailable){$('statCpu').textContent=s.cpu??'-';$('statRam').textContent=s.ram??'-';drawSystemUsage(s.cpu,s.ram);} $('statVersion').textContent=s.version||'-';$('statDl').textContent=s.down_rate_h||'0 B/s';$('statUl').textContent=s.up_rate_h||'0 B/s';if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};$('statDlLimit').textContent=s.down_limit_h||'∞';$('statUlLimit').textContent=s.up_limit_h||'∞';$('statTotalDl').textContent=compactTransferText(s.total_down_h);$('statTotalUl').textContent=compactTransferText(s.total_up_h);drawTraffic(s.down_rate,s.up_rate);drawDiskUsage(s.disk);updateSocketStatus(s);applyFooterPreferences();}); - updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); + socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{patchRows(msg);scheduleTrackerSummary(false);}); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{ + const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined; + $('statCpuBox')?.classList.toggle('d-none',!usageAvailable); + $('statRamBox')?.classList.toggle('d-none',!usageAvailable); + $('systemChart')?.classList.toggle('d-none',!usageAvailable); + if(usageAvailable){ + $('statCpu').textContent=s.cpu??'-'; + $('statRam').textContent=s.ram??'-'; + drawSystemUsage(s.cpu,s.ram); + } + $('statVersion').textContent=s.version||'-'; + $('statDl').textContent=s.down_rate_h||'0 B/s'; + $('statUl').textContent=s.up_rate_h||'0 B/s'; + if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s'; + if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s'; + lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)}; + $('statDlLimit').textContent=s.down_limit_h||'∞'; + $('statUlLimit').textContent=s.up_limit_h||'∞'; + $('statTotalDl').textContent=compactTransferText(s.total_down_h); + $('statTotalUl').textContent=compactTransferText(s.total_up_h); + updateSpeedPeaks(s.speed_peaks||{}); + updateBrowserSpeedTitle(s.down_rate_h||'0 B/s', s.up_rate_h||'0 B/s'); + drawTraffic(s.down_rate,s.up_rate); + drawDiskUsage(s.disk); + updateSocketStatus(s); + applyFooterPreferences(); + }); + updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); scheduleTrackerSummary(true); })(); diff --git a/pytorrent/static/favicon.svg b/pytorrent/static/favicon.svg new file mode 100644 index 0000000..5f52965 --- /dev/null +++ b/pytorrent/static/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 2fb5889..934d835 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1,9 +1,10 @@ :root { --app-font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; - --topbar: 50px; - --statusbar: 34px; - --sidebar: 270px; + --ui-scale: 1; + --topbar: calc(50px * var(--ui-scale)); + --statusbar: calc(34px * var(--ui-scale)); + --sidebar: calc(270px * var(--ui-scale)); --torrent-progress-complete: #198754; } [data-bs-theme="dark"] { @@ -49,13 +50,13 @@ body { } body { overflow: hidden; - font-size: 13px; - padding: 8px; + font-size: calc(13px * var(--ui-scale)); + padding: calc(8px * var(--ui-scale)); background: #05070a; font-family: var(--app-font-family); } .app-shell { - height: calc(100vh - 16px); + height: calc(100vh - (16px * var(--ui-scale))); display: grid; grid-template-rows: var(--topbar) 1fr var(--statusbar); background: var(--bs-body-bg); @@ -126,6 +127,11 @@ body { color: var(--bs-body-color); font-weight: 700; } +.mobile-speed-stats span { + display: inline-flex; + align-items: center; + gap: 0.18rem; +} .topbar .form-control, .topbar .form-select { height: 32px; @@ -218,8 +224,9 @@ body { display: grid; grid-template-columns: var(--sidebar) 1fr; } +/* Note: Sidebar filters are denser so large tracker lists fit better on one screen. */ .sidebar { - padding: 0.65rem; + padding: 0.5rem; overflow: auto; background: rgba(var(--bs-secondary-bg-rgb), 0.9); } @@ -227,10 +234,10 @@ body { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 0.15rem 0.55rem; + gap: 0.1rem 0.45rem; align-items: center; - margin-bottom: 0.2rem; - padding: 0.45rem 0.6rem; + margin-bottom: 0.12rem; + padding: 0.34rem 0.5rem; border: 0; border-radius: 0.55rem; background: transparent; @@ -379,6 +386,28 @@ body { .detail-table { white-space: nowrap; } +.responsive-table-wrap { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + -webkit-overflow-scrolling: touch; +} +.responsive-table-wrap .detail-table { + margin-bottom: 0; +} +.smart-exclusions-table { + min-width: 680px; +} +.smart-history-table { + min-width: 760px; + table-layout: fixed; +} +.smart-history-table th, +.smart-history-table td { + overflow-wrap: anywhere; + white-space: normal; +} .general-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -413,6 +442,11 @@ body { .statusbar b { color: var(--bs-body-color); } +.speed-peaks { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} .status-limit { border: 1px solid var(--bs-border-color); background: rgba(var(--bs-secondary-bg-rgb), 0.9); @@ -625,18 +659,6 @@ body { :root { --topbar: 132px; } - .toolbar-right { - width: 100%; - justify-content: flex-start; - flex-wrap: nowrap; - gap: 0.35rem; - } - .search { - flex: 1 1 0; - width: auto; - min-width: 0; - max-width: none; - } .preset-grid { grid-template-columns: 1fr 1fr; } @@ -657,6 +679,35 @@ body { font-weight: 700; text-transform: uppercase; } +/* Note: Browser title speed preference uses a two-column switch layout, so text aligns with the switch. */ +.browser-speed-pref { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + column-gap: 0.75rem; + row-gap: 0.2rem; + min-height: 58px; + margin: 0; + padding: 0.55rem 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.browser-speed-pref .form-check-input { + grid-row: 1 / span 2; + grid-column: 1; + float: none; + margin: 0; +} +.browser-speed-pref .form-check-label { + grid-column: 2; + line-height: 1.2; +} +.browser-speed-pref small { + grid-column: 2; + color: var(--bs-secondary-color); + line-height: 1.2; +} @media (max-width: 640px) { .preferences-grid { grid-template-columns: 1fr; @@ -737,6 +788,7 @@ body { } .mobile-actions { display: flex; + flex-wrap: wrap; gap: 0.35rem; margin-top: 0.45rem; } @@ -763,7 +815,7 @@ body.mobile-mode #mobileList { overflow: auto; position: relative; z-index: 2; - padding-top: 5.2rem !important; + padding-top: 7.1rem !important; padding-bottom: 1rem; } body.mobile-mode .content { @@ -790,6 +842,18 @@ body.mobile-mode .main-grid { width: 110px; white-space: nowrap; } + +.mobile-sort-row { + display: flex; + margin-top: 0.4rem; + justify-content: flex-end; + gap: 0.5rem; +} +.mobile-sort-row .btn { + width: 100%; + justify-content: center; +} + .hidden-col { display: none !important; } @@ -811,15 +875,52 @@ body.mobile-mode .main-grid { border-radius: 0.5rem; background: var(--bs-body-bg); } -.label-filters .label-filter { - font-size: 0.82rem; - padding: 0.34rem 0.5rem; - margin-bottom: 0.15rem; +.label-filters .label-filter, +.tracker-filters .tracker-filter { + font-size: 0.78rem; + margin-bottom: 0.08rem; + padding: 0.26rem 0.44rem; } -.label-filters .label-filter i { +.label-filters .label-filter i, +.tracker-filters .tracker-filter i { opacity: 0.75; margin-right: 0.25rem; } + +.tracker-filters .tracker-filter span:first-child { + align-items: center; + display: inline-flex; + gap: 0.35rem; + min-width: 0; +} + +.tracker-favicon { + border-radius: 0.2rem; + flex: 0 0 auto; + height: 14px; + object-fit: contain; + width: 14px; +} + +.tracker-favicon:not(.d-none) + .tracker-fallback-icon { + display: none; +} + +.tracker-filter-empty { + align-items: center; + color: var(--bs-secondary-color); + display: flex; + font-size: 0.76rem; + gap: 0.3rem; + padding: 0.2rem 0.44rem; +} + +/* Note: Empty tracker state uses the same sidebar spacing as regular filter rows. */ +.tracker-filter-empty .spinner-border-xs { + height: 0.65rem; + width: 0.65rem; +} + .column-manager { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); @@ -896,13 +997,6 @@ body.mobile-mode .mobile-card { align-items: end; justify-content: flex-start; } -#trafficHistoryChart { - width: 100%; - height: 420px; - border: 1px solid var(--bs-border-color); - border-radius: 0.75rem; - background: var(--bs-body-bg); -} @media (max-width: 992px) { .profile-form-grid { grid-template-columns: 1fr; @@ -1138,19 +1232,12 @@ body.mobile-mode .mobile-card { } } @media (max-width: 640px) { - .toolbar-right { - flex-wrap: nowrap !important; - gap: 0.3rem !important; - } - .search { - min-width: 0 !important; - width: auto !important; - flex: 1 1 0 !important; - max-width: none !important; - } .mobile-speed-stats { - gap: 0.25rem; + align-items: flex-start; + flex-direction: column; + gap: 0.08rem; font-size: 0.66rem; + line-height: 1.05; } } @@ -1338,6 +1425,35 @@ body.mobile-mode .mobile-card { white-space: normal; word-break: break-word; } +/* Note: Smart Queue stats are reusable because they are shown in App status. */ +.automation-smart-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.5rem; + margin: 0.5rem 0 0.75rem; +} +.automation-smart-stat { + min-width: 0; + padding: 0.5rem 0.6rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} +.automation-smart-stat span, +.automation-smart-stat small { + display: block; + color: var(--bs-secondary-color); + font-size: 0.72rem; + line-height: 1.2; +} +.automation-smart-stat b { + display: block; + overflow: hidden; + font-size: 1rem; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} .automation-history-toolbar { display: flex; justify-content: flex-end; @@ -1346,6 +1462,7 @@ body.mobile-mode .mobile-card { /* Note: Automation history has fixed compact metadata columns and a flexible Actions column, so long JSON cannot overlap Time/Rule. */ .automation-history-table { width: 100%; + min-width: 760px; table-layout: fixed; white-space: normal; } @@ -1504,11 +1621,12 @@ body.mobile-mode .mobile-card { } .mobile-filter-actions, .mobile-filter-select-row { - display: flex; align-items: center; + display: flex; gap: 0.35rem; } .mobile-filter-actions { + flex-wrap: wrap; margin-bottom: 0.4rem; } .mobile-filter-actions span { @@ -1533,7 +1651,7 @@ body.mobile-mode .mobile-filter-bar { display: block !important; } #mobileList { - padding-top: 5.2rem !important; + padding-top: 7.1rem !important; } .topbar .badge { width: 0.72rem; @@ -2065,16 +2183,20 @@ body.mobile-mode .mobile-filter-bar { background: rgba(var(--bs-secondary-bg-rgb), 0.28); } +/* Note: Smart Queue switch resets Bootstrap's negative switch offset so it cannot overflow narrow frames. */ .smart-toggle-row .form-check { display: flex; align-items: center; + justify-content: flex-end; + flex: 0 0 auto; min-height: 0; margin: 0; - padding-left: 2.25rem; + padding-left: 0; } .smart-toggle-row .form-check-input { margin-top: 0; + margin-left: 0; } .smart-setting-row .form-check-label, @@ -2149,7 +2271,7 @@ body.mobile-mode .mobile-filter-bar { } .smart-toggle-row .form-check { - padding-left: 0; + justify-content: flex-start; } } @@ -2204,6 +2326,34 @@ body.mobile-mode .mobile-filter-bar { color: var(--bs-secondary-color); } + +.about-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.6rem; +} + +.about-summary-grid div { + padding: 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} + +.about-summary-grid b, +.about-summary-grid span { + display: block; +} + +.about-summary-grid b { + margin-bottom: 0.2rem; +} + +.about-summary-grid span { + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + .about-list { display: grid; gap: 0.55rem; @@ -2336,7 +2486,3 @@ body.mobile-mode .mobile-filter-bar { white-space: normal; } -.automation-history-scroll { - width: 100%; - overflow-x: auto; -} diff --git a/pytorrent/static/tracker_favicons b/pytorrent/static/tracker_favicons new file mode 120000 index 0000000..d4b544b --- /dev/null +++ b/pytorrent/static/tracker_favicons @@ -0,0 +1 @@ +../../data/tracker_favicons \ No newline at end of file diff --git a/pytorrent/templates/error.html b/pytorrent/templates/error.html index e1e6dd0..16f4f94 100644 --- a/pytorrent/templates/error.html +++ b/pytorrent/templates/error.html @@ -4,6 +4,8 @@ pyTorrent {{ code }} + + diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index a0361d3..126e5df 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -1,9 +1,11 @@ - + pyTorrent + + @@ -58,6 +60,7 @@
+

Shortcuts
Ctrl+A — select visible
@@ -103,7 +106,7 @@
CPU -%RAM -% - - rTorrent -DL 0 B/sUL 0 B/s + rTorrent -DL 0 B/sUL 0 B/sPeak S 0B/s / 0B/s · All 0B/s / 0B/s Total DL/UP 0B/0B Port - unknown --:--:-- Sockets -Shown 0Selected 0 Docs API
@@ -148,13 +151,13 @@ - +