From b98505fd315e68d21dc43f91fbbf9a47fa5ea8b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 17 Jun 2026 09:02:41 +0200 Subject: [PATCH] fix planner --- pytorrent/__init__.py | 2 - pytorrent/cli.py | 2 - pytorrent/config.py | 1 - pytorrent/db.py | 1 - pytorrent/logging_config.py | 3 - pytorrent/migrations.py | 2 - pytorrent/routes/__init__.py | 20 ++++++ pytorrent/routes/_shared.py | 35 +++++----- pytorrent/routes/api.py | 11 +--- pytorrent/routes/auth_api.py | 2 - pytorrent/routes/automations.py | 1 - pytorrent/routes/backup.py | 2 - pytorrent/routes/main.py | 4 -- pytorrent/routes/operation_logs.py | 1 - pytorrent/routes/planner.py | 11 ++-- pytorrent/routes/profiles.py | 2 - pytorrent/routes/rss.py | 2 - pytorrent/routes/smart_queue.py | 3 +- pytorrent/routes/system.py | 3 - pytorrent/routes/torrents.py | 2 - pytorrent/services/auth.py | 8 --- pytorrent/services/automation_rules.py | 2 - pytorrent/services/background_automations.py | 1 - pytorrent/services/background_cache_warmup.py | 1 - pytorrent/services/backup.py | 1 - pytorrent/services/database_maintenance.py | 2 - pytorrent/services/disk_guard.py | 2 - pytorrent/services/download_planner.py | 5 +- pytorrent/services/frontend_assets.py | 2 - pytorrent/services/geoip.py | 3 +- pytorrent/services/operation_logs.py | 1 - pytorrent/services/pdf_preview_links.py | 8 --- pytorrent/services/poller_control.py | 9 --- pytorrent/services/port_check.py | 4 -- pytorrent/services/preferences.py | 3 - pytorrent/services/profile_speed_limits.py | 1 - pytorrent/services/ratio_rules.py | 2 - pytorrent/services/retention.py | 2 - pytorrent/services/reverse_dns.py | 1 - pytorrent/services/rss.py | 1 - pytorrent/services/rtorrent/README.md | 10 --- pytorrent/services/rtorrent/__init__.py | 7 +- pytorrent/services/rtorrent/chunks.py | 7 -- pytorrent/services/rtorrent/client.py | 1 - pytorrent/services/rtorrent/config.py | 1 - pytorrent/services/rtorrent/diagnostics.py | 1 - pytorrent/services/rtorrent/files.py | 2 - pytorrent/services/rtorrent/shared.py | 2 - pytorrent/services/rtorrent/system.py | 5 -- pytorrent/services/rtorrent/torrents.py | 32 +-------- pytorrent/services/smart_queue.py | 2 - pytorrent/services/speed_peaks.py | 2 - pytorrent/services/startup_config.py | 2 - pytorrent/services/torrent_cache.py | 2 - pytorrent/services/torrent_creator.py | 1 - pytorrent/services/torrent_meta.py | 1 - pytorrent/services/torrent_stats.py | 2 - pytorrent/services/torrent_summary.py | 1 - pytorrent/services/tracker_cache.py | 2 - pytorrent/services/traffic_history.py | 2 - pytorrent/services/websocket.py | 12 ---- pytorrent/services/workers.py | 26 +------- pytorrent/static/js/plannerActions.js | 2 +- pytorrent/static/styles.css | 66 +++++++++---------- pytorrent/utils.py | 1 - 65 files changed, 82 insertions(+), 279 deletions(-) create mode 100644 pytorrent/routes/__init__.py delete mode 100644 pytorrent/services/rtorrent/README.md diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py index 5f423b9..da2a0b5 100644 --- a/pytorrent/__init__.py +++ b/pytorrent/__init__.py @@ -124,10 +124,8 @@ def create_app() -> Flask: from .routes.main import bp as main_bp from .routes.api import bp as api_bp - from .routes.planner import bp as planner_api_bp app.register_blueprint(main_bp) app.register_blueprint(api_bp) - app.register_blueprint(planner_api_bp) register_error_pages(app) init_db() from .services.speed_peaks import load_cache diff --git a/pytorrent/cli.py b/pytorrent/cli.py index 77f4c39..c2d4a67 100644 --- a/pytorrent/cli.py +++ b/pytorrent/cli.py @@ -1,10 +1,8 @@ from __future__ import annotations - import argparse import getpass import sys import json - from .db import connect, init_db, utcnow from .services.auth import password_hash from .services import tracker_cache diff --git a/pytorrent/config.py b/pytorrent/config.py index 45810c0..5af67e3 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -1,5 +1,4 @@ from __future__ import annotations - import os import secrets from pathlib import Path diff --git a/pytorrent/db.py b/pytorrent/db.py index 24df03f..dfb6d96 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -1,5 +1,4 @@ from __future__ import annotations - import sqlite3 from contextlib import contextmanager from datetime import datetime, timezone diff --git a/pytorrent/logging_config.py b/pytorrent/logging_config.py index c5026c4..d211e10 100644 --- a/pytorrent/logging_config.py +++ b/pytorrent/logging_config.py @@ -1,13 +1,10 @@ from __future__ import annotations - import logging import time from logging.handlers import TimedRotatingFileHandler from pathlib import Path from typing import Any - from flask import Flask, g, request - from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS _CONFIGURED = False diff --git a/pytorrent/migrations.py b/pytorrent/migrations.py index 0675d2a..d1bb2c9 100644 --- a/pytorrent/migrations.py +++ b/pytorrent/migrations.py @@ -1,10 +1,8 @@ from __future__ import annotations - import sqlite3 from collections.abc import Callable from datetime import datetime, timezone - Migration = Callable[[sqlite3.Connection], bool] diff --git a/pytorrent/routes/__init__.py b/pytorrent/routes/__init__.py new file mode 100644 index 0000000..27e52b0 --- /dev/null +++ b/pytorrent/routes/__init__.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from importlib import import_module + +API_ROUTE_MODULES = ( + "torrents", + "profiles", + "rss", + "automations", + "smart_queue", + "system", + "backup", + "operation_logs", + "planner", +) + + +def load_api_route_modules() -> None: + """Import API route modules so their shared blueprint decorators are registered.""" + for module_name in API_ROUTE_MODULES: + import_module(f"{__name__}.{module_name}") diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py index 8bc9c79..48acf07 100644 --- a/pytorrent/routes/_shared.py +++ b/pytorrent/routes/_shared.py @@ -1,5 +1,4 @@ from __future__ import annotations - import base64 import os import platform @@ -19,7 +18,6 @@ import threading from pathlib import Path from urllib.parse import quote from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for -# Note: url_for is exported through this shared module for API routes that build temporary in-app links. from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR from ..db import connect, utcnow from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write, require_admin, is_admin @@ -34,23 +32,33 @@ bp = Blueprint("api", __name__, url_prefix="/api") MOVE_BULK_MAX_HASHES = 100 - from .auth_api import register_auth_routes register_auth_routes(bp) - - def _request_profile_selector() -> tuple[int | None, str]: - """Return the optional profile selector supplied by external API clients.""" + """Return the optional rTorrent profile selector supplied by external API clients.""" payload = {} if request.method in {"POST", "PUT", "PATCH", "DELETE"}: try: payload = request.get_json(silent=True) or {} except Exception: payload = {} - profile_id = request.args.get("profile_id") or request.form.get("profile_id") or payload.get("profile_id") or request.headers.get("X-PyTorrent-Profile-Id") - profile_name = request.args.get("profile_name") or request.form.get("profile_name") or payload.get("profile_name") or request.headers.get("X-PyTorrent-Profile-Name") or "" + + profile_id = ( + request.args.get("profile_id") + or request.form.get("profile_id") + or payload.get("rtorrent_profile_id") + or request.headers.get("X-PyTorrent-Profile-Id") + ) + profile_name = ( + request.args.get("profile_name") + or request.form.get("profile_name") + or payload.get("rtorrent_profile_name") + or request.headers.get("X-PyTorrent-Profile-Name") + or "" + ) + try: return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip()) except (TypeError, ValueError): @@ -123,13 +131,9 @@ def ok(payload=None): return jsonify(data) - from ..services.port_check import port_check_status - - - def _safe_len(callable_obj) -> int | None: try: return len(callable_obj()) @@ -261,13 +265,11 @@ def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict: def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]: - # Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable. safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES)) return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)] def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]: - # Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions. base_payload = enrich_bulk_payload(profile, action_name, data) hashes = base_payload.get("hashes") or [] chunks = _chunk_hashes(hashes) @@ -297,17 +299,14 @@ def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]: - # Note: Keep the old public move helper while using the same partitioning logic. return enqueue_bulk_parts(profile, "move", data) def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]: - # Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load. return enqueue_bulk_parts(profile, "remove", data) def _user_disk_status(profile: dict) -> dict: - # Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller. prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None) try: paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else [] @@ -321,6 +320,4 @@ def _user_disk_status(profile: dict) -> dict: ) - -# Note: Route modules import shared helpers with wildcard imports; include private helper names intentionally. __all__ = [name for name in globals() if not name.startswith('__')] diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index c9d5ac0..6909b3d 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -1,15 +1,8 @@ from __future__ import annotations from ._shared import bp +from . import load_api_route_modules -# Note: Route modules are imported for their decorators; this keeps the public API unchanged. -from . import torrents as _torrents_routes -from . import profiles as _profiles_routes -from . import rss as _rss_routes -from . import automations as _automations_routes -from . import smart_queue as _smart_queue_routes -from . import system as _system_routes -from . import backup as _backup_routes -from . import operation_logs as _operation_logs_routes +load_api_route_modules() __all__ = ["bp"] diff --git a/pytorrent/routes/auth_api.py b/pytorrent/routes/auth_api.py index dc30a44..d25ac3e 100644 --- a/pytorrent/routes/auth_api.py +++ b/pytorrent/routes/auth_api.py @@ -1,7 +1,5 @@ from __future__ import annotations - from flask import abort, jsonify, request - from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token diff --git a/pytorrent/routes/automations.py b/pytorrent/routes/automations.py index aeb517a..d14636d 100644 --- a/pytorrent/routes/automations.py +++ b/pytorrent/routes/automations.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * diff --git a/pytorrent/routes/backup.py b/pytorrent/routes/backup.py index 71a27e4..85c98f8 100644 --- a/pytorrent/routes/backup.py +++ b/pytorrent/routes/backup.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * from ..services import auth @@ -53,7 +52,6 @@ def backup_create_app(): @bp.post("/backup") def backup_create(): - # Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings. return backup_create_profile() diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py index 467e216..5d16794 100644 --- a/pytorrent/routes/main.py +++ b/pytorrent/routes/main.py @@ -12,8 +12,6 @@ from ..services.preferences import get_preferences, list_profiles, active_profil from ..services import auth, pdf_preview_links, rtorrent from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL from ..services.frontend_assets import asset_path - -# for favicon from flask import current_app, send_from_directory bp = Blueprint("main", __name__) @@ -24,8 +22,6 @@ def _asset_url(key: str) -> str: return path if path.startswith("http") else url_for("static", filename=path) - - def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict: safe = Path(download_name or "download.bin").name or "download.bin" safe_disposition = "inline" if disposition == "inline" else "attachment" diff --git a/pytorrent/routes/operation_logs.py b/pytorrent/routes/operation_logs.py index 1fb9739..eba68ef 100644 --- a/pytorrent/routes/operation_logs.py +++ b/pytorrent/routes/operation_logs.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * from ..services import operation_logs diff --git a/pytorrent/routes/planner.py b/pytorrent/routes/planner.py index c964ce5..e4835c3 100644 --- a/pytorrent/routes/planner.py +++ b/pytorrent/routes/planner.py @@ -1,14 +1,11 @@ from __future__ import annotations -from flask import Blueprint, jsonify, request +from flask import jsonify, request -from ._shared import request_profile -from ..services import preferences, download_planner, poller_control +from ._shared import bp, request_profile +from ..services import download_planner, poller_control from ..services.auth import current_user_id -bp = Blueprint("planner_api", __name__, url_prefix="/api") - - def ok(payload=None): data = {"ok": True} if payload: @@ -33,7 +30,7 @@ def download_planner_get(): @bp.post("/download-planner") def download_planner_save(): - # Note: Planner settings are saved through one canonical endpoint to avoid hidden frontend/backend fallbacks. + # Note: Planner settings are saved through one canonical endpoint to keep the frontend/backend contract explicit. profile, error = _profile_or_error() if error: return error diff --git a/pytorrent/routes/profiles.py b/pytorrent/routes/profiles.py index 9accebd..021351b 100644 --- a/pytorrent/routes/profiles.py +++ b/pytorrent/routes/profiles.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * from ..services.rtorrent.diagnostics import profile_diagnostics from ..services import auth @@ -26,7 +25,6 @@ def profiles_create(): return jsonify({"ok": False, "error": str(exc)}), 400 - @bp.put("/profiles/") def profiles_update(profile_id: int): try: diff --git a/pytorrent/routes/rss.py b/pytorrent/routes/rss.py index d96da64..f5ed7a0 100644 --- a/pytorrent/routes/rss.py +++ b/pytorrent/routes/rss.py @@ -1,8 +1,6 @@ from __future__ import annotations - from ._shared import * - def _active_profile_or_400(): profile = request_profile() if not profile: diff --git a/pytorrent/routes/smart_queue.py b/pytorrent/routes/smart_queue.py index a1646bd..2a8a5f5 100644 --- a/pytorrent/routes/smart_queue.py +++ b/pytorrent/routes/smart_queue.py @@ -1,7 +1,7 @@ from __future__ import annotations - from ._shared import * + @bp.get('/smart-queue') def smart_queue_get(): from ..services import smart_queue @@ -19,7 +19,6 @@ def smart_queue_get(): return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []}) - @bp.post('/smart-queue') def smart_queue_save(): from ..services import smart_queue diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index aa1694a..387eeaf 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * import posixpath from ..services import operation_logs @@ -27,7 +26,6 @@ def system_status(): status["disk"] = _user_disk_status(profile) if bool(profile.get("is_remote")): try: - # Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats. usage = rtorrent.remote_system_usage(profile) status.update(usage) status["usage_available"] = True @@ -40,7 +38,6 @@ def system_status(): status["ram"] = psutil.virtual_memory().percent status["usage_source"] = "local" status["usage_available"] = True - # Note: REST status returns the latest records without waiting for the next Socket.IO message. status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0)) return ok({"status": status}) except Exception as exc: diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index 146e094..2ab9cc6 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * from ..services import profile_speed_limits from ..services import pdf_preview_links, torrent_creator @@ -20,7 +19,6 @@ def torrents(): - @bp.get("/trackers/summary") def trackers_summary(): profile = request_profile() diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py index 535eb57..3d07480 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -1,11 +1,8 @@ from __future__ import annotations - from functools import wraps from typing import Any import secrets - from urllib.parse import urlparse - from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for from werkzeug.security import check_password_hash, generate_password_hash @@ -39,8 +36,6 @@ RTORRENT_WRITE_PREFIXES = ( ) RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",) ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles") -# Note: API reads that expose rTorrent/profile data must also respect profile permissions. -# Note: Planner, poller and operation-log endpoints are profile-scoped and must follow the active profile context. PROFILE_READ_PREFIXES = ( "/api/torrents", "/api/torrent-stats", @@ -101,7 +96,6 @@ def _host_matches_bypass(host: str) -> bool: def auth_bypassed_request() -> bool: - # Note: Allows trusted direct-IP access to keep auth enabled for reverse-proxy traffic. if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context(): return False return _host_matches_bypass(request.host) @@ -115,7 +109,6 @@ def bypass_user_id() -> int: row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone() if row: return int(row["id"]) - # Note: Keep direct-IP access usable after old installs, but never choose an inactive fallback. row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone() if row: return int(row["id"]) @@ -126,7 +119,6 @@ def current_user_id() -> int: if not enabled(): return default_user_id() if not has_request_context(): - # Note: Background jobs and schedulers do not have Flask request/session state. return 0 if auth_bypassed_request(): return bypass_user_id() diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index ed042ee..4e86848 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -23,8 +23,6 @@ def _check_lock(profile_id: int, rule_id: int | None = None) -> threading.Lock: return _CHECK_LOCKS[key] - - def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None = None) -> int: """Return a safe user id for rule ownership or background execution.""" if user_id: diff --git a/pytorrent/services/background_automations.py b/pytorrent/services/background_automations.py index 03f8abc..0df85f5 100644 --- a/pytorrent/services/background_automations.py +++ b/pytorrent/services/background_automations.py @@ -4,7 +4,6 @@ import os import threading import time from typing import Any - from ..db import connect, default_user_id from . import automation_rules, operation_logs, poller_control, rtorrent from .websocket import emit_profile_event diff --git a/pytorrent/services/background_cache_warmup.py b/pytorrent/services/background_cache_warmup.py index 1b002bf..bb27621 100644 --- a/pytorrent/services/background_cache_warmup.py +++ b/pytorrent/services/background_cache_warmup.py @@ -4,7 +4,6 @@ import os import threading import time from typing import Any - from ..db import connect, default_user_id from . import port_check, preferences, rtorrent, tracker_cache from .torrent_cache import torrent_cache diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index 2742a6b..debca40 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json import threading import time diff --git a/pytorrent/services/database_maintenance.py b/pytorrent/services/database_maintenance.py index 95b108c..cc6c228 100644 --- a/pytorrent/services/database_maintenance.py +++ b/pytorrent/services/database_maintenance.py @@ -1,11 +1,9 @@ from __future__ import annotations - import shutil import sqlite3 import threading import time from typing import Any - from ..config import DB_PATH _VACUUM_LOCK = threading.Lock() diff --git a/pytorrent/services/disk_guard.py b/pytorrent/services/disk_guard.py index b997307..42e8073 100644 --- a/pytorrent/services/disk_guard.py +++ b/pytorrent/services/disk_guard.py @@ -1,7 +1,5 @@ from __future__ import annotations - from typing import Any - from . import download_planner diff --git a/pytorrent/services/download_planner.py b/pytorrent/services/download_planner.py index 39e4f26..176064b 100644 --- a/pytorrent/services/download_planner.py +++ b/pytorrent/services/download_planner.py @@ -1,12 +1,9 @@ from __future__ import annotations - import json import time +import psutil from datetime import datetime, timezone from typing import Any - -import psutil - from ..db import connect, default_user_id, utcnow from . import auth, operation_logs, rtorrent diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py index 5c3f99f..e1bf15c 100644 --- a/pytorrent/services/frontend_assets.py +++ b/pytorrent/services/frontend_assets.py @@ -1,7 +1,5 @@ from __future__ import annotations - from pathlib import Path - from ..config import BASE_DIR, USE_OFFLINE_LIBS LIBS_STATIC_DIR = "libs" diff --git a/pytorrent/services/geoip.py b/pytorrent/services/geoip.py index 6cd9342..29bef32 100644 --- a/pytorrent/services/geoip.py +++ b/pytorrent/services/geoip.py @@ -1,12 +1,11 @@ from __future__ import annotations - from functools import lru_cache from pathlib import Path from ..config import GEOIP_DB try: import geoip2.database -except Exception: # pragma: no cover +except Exception: geoip2 = None _reader = None diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py index 15faa5f..8118653 100644 --- a/pytorrent/services/operation_logs.py +++ b/pytorrent/services/operation_logs.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json from datetime import datetime, timedelta, timezone from typing import Any diff --git a/pytorrent/services/pdf_preview_links.py b/pytorrent/services/pdf_preview_links.py index c856d33..b3d5fc9 100644 --- a/pytorrent/services/pdf_preview_links.py +++ b/pytorrent/services/pdf_preview_links.py @@ -1,5 +1,4 @@ from __future__ import annotations - import secrets import threading import time @@ -18,7 +17,6 @@ def _cleanup_expired(now: float | None = None) -> None: def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict: """Create a short-lived in-app link target used by preview and download routes.""" - # Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI. now = time.time() token = secrets.token_urlsafe(24) with _TEMPORARY_LINK_LOCK: @@ -35,7 +33,6 @@ def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: di def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict: """Create a short-lived in-app PDF preview link without exposing the API download URL.""" - # Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader. return _create_temporary_link( "pdf_preview", profile_id, @@ -46,7 +43,6 @@ def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict: """Create a temporary in-app download link for one torrent file.""" - # Note: File downloads use /download/ in the UI, but the backend keeps the same rTorrent streaming logic. return _create_temporary_link( "file_download", profile_id, @@ -57,7 +53,6 @@ def create_file_download_link(torrent_hash: str, file_index: int, profile_id: in def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict: """Create a temporary in-app download link for a ZIP of torrent files.""" - # Note: Selected indexes are stored with the token so the final /download route does not need an API body. clean_indexes = None if indexes is None else [int(index) for index in indexes] return _create_temporary_link( "file_zip_download", @@ -69,7 +64,6 @@ def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict: """Create a temporary in-app download link for an exported .torrent file.""" - # Note: The token hides the stable export API URL from browser-visible download actions. return _create_temporary_link( "torrent_file_download", profile_id, @@ -80,7 +74,6 @@ def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_i def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict: """Create a temporary in-app download link for a ZIP of exported .torrent files.""" - # Note: Hashes are copied into the token target after the API validates that the request is non-empty. return _create_temporary_link( "torrent_files_zip_download", profile_id, @@ -91,7 +84,6 @@ def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, u def get_temporary_link(token: str) -> dict | None: """Return a temporary target if the link is still valid.""" - # Note: Expired links are removed on read so stale browser tabs stop resolving automatically. clean = str(token or "").strip() if not clean: return None diff --git a/pytorrent/services/poller_control.py b/pytorrent/services/poller_control.py index 5a0067e..d7ca21a 100644 --- a/pytorrent/services/poller_control.py +++ b/pytorrent/services/poller_control.py @@ -1,10 +1,8 @@ from __future__ import annotations - import json import time from dataclasses import dataclass, field from typing import Any - from ..db import connect, utcnow from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS @@ -81,7 +79,6 @@ def normalize_settings(data: dict | None) -> dict: "recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)), } if settings["safe_fallback_enabled"]: - # Note: Safe fallback keeps existing functionality, but prevents very aggressive polling from overloading rTorrent or the browser. for key, minimum in SAFE_FALLBACK_MINIMUMS.items(): settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum)) return settings @@ -91,7 +88,6 @@ def get_settings(profile_id: int) -> dict: with connect() as conn: row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone() if not row: - # Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read. legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone() if legacy: try: @@ -240,7 +236,6 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", updated_count: int = 0, requires_full_refresh: bool = False) -> None: now = time.monotonic() - # Note: Live poller diagnostics track only lightweight speed/status refreshes, not the full torrent snapshot loop. state.live_poll_count += 1 state.last_live_duration_ms = round((now - started_at) * 1000.0, 2) state.last_live_updated_count = int(updated_count or 0) @@ -254,7 +249,6 @@ def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", added_count: int = 0, updated_count: int = 0, removed_count: int = 0) -> None: now = time.monotonic() - # Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation. state.list_poll_count += 1 state.last_list_duration_ms = round((now - started_at) * 1000.0, 2) state.last_list_added_count = int(added_count or 0) @@ -269,7 +263,6 @@ def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: def reset_runtime_stats(profile_id: int) -> dict: state = state_for(profile_id) - # Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged. state.tick_count = 0 state.last_tick_ms = 0.0 state.last_tick_gap_ms = 0.0 @@ -390,7 +383,6 @@ def snapshot(profile_id: int, settings: dict | None = None) -> dict: effective_settings = normalize_settings(settings) if settings is not None else get_settings(profile_id) data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count}) runtime_ready = bool(state.stats) or state.tick_count > 0 - # Note: Snapshot includes saved intervals even before the first runtime tick so diagnostics never render as an empty zero-only panel. data.setdefault("runtime_ready", runtime_ready) data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"]))) data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting")) @@ -399,7 +391,6 @@ def snapshot(profile_id: int, settings: dict | None = None) -> dict: data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS) if not runtime_ready: data["last_ok"] = None - # Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats. data.update({ "live_poll_count": state.live_poll_count, "list_poll_count": state.list_poll_count, diff --git a/pytorrent/services/port_check.py b/pytorrent/services/port_check.py index 9b334c1..3c432d4 100644 --- a/pytorrent/services/port_check.py +++ b/pytorrent/services/port_check.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json import re import socket @@ -8,7 +7,6 @@ import urllib.parse import urllib.request from datetime import datetime, timezone from typing import Any - from ..db import connect from . import preferences, rtorrent @@ -44,7 +42,6 @@ def _public_ip(profile: dict | None = None, force: bool = False) -> str: def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]: """Return valid incoming port candidates from rTorrent network.port_range.""" - # Note: rTorrent can keep a range/list and pick a random port on start, so the checker tests all safe candidates. ports: list[int] = [] seen: set[int] = set() truncated = False @@ -136,7 +133,6 @@ def _check_ports(public_ip: str, ports: list[int], checker) -> dict: def port_check_status(profile: dict | None = None, force: bool = False, user_id: int | None = None) -> dict: """Return cached or freshly checked incoming-port status for one rTorrent profile.""" - # Note: This service is shared by UI routes and the background worker, so browser startup is not required. profile = profile or preferences.active_profile(user_id) prefs = preferences.get_preferences(user_id, int(profile.get("id"))) if profile else preferences.get_preferences(user_id) enabled = bool((prefs or {}).get("port_check_enabled")) diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 3b4dc97..3328942 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -1,7 +1,5 @@ from __future__ import annotations - import json - from ..db import connect, utcnow, default_user_id from . import auth from .frontend_assets import BOOTSTRAP_THEME_LABELS @@ -28,7 +26,6 @@ FONT_FAMILIES = { "adwaita-mono": "Adwaita Mono", } -# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets. RECOMMENDED_TABLE_COLUMNS = { "hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"], "shown": ["down_total", "to_download", "up_total", "created"], diff --git a/pytorrent/services/profile_speed_limits.py b/pytorrent/services/profile_speed_limits.py index b7883ca..77fdeab 100644 --- a/pytorrent/services/profile_speed_limits.py +++ b/pytorrent/services/profile_speed_limits.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ..db import connect, utcnow diff --git a/pytorrent/services/ratio_rules.py b/pytorrent/services/ratio_rules.py index 321b371..18fa48f 100644 --- a/pytorrent/services/ratio_rules.py +++ b/pytorrent/services/ratio_rules.py @@ -1,9 +1,7 @@ from __future__ import annotations - import json import time from datetime import datetime, timezone - from ..db import connect, utcnow, default_user_id from . import auth, rtorrent from .workers import enqueue diff --git a/pytorrent/services/retention.py b/pytorrent/services/retention.py index db9c707..9999e84 100644 --- a/pytorrent/services/retention.py +++ b/pytorrent/services/retention.py @@ -1,7 +1,5 @@ from __future__ import annotations - from datetime import datetime, timedelta, timezone - from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS from ..db import connect diff --git a/pytorrent/services/reverse_dns.py b/pytorrent/services/reverse_dns.py index 1b90a13..f44957b 100644 --- a/pytorrent/services/reverse_dns.py +++ b/pytorrent/services/reverse_dns.py @@ -1,5 +1,4 @@ from __future__ import annotations - import ipaddress import socket import time diff --git a/pytorrent/services/rss.py b/pytorrent/services/rss.py index 97e8450..b6297ab 100644 --- a/pytorrent/services/rss.py +++ b/pytorrent/services/rss.py @@ -1,5 +1,4 @@ from __future__ import annotations - import re import time import urllib.request diff --git a/pytorrent/services/rtorrent/README.md b/pytorrent/services/rtorrent/README.md deleted file mode 100644 index c276e1f..0000000 --- a/pytorrent/services/rtorrent/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# rTorrent service modules - -The old `pytorrent/services/rtorrent.py` monolith is end-of-life. -Do not recreate it and do not add new rTorrent logic outside this directory. - -Use focused modules in `pytorrent/services/rtorrent/` instead: -- `client.py` for SCGI/XMLRPC transport and shared caches. -- `system.py` for status, footer metrics, disk and remote host usage. -- `torrents.py` for torrent list and torrent operations. -- `files.py`, `config.py`, `diagnostics.py` for their dedicated areas. diff --git a/pytorrent/services/rtorrent/__init__.py b/pytorrent/services/rtorrent/__init__.py index 143fe1d..e2c33ee 100644 --- a/pytorrent/services/rtorrent/__init__.py +++ b/pytorrent/services/rtorrent/__init__.py @@ -1,14 +1,9 @@ from __future__ import annotations -# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith. -# All rTorrent code belongs in this package directory. - -# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent. -# Compatibility note: module __all__ definitions include selected private helpers used by existing routes. from .client import * from .system import * from .diagnostics import * from .files import * from .config import * from .torrents import * -from .chunks import * +from .chunks import * \ No newline at end of file diff --git a/pytorrent/services/rtorrent/chunks.py b/pytorrent/services/rtorrent/chunks.py index da8c8a3..c1c0497 100644 --- a/pytorrent/services/rtorrent/chunks.py +++ b/pytorrent/services/rtorrent/chunks.py @@ -1,5 +1,4 @@ from __future__ import annotations - import math import re from .client import * @@ -11,13 +10,11 @@ _HEX_RE = re.compile(r"[0-9a-fA-F]") def _clean_hex_bitfield(value) -> str: """Return only hexadecimal bitfield characters from rTorrent output.""" - # Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload. return "".join(_HEX_RE.findall(str(value or ""))).lower() def _hex_to_bits(value: str, limit: int | None = None) -> list[int]: """Decode an rTorrent hex bitfield into one bit per torrent piece.""" - # Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents. bits: list[int] = [] for char in _clean_hex_bitfield(value): nibble = int(char, 16) @@ -47,7 +44,6 @@ def _chunk_status(completed: int, total: int, seen: bool = False) -> str: def _group_cells(cells: list[dict], max_cells: int) -> list[dict]: """Reduce very large torrents to a browser-friendly number of visual cells.""" - # Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress. if max_cells <= 0 or len(cells) <= max_cells: return cells grouped: list[dict] = [] @@ -79,7 +75,6 @@ def _group_cells(cells: list[dict], max_cells: int) -> list[dict]: def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]: """Create one raw cell per real torrent piece.""" - # Note: The UI still groups these cells later when needed, but the source data remains exact per piece. cells: list[dict] = [] for idx in range(max(0, int(total_chunks or 0))): completed = 1 if idx < len(have_bits) and have_bits[idx] else 0 @@ -101,7 +96,6 @@ def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[ def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict: """Return ruTorrent-like visual chunk data for one torrent.""" - # Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks. c = client_for(profile) values = { "bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)), @@ -177,7 +171,6 @@ def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict: """Run safe actions related to visual chunk selection.""" - # Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide. payload = payload or {} action = str(action or "").strip().lower() c = client_for(profile) diff --git a/pytorrent/services/rtorrent/client.py b/pytorrent/services/rtorrent/client.py index a047e59..44d3fa2 100644 --- a/pytorrent/services/rtorrent/client.py +++ b/pytorrent/services/rtorrent/client.py @@ -1,5 +1,4 @@ from __future__ import annotations - import errno import os import posixpath diff --git a/pytorrent/services/rtorrent/config.py b/pytorrent/services/rtorrent/config.py index f919844..1be905f 100644 --- a/pytorrent/services/rtorrent/config.py +++ b/pytorrent/services/rtorrent/config.py @@ -1,5 +1,4 @@ from __future__ import annotations - from .client import * RTORRENT_CONFIG_FIELDS = [ diff --git a/pytorrent/services/rtorrent/diagnostics.py b/pytorrent/services/rtorrent/diagnostics.py index e934d58..9f2e6af 100644 --- a/pytorrent/services/rtorrent/diagnostics.py +++ b/pytorrent/services/rtorrent/diagnostics.py @@ -1,5 +1,4 @@ from __future__ import annotations - from .client import * from .. import poller_control diff --git a/pytorrent/services/rtorrent/files.py b/pytorrent/services/rtorrent/files.py index 93cd6aa..97272b2 100644 --- a/pytorrent/services/rtorrent/files.py +++ b/pytorrent/services/rtorrent/files.py @@ -1,5 +1,4 @@ from __future__ import annotations - from .client import * from ...config import BASE_DIR @@ -25,7 +24,6 @@ def torrent_files(profile: dict, torrent_hash: str) -> list[dict]: def torrent_file_tree(profile: dict, torrent_hash: str) -> dict: - # Note: The tree is built from rTorrent file paths without changing the existing flat file API. root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}} for item in torrent_files(profile, torrent_hash): parts = [part for part in str(item.get("path") or "").split("/") if part] diff --git a/pytorrent/services/rtorrent/shared.py b/pytorrent/services/rtorrent/shared.py index 4a7729e..0eec6a8 100644 --- a/pytorrent/services/rtorrent/shared.py +++ b/pytorrent/services/rtorrent/shared.py @@ -1,4 +1,2 @@ from __future__ import annotations - -# Note: Backward-compatible internal alias for modules created during refactor. from .client import * diff --git a/pytorrent/services/rtorrent/system.py b/pytorrent/services/rtorrent/system.py index 457c674..07c95dd 100644 --- a/pytorrent/services/rtorrent/system.py +++ b/pytorrent/services/rtorrent/system.py @@ -1,8 +1,6 @@ from __future__ import annotations - from typing import Any from threading import RLock - from .client import * from .config import default_download_path from ...utils import human_size @@ -10,7 +8,6 @@ from ...utils import human_size def browse_path(profile: dict, path: str | None = None) -> dict: """List directories through rTorrent execute.capture to avoid pyTorrent FS permissions.""" - # Note: Directory browsing stays remote-side, matching the original monolithic service behavior. c = client_for(profile) base = _remote_clean_path(path or default_download_path(profile)) script = ( @@ -44,7 +41,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict: name, full_path = parts[0], parts[1] is_empty = len(parts) > 2 and parts[2] == "1" if name not in {".", ".."}: - # Note: Empty status is returned with every directory so the path picker can enable safe inline rename. dirs.append({"name": name, "path": full_path, "empty": is_empty}) elif marker == "M" and "\t" in rest: first, second = rest.split("\t", 1) @@ -67,7 +63,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict: parent = posixpath.dirname(base.rstrip("/")) or "/" if parent == base: parent = base - # Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts. return { "path": base, "parent": parent, diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py index fe982ae..7f4e6e4 100644 --- a/pytorrent/services/rtorrent/torrents.py +++ b/pytorrent/services/rtorrent/torrents.py @@ -1,18 +1,14 @@ from __future__ import annotations - import time - from .client import * from .files import set_file_priorities from .system import disk_usage_for_default_path - XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024 def _parse_xmlrpc_size_limit(value) -> int: """Parse rTorrent XML-RPC size values such as 524288, 16M or 8K.""" - # Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes. text = str(value or '').strip().lower() if not text: return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES @@ -29,7 +25,6 @@ def _parse_xmlrpc_size_limit(value) -> int: def xmlrpc_size_limit(profile: dict) -> dict: """Return the current rTorrent XML-RPC request size limit.""" - # Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC. try: raw = client_for(profile).call('network.xmlrpc.size_limit') limit = _parse_xmlrpc_size_limit(raw) @@ -40,7 +35,6 @@ def xmlrpc_size_limit(profile: dict) -> dict: def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int: """Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file.""" - # Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file. commands = [] if directory: commands.append(f'd.directory.set={directory}') @@ -93,7 +87,6 @@ def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool: if age > _POST_CHECK_WATCH_TTL_SECONDS: _clear_post_check_watch(profile_id, torrent_hash) return False - # Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet. return age >= _POST_CHECK_WATCH_MIN_SECONDS @@ -124,7 +117,6 @@ def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, cu labels = _label_names(str(label_source or "")) if POST_CHECK_DOWNLOAD_LABEL not in labels: return False - # Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue. c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL])) return True @@ -151,11 +143,9 @@ def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool if POST_CHECK_DOWNLOAD_LABEL not in labels: return False status = str(row.get("status") or "").lower() - # Note: rTorrent may report state=1 after a recheck even when the download is not really active yet. started_after_wait = bool(int(row.get("state") or 0)) and bool(int(row.get("active") or 0)) and status != "checking" if not (_row_progress_complete(row) or status == "seeding" or started_after_wait): return False - # Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding. clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or "")) row["label"] = _without_post_check_download_label(str(row.get("label") or "")) return True @@ -183,7 +173,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict complete = _row_progress_complete(row) try: if complete: - # Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately. start_result = start_or_resume_hash(c, h) clear_post_check_download_label(c, h, str(row.get("label") or "")) row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))}) @@ -193,7 +182,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict if POST_CHECK_DOWNLOAD_LABEL not in labels: labels.append(POST_CHECK_DOWNLOAD_LABEL) label_value = _label_value(labels) - # Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit. c.call("d.stop", h) try: c.call("d.close", h) @@ -229,7 +217,6 @@ LIVE_TORRENT_FIELDS = [ def human_duration(seconds: int) -> str: - # Note: Download ETA is derived locally from remaining bytes and current download speed. seconds = max(0, int(seconds or 0)) if seconds <= 0: return '-' @@ -256,12 +243,8 @@ def normalize_row(row: list) -> dict: base_path = str(row[15] or "") state = int(row[2] or 0) complete = int(row[3] or 0) - # Note: is_multi_file is needed before status calculation because the display path hides the torrent root for multi-file payloads. is_multi_file = int(row[24] or 0) if len(row) > 24 else 0 - # Show the selected download location only. Hide the torrent root - # directory for multi-file torrents and the filename for single-file - # torrents. Data deletion still uses the full d.base_path elsewhere. if base_path and base_path != "/": display_parent = posixpath.dirname(base_path.rstrip("/")) or "/" display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent @@ -280,20 +263,15 @@ def normalize_row(row: list) -> dict: is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state) last_activity = int(row[25] or 0) if len(row) > 25 else 0 if not last_activity and (down_rate > 0 or up_rate > 0): - # Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp. last_activity = int(time.time()) completed_at = int(row[26] or 0) if len(row) > 26 else 0 - # 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) post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active) - # Note: rTorrent exposes queued/inactive torrents with the same runtime flags that older UI code called paused. - # The app marks only explicit user Pause requests with py_manual_pause so queued rows stay separate. is_paused = manual_pause and not is_checking and not post_check is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check - # Note: Post-check and Queued are application-level UI statuses; rTorrent itself mainly exposes flags. status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped" to_download_bytes = remaining_bytes if not complete else 0 - # Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value. + return { "hash": str(row[0] or ""), "name": str(row[1] or ""), @@ -338,7 +316,6 @@ def normalize_row(row: list) -> dict: def normalize_live_row(row: list) -> dict: """Normalize the small row used by the fast live stats poller.""" - # Note: The live poller intentionally reads only volatile fields so the main list poller can run less often. size = int(row[3] or 0) completed = int(row[4] or 0) complete = int(row[2] or 0) @@ -406,13 +383,10 @@ def list_torrents(profile: dict) -> list[dict]: try: rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS)) except Exception: - # Keep compatibility with older rTorrent builds that do not expose optional timestamp fields. rows = c.d.multicall2("", "main", *TORRENT_FIELDS) return [normalize_row(list(row)) for row in rows] - - def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]: fields = [ "p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", @@ -444,8 +418,6 @@ def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]: return peers - - def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict: errors = [] for method, args in candidates: @@ -457,7 +429,6 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d raise RuntimeError("; ".join(errors)) - def _tracker_domain(url: str) -> str: raw = str(url or '').strip() if not raw: @@ -471,7 +442,6 @@ def _tracker_domain(url: str) -> str: def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict: """Return tracker domains grouped by torrent for the sidebar filter.""" - # Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list. hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()] if not hashes: hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')] diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index 8ce21bb..05e7ac7 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -1,12 +1,10 @@ from __future__ import annotations - from collections import Counter from datetime import datetime, timezone from typing import Any import json import os import time - from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL from ..db import connect, default_user_id, utcnow from . import rtorrent diff --git a/pytorrent/services/speed_peaks.py b/pytorrent/services/speed_peaks.py index a8b8f58..b4f8eb7 100644 --- a/pytorrent/services/speed_peaks.py +++ b/pytorrent/services/speed_peaks.py @@ -1,8 +1,6 @@ from __future__ import annotations - import threading from typing import Any - from ..db import connect, utcnow from .rtorrent import human_rate diff --git a/pytorrent/services/startup_config.py b/pytorrent/services/startup_config.py index 42afc7f..3153ea9 100644 --- a/pytorrent/services/startup_config.py +++ b/pytorrent/services/startup_config.py @@ -1,8 +1,6 @@ from __future__ import annotations - import threading from time import monotonic - from ..db import connect from . import operation_logs, rtorrent diff --git a/pytorrent/services/torrent_cache.py b/pytorrent/services/torrent_cache.py index d658285..5dab484 100644 --- a/pytorrent/services/torrent_cache.py +++ b/pytorrent/services/torrent_cache.py @@ -1,11 +1,9 @@ from __future__ import annotations - from threading import RLock from time import time from . import rtorrent, operation_logs _LIVE_KEYS = {"state", "active", "paused", "complete", "completed_bytes", "progress", "ratio", "up_rate", "up_rate_h", "down_rate", "down_rate_h", "eta_seconds", "eta_h", "up_total", "up_total_h", "down_total", "down_total_h", "to_download", "to_download_h", "peers", "seeds", "message", "status", "post_check", "hashing"} - _VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"} diff --git a/pytorrent/services/torrent_creator.py b/pytorrent/services/torrent_creator.py index 6dbf909..a7579a7 100644 --- a/pytorrent/services/torrent_creator.py +++ b/pytorrent/services/torrent_creator.py @@ -1,5 +1,4 @@ from __future__ import annotations - import hashlib import os import time diff --git a/pytorrent/services/torrent_meta.py b/pytorrent/services/torrent_meta.py index 3f53aaa..4e1680d 100644 --- a/pytorrent/services/torrent_meta.py +++ b/pytorrent/services/torrent_meta.py @@ -1,5 +1,4 @@ from __future__ import annotations - import hashlib from pathlib import PurePosixPath from typing import Any diff --git a/pytorrent/services/torrent_stats.py b/pytorrent/services/torrent_stats.py index 6745b05..d21d521 100644 --- a/pytorrent/services/torrent_stats.py +++ b/pytorrent/services/torrent_stats.py @@ -1,10 +1,8 @@ from __future__ import annotations - import json import threading import time from typing import Any - from ..db import connect, utcnow from . import rtorrent from .torrent_cache import torrent_cache diff --git a/pytorrent/services/torrent_summary.py b/pytorrent/services/torrent_summary.py index 70d43f4..726442f 100644 --- a/pytorrent/services/torrent_summary.py +++ b/pytorrent/services/torrent_summary.py @@ -1,5 +1,4 @@ from __future__ import annotations - from copy import deepcopy from threading import RLock from time import time diff --git a/pytorrent/services/tracker_cache.py b/pytorrent/services/tracker_cache.py index 8746756..573b5ea 100644 --- a/pytorrent/services/tracker_cache.py +++ b/pytorrent/services/tracker_cache.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json import mimetypes import re @@ -11,7 +10,6 @@ import urllib.parse import urllib.request from html.parser import HTMLParser from pathlib import Path - from ..config import BASE_DIR from ..db import connect, utcnow diff --git a/pytorrent/services/traffic_history.py b/pytorrent/services/traffic_history.py index cdb459f..88be994 100644 --- a/pytorrent/services/traffic_history.py +++ b/pytorrent/services/traffic_history.py @@ -1,8 +1,6 @@ from __future__ import annotations - from datetime import datetime, timedelta, timezone from typing import Any - from ..config import TRAFFIC_HISTORY_RETENTION_DAYS from ..db import connect, utcnow from . import retention diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index 60b9992..b08e81a 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -1,5 +1,4 @@ from __future__ import annotations - import threading import time import json @@ -17,7 +16,6 @@ def _profile_room(profile_id: int) -> str: def _poller_profiles() -> list[dict]: - # Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms. if not auth.enabled(): profile = active_profile() return [profile] if profile else [] @@ -27,7 +25,6 @@ def _poller_profiles() -> list[dict]: def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None: - # Note: Profile-scoped events always go to the selected profile room, even when authentication is disabled. scoped_payload = {**(payload or {}), "profile_id": int(profile_id)} socketio.emit(event, scoped_payload, to=_profile_room(profile_id)) @@ -36,19 +33,15 @@ def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None: emit_profile_event(socketio, event, payload, profile_id) - - def _apply_configured_speed_limits(profile: dict) -> None: limits = profile_speed_limits.get_limits(int(profile.get("id") or 0)) if not limits.get("configured"): return - # Note: Profile-level speed limits are re-applied when the profile is opened so they are not tied to a specific user session. rtorrent.set_limits(profile, limits.get("down"), limits.get("up")) def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: state = poller_control.state_for(profile_id) - # Note: Background checks keep the profile owner so bypass/admin profiles do not enqueue jobs as the fallback user. profile_user_id = int(profile.get("user_id") or default_user_id()) try: try: @@ -67,7 +60,6 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: except Exception as exc: _emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id) try: - # Note: Automations are profile-scoped; each queued job still runs as the rule owner. auto_result = automation_rules.check(profile, force=False) if auto_result.get("applied") or auto_result.get("batches"): _emit_profile(socketio, "automation_update", auto_result, profile_id) @@ -94,7 +86,6 @@ def _is_active_rows(rows: list[dict]) -> bool: def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict: - # Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats. down_rate = sum(int(row.get("down_rate") or 0) for row in rows or []) up_rate = sum(int(row.get("up_rate") or 0) for row in rows or []) return { @@ -184,7 +175,6 @@ def register_socketio_handlers(socketio): else: skipped_emissions += 1 if live.get("requires_full_refresh"): - # Note: Missing or unknown hashes mean the next slow list tick must reconcile rows. state.last_list_at = 0.0 run_list = True else: @@ -218,7 +208,6 @@ def register_socketio_handlers(socketio): rtorrent_call_count += 1 if bool(profile.get("is_remote")): try: - # Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats. usage = rtorrent.remote_system_usage(profile) status.update(usage) status["usage_available"] = True @@ -272,7 +261,6 @@ def register_socketio_handlers(socketio): global _started with _start_lock: if not _started: - # The poller starts with the app, so Smart Queue, planner and automations work without an open UI. socketio.start_background_task(poller) _started = True diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index 56c8dcc..e91eec9 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json import threading import time @@ -43,7 +42,6 @@ def _emit(name: str, payload: dict): return profile_id = payload.get("profile_id") if profile_id: - # Note: Job/socket events are profile-room scoped so modals and toasts do not leak between rTorrent profiles. _socketio.emit(name, payload, to=f"profile:{int(profile_id)}") else: _socketio.emit(name, payload) @@ -102,7 +100,6 @@ def _job_payload(row) -> dict: def _is_ordered_job(row) -> bool: payload = _job_payload(row) action = str((row or {}).get("action") or "") - # Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work. return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order")) @@ -195,7 +192,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non job_id = uuid.uuid4().hex if force: payload = dict(payload or {}) - # Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation. payload['force_job'] = True payload['priority_job'] = True now = utcnow() @@ -205,7 +201,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non "INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now), ) - # Note: Queued jobs are now written to operation logs so work is visible before a worker starts it. operation_logs.record_job_event(profile_id, action_name, "queued", payload, job_id=job_id, user_id=user_id) _emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"}) _submit_job(job_id, action_name) @@ -217,7 +212,6 @@ def _job_event_meta(payload: dict) -> dict: source = str(ctx.get("source") or payload.get("source") or "user") meta = {"source": source} if source == "automation": - # Note: Socket operation toasts use this flag so automation notifications respect user preferences. meta["automation"] = True meta["source_label"] = str(ctx.get("rule_name") or "automation") if ctx.get("rule_id") is not None: @@ -226,7 +220,6 @@ def _job_event_meta(payload: dict) -> dict: - def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | None = None) -> bool: # Note: Disk usage refreshes only when a remove job actually requested data deletion. if str(action_name or "") != "remove": @@ -239,7 +232,6 @@ def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | Non def _clear_disk_refresh_cache(profile_id: int) -> None: try: - # Note: Remove-with-data jobs invalidate disk cache before notifying browsers, otherwise /api/system/disk may return stale values. rtorrent.clear_profile_runtime_caches(int(profile_id)) except Exception: pass @@ -247,7 +239,6 @@ def _clear_disk_refresh_cache(profile_id: int) -> None: def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None: _clear_disk_refresh_cache(profile_id) - # Note: The browser performs the fresh /api/system/disk read so profile-scoped disk monitor preferences stay respected. _emit("disk_refresh_requested", { "profile_id": int(profile_id), "hash_count": int(hash_count or 0), @@ -282,7 +273,6 @@ def _schedule_profile_disk_refresh(profile_id: int, hash_count: int = 0) -> None old_timer = _disk_refresh_timers.get(key) if old_timer: old_timer.cancel() - # Note: Repeated delete jobs share one delayed refresh per profile and delay, preventing timer storms during bulk cleanup. timer = threading.Timer(float(delay_seconds), _run_delayed_disk_refresh, args=(profile_id, int(delay_seconds))) timer.daemon = True _disk_refresh_timers[key] = timer @@ -301,7 +291,6 @@ def _emit_disk_refresh_requested(profile_id: int, action_name: str, payload: dic def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None): if action_name == "smart_queue_check": from . import smart_queue - # Note: Worker execution uses the job owner instead of Flask session state. return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True) if action_name == "add_magnet": if bool(payload.get("start", True)): @@ -363,7 +352,6 @@ def _emit_torrent_refresh(profile: dict, action_name: str) -> None: else: _emit("rtorrent_error", {**diff, "profile_id": profile_id}) except Exception as exc: - # Note: A failed live refresh must not change the already completed job result. _emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)}) @@ -372,7 +360,6 @@ def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None: return def delayed_refresh(): - # Note: rTorrent may expose state changes one poll later than the XML-RPC action result. sleep_fn = getattr(_socketio, "sleep", time.sleep) for delay in (0.75, 1.75): sleep_fn(delay) @@ -395,7 +382,6 @@ def _run(job_id: str): profile = get_profile(int(job["profile_id"]), int(job["user_id"])) if not profile: _set_job(job_id, "failed", "rTorrent profile does not exist", finished=True) - # Note: Profile lookup failures used to appear only in the job queue; they are now persisted in operation logs too. operation_logs.record_worker_event(int(job.get("profile_id") or 0), str(job.get("action") or ""), "failed", "Job failed: rTorrent profile does not exist", job_id=job_id, user_id=int(job.get("user_id") or 0), error="profile not found") _emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"}) return @@ -422,16 +408,13 @@ def _run(job_id: str): _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts}) result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0)) fresh = _job_row(job_id) - # Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state. if fresh and fresh["status"] != "running": return _set_job(job_id, "done", result=result, finished=True) operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0)) _emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta}) - # Note: Remove-with-data jobs ask connected browsers to refresh disk usage immediately after filesystem deletion finishes. action_name = str(job["action"] or "") _emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result or {}) - # Note: Completed jobs must publish a fresh torrent snapshot/patch so removed or moved torrents disappear without a page reload. _emit_torrent_refresh(profile, action_name) _schedule_delayed_torrent_refresh(profile, action_name) _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result}) @@ -495,7 +478,6 @@ def _timeout_running_jobs() -> None: continue message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds" _set_job(row["id"], "failed", message, finished=True) - # Note: Watchdog timeouts are stored in operation logs because no normal worker exception may be raised. operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "timeout", message, job_id=row["id"], user_id=int(row.get("user_id") or 0), error=message) _emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"}) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message}) @@ -514,8 +496,7 @@ def _resubmit_interrupted_running_jobs() -> None: if not profile: continue last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at")) - # Note: After process restart there is no in-memory runner for this job. - # A short grace avoids stealing work from another still-alive Gunicorn worker. + if last_seen_ts is not None and now_ts - last_seen_ts < 90: continue with connect() as conn: @@ -524,7 +505,6 @@ def _resubmit_interrupted_running_jobs() -> None: ("Resuming interrupted job from last checkpoint", utcnow(), row["id"]), ) if int(cur.rowcount or 0): - # Note: Interrupted jobs returned to the queue are logged so restart recovery is auditable. operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Interrupted job resubmitted from checkpoint", job_id=row["id"], user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True}) _submit_job(row["id"], row.get("action")) @@ -547,7 +527,6 @@ def _resubmit_stale_pending_jobs() -> None: continue with connect() as conn: conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"])) - # Note: Stale pending resubmits are logged to explain duplicated queue attempts after watchdog recovery. operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Stale pending job resubmitted by watchdog", job_id=row["id"], user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True}) _submit_job(row["id"], row.get("action")) @@ -586,7 +565,6 @@ def _job_summary(row: dict, payload: dict, result: dict) -> str: count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0) parts = [] if ctx.get("bulk_label"): - # Note: Shows which generated bulk part is being displayed in the job queue. parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}") if count: parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)") @@ -652,7 +630,6 @@ def cancel_job(job_id: str) -> bool: row = _job_row(job_id) if not row or row["status"] not in {"pending", "running"}: return False - # Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup. _set_job(job_id, "cancelled", finished=True) payload = _job_payload(row) operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "cancelled", payload, error="Cancelled by user", job_id=job_id, user_id=int(row.get("user_id") or 0)) @@ -670,7 +647,6 @@ def clear_jobs() -> int: def emergency_clear_jobs() -> int: - # Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list. now = utcnow() where, params = _job_scope_sql(writable=True) status_clause = "status IN ('pending', 'running')" diff --git a/pytorrent/static/js/plannerActions.js b/pytorrent/static/js/plannerActions.js index f74fc22..56073a5 100644 --- a/pytorrent/static/js/plannerActions.js +++ b/pytorrent/static/js/plannerActions.js @@ -1 +1 @@ -export const plannerActionsSource = " function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' · dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} · next change ${esc(plannerDateText(preview.next_change_at))} · DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' · pauses downloads':''}${preview.manual_override_until?' · override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const payload=plannerPayload(); const j=await post('/api/download-planner',payload); fillPlanner(j.settings||payload); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; +export const plannerActionsSource = " const PLANNER_API_BASE = '/api/download-planner';\n\n async function plannerApiJson(url, options={}){\n const response = await fetch(url, {cache:'no-store', ...options});\n const json = await response.json().catch(() => ({}));\n if(!response.ok || json.ok === false){\n throw new Error(json.error || `Planner API failed (${response.status})`);\n }\n return json;\n }\n\n function renderPlannerPreview(preview={}){\n updatePlannerCurrentSummary(preview);\n const box=$('plannerPreview');\n if(!box) return;\n const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0);\n box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`;\n updatePlannerFooter(!!$('plannerEnabled')?.checked,preview);\n const ov=$('plannerOverrideStatus');\n if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.';\n }\n\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory');\n if(!box) return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n\n function fillPlanner(st){\n if(!st) return;\n $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled);\n $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode');\n $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run);\n updatePlannerFooter(!!st.enabled,st);\n $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled);\n const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[];\n for(let hour=0;hour<24;hour++){\n const item=hourly.find(x=>Number(x.hour)===hour)||{};\n const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`);\n if(d) d.value=Number(item.down||0);\n if(u) u.value=Number(item.up||0);\n updatePlannerHourSummary(hour);\n }\n $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled);\n $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00');\n $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00');\n $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled);\n $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00');\n $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00');\n $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0);\n $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0);\n $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0);\n $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0);\n updatePlannerSpeedControls('plannerWeekday');\n updatePlannerSpeedControls('plannerWeekend');\n $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled);\n $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90);\n $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled);\n $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95);\n $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled);\n $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0);\n $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0);\n $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled);\n $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95);\n $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false);\n $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0);\n if(st.manual_override_until) renderPlannerPreview(st);\n updatePlannerCurrentSummary(st);\n }\n\n function applyPlannerPreset(){\n const name=$('plannerProfileName')?.value||'';\n if(name==='night mode'){\n $('plannerNightOnly').checked=true;\n $('plannerQuietEnabled').checked=false;\n setPlannerSpeed('plannerWeekday',100);\n setPlannerSpeed('plannerWeekend',250);\n }\n if(name==='weekend mode'){\n $('plannerNightOnly').checked=false;\n setPlannerSpeed('plannerWeekday',50);\n setPlannerSpeed('plannerWeekend',0);\n }\n if(name==='low power mode'){\n $('plannerLoadEnabled').checked=true;\n $('plannerCpuEnabled').checked=true;\n $('plannerCpuPercent').value=70;\n setPlannerSpeed('plannerWeekday',50);\n setPlannerSpeed('plannerWeekend',50);\n }\n if(name==='unlimited mode'){\n $('plannerNightOnly').checked=false;\n $('plannerQuietEnabled').checked=false;\n setPlannerSpeed('plannerWeekday',0);\n setPlannerSpeed('plannerWeekend',0);\n }\n updatePlannerCurrentSummary();\n }\n\n async function loadPlannerPreview(){\n try{\n const limit=plannerHistoryExpanded?100:10;\n const j=await plannerApiJson(`${PLANNER_API_BASE}/preview?history_limit=${limit}`);\n renderPlannerPreview(j.preview||{});\n renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));\n }catch(e){\n const box=$('plannerPreview');\n if(box) box.innerHTML=`${esc(e.message||'Planner preview failed')}`;\n }\n }\n\n async function loadDownloadPlanner(){\n ensurePlannerToolsUI();\n try{\n const j=await plannerApiJson(PLANNER_API_BASE);\n fillPlanner(j.settings||{});\n await loadPlannerPreview();\n }catch(e){\n const box=$('plannerPreview');\n if(box) box.innerHTML=`${esc(e.message||'Planner settings failed')}`;\n }\n }\n\n async function saveDownloadPlanner(){\n setBusy(true);\n try{\n // Note: Save uses the canonical Planner endpoint registered on the shared API blueprint.\n const payload=plannerPayload();\n const j=await post(PLANNER_API_BASE,payload);\n fillPlanner(j.settings||payload);\n await loadPlannerPreview();\n toast('Download planner saved','success');\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n\n async function applyDownloadPlannerNow(dryRun=false){\n setBusy(true);\n try{\n const j=await post(`${PLANNER_API_BASE}/check`,{dry_run:!!dryRun});\n const r=j.result||{};\n if(r.settings) fillPlanner(r.settings);\n renderPlannerPreview(r.preview||r);\n if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length);\n else await loadPlannerPreview();\n toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n\n async function setPlannerOverride(){\n setBusy(true);\n try{\n const seconds=Number($('plannerOverrideSeconds')?.value||0);\n await post(`${PLANNER_API_BASE}/override`,{seconds});\n toast(seconds?'Planner override set':'Planner override cleared','success');\n await loadDownloadPlanner();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 9f36fd9..def9d5b 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -3418,6 +3418,39 @@ body.mobile-mode .mobile-filter-bar { margin-bottom: 0.7rem; } +/* Note: Planner Current Settings inherits the original compact card chrome from .smart-setting-row. */ +.planner-current-summary { + align-items: flex-start; +} + +/* Note: Keep Planner Current Settings entries on one visual line, with the same separator spacing as before. */ +.planner-diagnostic-line { + align-items: center; + color: var(--bs-secondary-color); + display: flex; + flex-wrap: wrap; + gap: 0.3rem 0.55rem; + line-height: 1.45; + margin-top: 0.2rem; +} + +.planner-diagnostic-item { + align-items: baseline; + display: inline-flex; + gap: 0.25rem; + white-space: nowrap; +} + +.planner-diagnostic-item b { + color: var(--bs-body-color); + display: inline; + font-weight: 700; +} + +.planner-diagnostic-line .diagnostic-separator { + margin: 0 0.18rem; +} + .planner-current-summary ul { display: flex; flex-wrap: wrap; @@ -5746,39 +5779,6 @@ body.compact-torrent-list .mobile-progress .torrent-progress { width: 1rem; } -/* Note: Planner Current Settings inherits the original compact card chrome from .smart-setting-row. */ -.planner-current-summary { - align-items: flex-start; -} - -/* Note: Keep Planner Current Settings entries on one visual line, with the same separator spacing as before. */ -.planner-diagnostic-line { - align-items: center; - color: var(--bs-secondary-color); - display: flex; - flex-wrap: wrap; - gap: 0.3rem 0.55rem; - line-height: 1.45; - margin-top: 0.2rem; -} - -.planner-diagnostic-item { - align-items: baseline; - display: inline-flex; - gap: 0.25rem; - white-space: nowrap; -} - -.planner-diagnostic-item b { - color: var(--bs-body-color); - display: inline; - font-weight: 700; -} - -.planner-diagnostic-line .diagnostic-separator { - margin: 0 0.18rem; -} - .diagnostic-separator, .modal-meta-separator { color: var(--bs-secondary-color); diff --git a/pytorrent/utils.py b/pytorrent/utils.py index 7a2639a..602cba3 100644 --- a/pytorrent/utils.py +++ b/pytorrent/utils.py @@ -1,5 +1,4 @@ from __future__ import annotations - import hashlib from pathlib import Path