fix planner

This commit is contained in:
Mateusz Gruszczyński
2026-06-17 09:02:41 +02:00
parent 99692ef217
commit b98505fd31
65 changed files with 82 additions and 279 deletions
-2
View File
@@ -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
-2
View File
@@ -1,10 +1,8 @@
from __future__ import annotations
import argparse
import getpass
import sys
import json
from .db import connect, init_db, utcnow
from .services.auth import password_hash
from .services import tracker_cache
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import os
import secrets
from pathlib import Path
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
-3
View File
@@ -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
-2
View File
@@ -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]
+20
View File
@@ -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}")
+16 -19
View File
@@ -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('__')]
+2 -9
View File
@@ -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"]
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from flask import abort, jsonify, request
from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from ._shared import *
-2
View File
@@ -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()
-4
View File
@@ -12,8 +12,6 @@ from ..services.preferences import get_preferences, list_profiles, active_profil
from ..services import auth, pdf_preview_links, rtorrent
from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
from ..services.frontend_assets import asset_path
# for favicon
from flask import current_app, send_from_directory
bp = Blueprint("main", __name__)
@@ -24,8 +22,6 @@ def _asset_url(key: str) -> str:
return path if path.startswith("http") else url_for("static", filename=path)
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
safe = Path(download_name or "download.bin").name or "download.bin"
safe_disposition = "inline" if disposition == "inline" else "attachment"
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from ._shared import *
from ..services import operation_logs
+4 -7
View File
@@ -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
-2
View File
@@ -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/<int:profile_id>")
def profiles_update(profile_id: int):
try:
-2
View File
@@ -1,8 +1,6 @@
from __future__ import annotations
from ._shared import *
def _active_profile_or_400():
profile = request_profile()
if not profile:
+1 -2
View File
@@ -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
-3
View File
@@ -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:
-2
View File
@@ -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()
-8
View File
@@ -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()
-2
View File
@@ -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:
@@ -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
@@ -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
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import json
import threading
import time
@@ -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()
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import Any
from . import download_planner
+1 -4
View File
@@ -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
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from pathlib import Path
from ..config import BASE_DIR, USE_OFFLINE_LIBS
LIBS_STATIC_DIR = "libs"
+1 -2
View File
@@ -1,12 +1,11 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from ..config import GEOIP_DB
try:
import geoip2.database
except Exception: # pragma: no cover
except Exception:
geoip2 = None
_reader = None
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
-8
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import secrets
import threading
import time
@@ -18,7 +17,6 @@ def _cleanup_expired(now: float | None = None) -> None:
def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict:
"""Create a short-lived in-app link target used by preview and download routes."""
# Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI.
now = time.time()
token = secrets.token_urlsafe(24)
with _TEMPORARY_LINK_LOCK:
@@ -35,7 +33,6 @@ def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: di
def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
"""Create a short-lived in-app PDF preview link without exposing the API download URL."""
# Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader.
return _create_temporary_link(
"pdf_preview",
profile_id,
@@ -46,7 +43,6 @@ def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int,
def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for one torrent file."""
# Note: File downloads use /download/<token> in the UI, but the backend keeps the same rTorrent streaming logic.
return _create_temporary_link(
"file_download",
profile_id,
@@ -57,7 +53,6 @@ def create_file_download_link(torrent_hash: str, file_index: int, profile_id: in
def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for a ZIP of torrent files."""
# Note: Selected indexes are stored with the token so the final /download route does not need an API body.
clean_indexes = None if indexes is None else [int(index) for index in indexes]
return _create_temporary_link(
"file_zip_download",
@@ -69,7 +64,6 @@ def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None,
def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for an exported .torrent file."""
# Note: The token hides the stable export API URL from browser-visible download actions.
return _create_temporary_link(
"torrent_file_download",
profile_id,
@@ -80,7 +74,6 @@ def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_i
def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for a ZIP of exported .torrent files."""
# Note: Hashes are copied into the token target after the API validates that the request is non-empty.
return _create_temporary_link(
"torrent_files_zip_download",
profile_id,
@@ -91,7 +84,6 @@ def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, u
def get_temporary_link(token: str) -> dict | None:
"""Return a temporary target if the link is still valid."""
# Note: Expired links are removed on read so stale browser tabs stop resolving automatically.
clean = str(token or "").strip()
if not clean:
return None
-9
View File
@@ -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,
-4
View File
@@ -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"))
-3
View File
@@ -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"],
@@ -1,5 +1,4 @@
from __future__ import annotations
from ..db import connect, utcnow
-2
View File
@@ -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
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
from ..db import connect
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import ipaddress
import socket
import time
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import re
import time
import urllib.request
-10
View File
@@ -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.
-5
View File
@@ -1,10 +1,5 @@
from __future__ import annotations
# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith.
# All rTorrent code belongs in this package directory.
# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent.
# Compatibility note: module __all__ definitions include selected private helpers used by existing routes.
from .client import *
from .system import *
from .diagnostics import *
-7
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import math
import re
from .client import *
@@ -11,13 +10,11 @@ _HEX_RE = re.compile(r"[0-9a-fA-F]")
def _clean_hex_bitfield(value) -> str:
"""Return only hexadecimal bitfield characters from rTorrent output."""
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
return "".join(_HEX_RE.findall(str(value or ""))).lower()
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
bits: list[int] = []
for char in _clean_hex_bitfield(value):
nibble = int(char, 16)
@@ -47,7 +44,6 @@ def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
"""Reduce very large torrents to a browser-friendly number of visual cells."""
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
if max_cells <= 0 or len(cells) <= max_cells:
return cells
grouped: list[dict] = []
@@ -79,7 +75,6 @@ def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
"""Create one raw cell per real torrent piece."""
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
cells: list[dict] = []
for idx in range(max(0, int(total_chunks or 0))):
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
@@ -101,7 +96,6 @@ def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
"""Return ruTorrent-like visual chunk data for one torrent."""
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
c = client_for(profile)
values = {
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
@@ -177,7 +171,6 @@ def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
"""Run safe actions related to visual chunk selection."""
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
payload = payload or {}
action = str(action or "").strip().lower()
c = client_for(profile)
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import errno
import os
import posixpath
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from .client import *
RTORRENT_CONFIG_FIELDS = [
@@ -1,5 +1,4 @@
from __future__ import annotations
from .client import *
from .. import poller_control
-2
View File
@@ -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]
-2
View File
@@ -1,4 +1,2 @@
from __future__ import annotations
# Note: Backward-compatible internal alias for modules created during refactor.
from .client import *
-5
View File
@@ -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,
+1 -31
View File
@@ -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')]
-2
View File
@@ -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
-2
View File
@@ -1,8 +1,6 @@
from __future__ import annotations
import threading
from typing import Any
from ..db import connect, utcnow
from .rtorrent import human_rate
-2
View File
@@ -1,8 +1,6 @@
from __future__ import annotations
import threading
from time import monotonic
from ..db import connect
from . import operation_logs, rtorrent
-2
View File
@@ -1,11 +1,9 @@
from __future__ import annotations
from threading import RLock
from time import time
from . import rtorrent, operation_logs
_LIVE_KEYS = {"state", "active", "paused", "complete", "completed_bytes", "progress", "ratio", "up_rate", "up_rate_h", "down_rate", "down_rate_h", "eta_seconds", "eta_h", "up_total", "up_total_h", "down_total", "down_total_h", "to_download", "to_download_h", "peers", "seeds", "message", "status", "post_check", "hashing"}
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import hashlib
import os
import time
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import hashlib
from pathlib import PurePosixPath
from typing import Any
-2
View File
@@ -1,10 +1,8 @@
from __future__ import annotations
import json
import threading
import time
from typing import Any
from ..db import connect, utcnow
from . import rtorrent
from .torrent_cache import torrent_cache
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from copy import deepcopy
from threading import RLock
from time import time
-2
View File
@@ -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
-2
View File
@@ -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
-12
View File
@@ -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
+1 -25
View File
@@ -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')"
File diff suppressed because one or more lines are too long
+33 -33
View File
@@ -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);
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import hashlib
from pathlib import Path