Profile id api #30

Merged
gru merged 6 commits from profile_id_api into master 2026-06-17 09:04:13 +02:00
65 changed files with 82 additions and 279 deletions
Showing only changes of commit b98505fd31 - Show all commits
-2
View File
@@ -124,10 +124,8 @@ def create_app() -> Flask:
from .routes.main import bp as main_bp from .routes.main import bp as main_bp
from .routes.api import bp as api_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(main_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
app.register_blueprint(planner_api_bp)
register_error_pages(app) register_error_pages(app)
init_db() init_db()
from .services.speed_peaks import load_cache from .services.speed_peaks import load_cache
-2
View File
@@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import getpass import getpass
import sys import sys
import json import json
from .db import connect, init_db, utcnow from .db import connect, init_db, utcnow
from .services.auth import password_hash from .services.auth import password_hash
from .services import tracker_cache from .services import tracker_cache
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import os import os
import secrets import secrets
from pathlib import Path from pathlib import Path
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
-3
View File
@@ -1,13 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import time import time
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from flask import Flask, g, request from flask import Flask, g, request
from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS
_CONFIGURED = False _CONFIGURED = False
-2
View File
@@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
Migration = Callable[[sqlite3.Connection], bool] 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 from __future__ import annotations
import base64 import base64
import os import os
import platform import platform
@@ -19,7 +18,6 @@ import threading
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for 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 ..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 ..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 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 MOVE_BULK_MAX_HASHES = 100
from .auth_api import register_auth_routes from .auth_api import register_auth_routes
register_auth_routes(bp) register_auth_routes(bp)
def _request_profile_selector() -> tuple[int | None, str]: 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 = {} payload = {}
if request.method in {"POST", "PUT", "PATCH", "DELETE"}: if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
try: try:
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
except Exception: except Exception:
payload = {} 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: try:
return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip()) return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip())
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -123,13 +131,9 @@ def ok(payload=None):
return jsonify(data) return jsonify(data)
from ..services.port_check import port_check_status from ..services.port_check import port_check_status
def _safe_len(callable_obj) -> int | None: def _safe_len(callable_obj) -> int | None:
try: try:
return len(callable_obj()) 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]]: 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)) 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)] 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]: 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) base_payload = enrich_bulk_payload(profile, action_name, data)
hashes = base_payload.get("hashes") or [] hashes = base_payload.get("hashes") or []
chunks = _chunk_hashes(hashes) 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]: 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) return enqueue_bulk_parts(profile, "move", data)
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]: 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) return enqueue_bulk_parts(profile, "remove", data)
def _user_disk_status(profile: dict) -> dict: 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) prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
try: try:
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else [] 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('__')] __all__ = [name for name in globals() if not name.startswith('__')]
+2 -9
View File
@@ -1,15 +1,8 @@
from __future__ import annotations from __future__ import annotations
from ._shared import bp from ._shared import bp
from . import load_api_route_modules
# Note: Route modules are imported for their decorators; this keeps the public API unchanged. load_api_route_modules()
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
__all__ = ["bp"] __all__ = ["bp"]
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from flask import abort, jsonify, request 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 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 __future__ import annotations
from ._shared import * from ._shared import *
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import auth from ..services import auth
@@ -53,7 +52,6 @@ def backup_create_app():
@bp.post("/backup") @bp.post("/backup")
def backup_create(): def backup_create():
# Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings.
return backup_create_profile() 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 ..services import auth, pdf_preview_links, rtorrent
from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
from ..services.frontend_assets import asset_path from ..services.frontend_assets import asset_path
# for favicon
from flask import current_app, send_from_directory from flask import current_app, send_from_directory
bp = Blueprint("main", __name__) 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) 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: 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 = Path(download_name or "download.bin").name or "download.bin"
safe_disposition = "inline" if disposition == "inline" else "attachment" safe_disposition = "inline" if disposition == "inline" else "attachment"
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import operation_logs from ..services import operation_logs
+4 -7
View File
@@ -1,14 +1,11 @@
from __future__ import annotations from __future__ import annotations
from flask import Blueprint, jsonify, request from flask import jsonify, request
from ._shared import request_profile from ._shared import bp, request_profile
from ..services import preferences, download_planner, poller_control from ..services import download_planner, poller_control
from ..services.auth import current_user_id from ..services.auth import current_user_id
bp = Blueprint("planner_api", __name__, url_prefix="/api")
def ok(payload=None): def ok(payload=None):
data = {"ok": True} data = {"ok": True}
if payload: if payload:
@@ -33,7 +30,7 @@ def download_planner_get():
@bp.post("/download-planner") @bp.post("/download-planner")
def download_planner_save(): 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() profile, error = _profile_or_error()
if error: if error:
return error return error
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services.rtorrent.diagnostics import profile_diagnostics from ..services.rtorrent.diagnostics import profile_diagnostics
from ..services import auth from ..services import auth
@@ -26,7 +25,6 @@ def profiles_create():
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.put("/profiles/<int:profile_id>") @bp.put("/profiles/<int:profile_id>")
def profiles_update(profile_id: int): def profiles_update(profile_id: int):
try: try:
-2
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
def _active_profile_or_400(): def _active_profile_or_400():
profile = request_profile() profile = request_profile()
if not profile: if not profile:
+1 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
@bp.get('/smart-queue') @bp.get('/smart-queue')
def smart_queue_get(): def smart_queue_get():
from ..services import smart_queue from ..services import smart_queue
@@ -19,7 +19,6 @@ def smart_queue_get():
return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []}) return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []})
@bp.post('/smart-queue') @bp.post('/smart-queue')
def smart_queue_save(): def smart_queue_save():
from ..services import smart_queue from ..services import smart_queue
-3
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
import posixpath import posixpath
from ..services import operation_logs from ..services import operation_logs
@@ -27,7 +26,6 @@ def system_status():
status["disk"] = _user_disk_status(profile) status["disk"] = _user_disk_status(profile)
if bool(profile.get("is_remote")): if bool(profile.get("is_remote")):
try: try:
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
usage = rtorrent.remote_system_usage(profile) usage = rtorrent.remote_system_usage(profile)
status.update(usage) status.update(usage)
status["usage_available"] = True status["usage_available"] = True
@@ -40,7 +38,6 @@ def system_status():
status["ram"] = psutil.virtual_memory().percent status["ram"] = psutil.virtual_memory().percent
status["usage_source"] = "local" status["usage_source"] = "local"
status["usage_available"] = True 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)) status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
return ok({"status": status}) return ok({"status": status})
except Exception as exc: except Exception as exc:
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import profile_speed_limits from ..services import profile_speed_limits
from ..services import pdf_preview_links, torrent_creator from ..services import pdf_preview_links, torrent_creator
@@ -20,7 +19,6 @@ def torrents():
@bp.get("/trackers/summary") @bp.get("/trackers/summary")
def trackers_summary(): def trackers_summary():
profile = request_profile() profile = request_profile()
-8
View File
@@ -1,11 +1,8 @@
from __future__ import annotations from __future__ import annotations
from functools import wraps from functools import wraps
from typing import Any from typing import Any
import secrets import secrets
from urllib.parse import urlparse from urllib.parse import urlparse
from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
@@ -39,8 +36,6 @@ RTORRENT_WRITE_PREFIXES = (
) )
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",) RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles") 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 = ( PROFILE_READ_PREFIXES = (
"/api/torrents", "/api/torrents",
"/api/torrent-stats", "/api/torrent-stats",
@@ -101,7 +96,6 @@ def _host_matches_bypass(host: str) -> bool:
def auth_bypassed_request() -> 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(): if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context():
return False return False
return _host_matches_bypass(request.host) 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() row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone()
if row: if row:
return int(row["id"]) 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() row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone()
if row: if row:
return int(row["id"]) return int(row["id"])
@@ -126,7 +119,6 @@ def current_user_id() -> int:
if not enabled(): if not enabled():
return default_user_id() return default_user_id()
if not has_request_context(): if not has_request_context():
# Note: Background jobs and schedulers do not have Flask request/session state.
return 0 return 0
if auth_bypassed_request(): if auth_bypassed_request():
return bypass_user_id() 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] return _CHECK_LOCKS[key]
def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None = None) -> int: 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.""" """Return a safe user id for rule ownership or background execution."""
if user_id: if user_id:
@@ -4,7 +4,6 @@ import os
import threading import threading
import time import time
from typing import Any from typing import Any
from ..db import connect, default_user_id from ..db import connect, default_user_id
from . import automation_rules, operation_logs, poller_control, rtorrent from . import automation_rules, operation_logs, poller_control, rtorrent
from .websocket import emit_profile_event from .websocket import emit_profile_event
@@ -4,7 +4,6 @@ import os
import threading import threading
import time import time
from typing import Any from typing import Any
from ..db import connect, default_user_id from ..db import connect, default_user_id
from . import port_check, preferences, rtorrent, tracker_cache from . import port_check, preferences, rtorrent, tracker_cache
from .torrent_cache import torrent_cache from .torrent_cache import torrent_cache
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import json import json
import threading import threading
import time import time
@@ -1,11 +1,9 @@
from __future__ import annotations from __future__ import annotations
import shutil import shutil
import sqlite3 import sqlite3
import threading import threading
import time import time
from typing import Any from typing import Any
from ..config import DB_PATH from ..config import DB_PATH
_VACUUM_LOCK = threading.Lock() _VACUUM_LOCK = threading.Lock()
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from . import download_planner from . import download_planner
+1 -4
View File
@@ -1,12 +1,9 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time import time
import psutil
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import psutil
from ..db import connect, default_user_id, utcnow from ..db import connect, default_user_id, utcnow
from . import auth, operation_logs, rtorrent from . import auth, operation_logs, rtorrent
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from ..config import BASE_DIR, USE_OFFLINE_LIBS from ..config import BASE_DIR, USE_OFFLINE_LIBS
LIBS_STATIC_DIR = "libs" LIBS_STATIC_DIR = "libs"
+1 -2
View File
@@ -1,12 +1,11 @@
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from ..config import GEOIP_DB from ..config import GEOIP_DB
try: try:
import geoip2.database import geoip2.database
except Exception: # pragma: no cover except Exception:
geoip2 = None geoip2 = None
_reader = None _reader = None
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
-8
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
import threading import threading
import time 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: 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.""" """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() now = time.time()
token = secrets.token_urlsafe(24) token = secrets.token_urlsafe(24)
with _TEMPORARY_LINK_LOCK: 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: 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.""" """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( return _create_temporary_link(
"pdf_preview", "pdf_preview",
profile_id, 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: 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.""" """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( return _create_temporary_link(
"file_download", "file_download",
profile_id, 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: 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.""" """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] clean_indexes = None if indexes is None else [int(index) for index in indexes]
return _create_temporary_link( return _create_temporary_link(
"file_zip_download", "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: 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.""" """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( return _create_temporary_link(
"torrent_file_download", "torrent_file_download",
profile_id, 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: 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.""" """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( return _create_temporary_link(
"torrent_files_zip_download", "torrent_files_zip_download",
profile_id, 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: def get_temporary_link(token: str) -> dict | None:
"""Return a temporary target if the link is still valid.""" """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() clean = str(token or "").strip()
if not clean: if not clean:
return None return None
-9
View File
@@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from ..db import connect, utcnow from ..db import connect, utcnow
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS 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)), "recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
} }
if settings["safe_fallback_enabled"]: 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(): for key, minimum in SAFE_FALLBACK_MINIMUMS.items():
settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum)) settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum))
return settings return settings
@@ -91,7 +88,6 @@ def get_settings(profile_id: int) -> dict:
with connect() as conn: with connect() as conn:
row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone() row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
if not row: 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() legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
if legacy: if legacy:
try: 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: 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() 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.live_poll_count += 1
state.last_live_duration_ms = round((now - started_at) * 1000.0, 2) state.last_live_duration_ms = round((now - started_at) * 1000.0, 2)
state.last_live_updated_count = int(updated_count or 0) 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: 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() now = time.monotonic()
# Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation.
state.list_poll_count += 1 state.list_poll_count += 1
state.last_list_duration_ms = round((now - started_at) * 1000.0, 2) state.last_list_duration_ms = round((now - started_at) * 1000.0, 2)
state.last_list_added_count = int(added_count or 0) state.last_list_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: def reset_runtime_stats(profile_id: int) -> dict:
state = state_for(profile_id) state = state_for(profile_id)
# Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged.
state.tick_count = 0 state.tick_count = 0
state.last_tick_ms = 0.0 state.last_tick_ms = 0.0
state.last_tick_gap_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) 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}) 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 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("runtime_ready", runtime_ready)
data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"]))) 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")) 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) data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS)
if not runtime_ready: if not runtime_ready:
data["last_ok"] = None data["last_ok"] = None
# Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats.
data.update({ data.update({
"live_poll_count": state.live_poll_count, "live_poll_count": state.live_poll_count,
"list_poll_count": state.list_poll_count, "list_poll_count": state.list_poll_count,
-4
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re import re
import socket import socket
@@ -8,7 +7,6 @@ import urllib.parse
import urllib.request import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from ..db import connect from ..db import connect
from . import preferences, rtorrent 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]: 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.""" """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] = [] ports: list[int] = []
seen: set[int] = set() seen: set[int] = set()
truncated = False 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: 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.""" """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) 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) 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")) enabled = bool((prefs or {}).get("port_check_enabled"))
-3
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json import json
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from . import auth from . import auth
from .frontend_assets import BOOTSTRAP_THEME_LABELS from .frontend_assets import BOOTSTRAP_THEME_LABELS
@@ -28,7 +26,6 @@ FONT_FAMILIES = {
"adwaita-mono": "Adwaita Mono", "adwaita-mono": "Adwaita Mono",
} }
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
RECOMMENDED_TABLE_COLUMNS = { RECOMMENDED_TABLE_COLUMNS = {
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"], "hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
"shown": ["down_total", "to_download", "up_total", "created"], "shown": ["down_total", "to_download", "up_total", "created"],
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from ..db import connect, utcnow from ..db import connect, utcnow
-2
View File
@@ -1,9 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from . import auth, rtorrent from . import auth, rtorrent
from .workers import enqueue from .workers import enqueue
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone 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 ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
from ..db import connect from ..db import connect
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import socket import socket
import time import time
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import re import re
import time import time
import urllib.request 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 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 .client import *
from .system import * from .system import *
from .diagnostics import * from .diagnostics import *
-7
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import math import math
import re import re
from .client import * from .client import *
@@ -11,13 +10,11 @@ _HEX_RE = re.compile(r"[0-9a-fA-F]")
def _clean_hex_bitfield(value) -> str: def _clean_hex_bitfield(value) -> str:
"""Return only hexadecimal bitfield characters from rTorrent output.""" """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() return "".join(_HEX_RE.findall(str(value or ""))).lower()
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]: def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
"""Decode an rTorrent hex bitfield into one bit per torrent piece.""" """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] = [] bits: list[int] = []
for char in _clean_hex_bitfield(value): for char in _clean_hex_bitfield(value):
nibble = int(char, 16) 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]: def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
"""Reduce very large torrents to a browser-friendly number of visual cells.""" """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: if max_cells <= 0 or len(cells) <= max_cells:
return cells return cells
grouped: list[dict] = [] 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]: 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.""" """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] = [] cells: list[dict] = []
for idx in range(max(0, int(total_chunks or 0))): for idx in range(max(0, int(total_chunks or 0))):
completed = 1 if idx < len(have_bits) and have_bits[idx] else 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: def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
"""Return ruTorrent-like visual chunk data for one torrent.""" """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) c = client_for(profile)
values = { values = {
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)), "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: def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
"""Run safe actions related to visual chunk selection.""" """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 {} payload = payload or {}
action = str(action or "").strip().lower() action = str(action or "").strip().lower()
c = client_for(profile) c = client_for(profile)
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import errno import errno
import os import os
import posixpath import posixpath
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from .client import * from .client import *
RTORRENT_CONFIG_FIELDS = [ RTORRENT_CONFIG_FIELDS = [
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from .client import * from .client import *
from .. import poller_control from .. import poller_control
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from .client import * from .client import *
from ...config import BASE_DIR 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: 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": {}} root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
for item in torrent_files(profile, torrent_hash): for item in torrent_files(profile, torrent_hash):
parts = [part for part in str(item.get("path") or "").split("/") if part] parts = [part for part in str(item.get("path") or "").split("/") if part]
-2
View File
@@ -1,4 +1,2 @@
from __future__ import annotations from __future__ import annotations
# Note: Backward-compatible internal alias for modules created during refactor.
from .client import * from .client import *
-5
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from threading import RLock from threading import RLock
from .client import * from .client import *
from .config import default_download_path from .config import default_download_path
from ...utils import human_size from ...utils import human_size
@@ -10,7 +8,6 @@ from ...utils import human_size
def browse_path(profile: dict, path: str | None = None) -> dict: def browse_path(profile: dict, path: str | None = None) -> dict:
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions.""" """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) c = client_for(profile)
base = _remote_clean_path(path or default_download_path(profile)) base = _remote_clean_path(path or default_download_path(profile))
script = ( script = (
@@ -44,7 +41,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
name, full_path = parts[0], parts[1] name, full_path = parts[0], parts[1]
is_empty = len(parts) > 2 and parts[2] == "1" is_empty = len(parts) > 2 and parts[2] == "1"
if name not in {".", ".."}: 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}) dirs.append({"name": name, "path": full_path, "empty": is_empty})
elif marker == "M" and "\t" in rest: elif marker == "M" and "\t" in rest:
first, second = rest.split("\t", 1) 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 "/" parent = posixpath.dirname(base.rstrip("/")) or "/"
if parent == base: if parent == base:
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 { return {
"path": base, "path": base,
"parent": parent, "parent": parent,
+1 -31
View File
@@ -1,18 +1,14 @@
from __future__ import annotations from __future__ import annotations
import time import time
from .client import * from .client import *
from .files import set_file_priorities from .files import set_file_priorities
from .system import disk_usage_for_default_path from .system import disk_usage_for_default_path
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024 XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
def _parse_xmlrpc_size_limit(value) -> int: def _parse_xmlrpc_size_limit(value) -> int:
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K.""" """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() text = str(value or '').strip().lower()
if not text: if not text:
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
@@ -29,7 +25,6 @@ def _parse_xmlrpc_size_limit(value) -> int:
def xmlrpc_size_limit(profile: dict) -> dict: def xmlrpc_size_limit(profile: dict) -> dict:
"""Return the current rTorrent XML-RPC request size limit.""" """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: try:
raw = client_for(profile).call('network.xmlrpc.size_limit') raw = client_for(profile).call('network.xmlrpc.size_limit')
limit = _parse_xmlrpc_size_limit(raw) 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: 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.""" """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 = [] commands = []
if directory: if directory:
commands.append(f'd.directory.set={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: if age > _POST_CHECK_WATCH_TTL_SECONDS:
_clear_post_check_watch(profile_id, torrent_hash) _clear_post_check_watch(profile_id, torrent_hash)
return False 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 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 "")) labels = _label_names(str(label_source or ""))
if POST_CHECK_DOWNLOAD_LABEL not in labels: if POST_CHECK_DOWNLOAD_LABEL not in labels:
return False 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])) c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
return True 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: if POST_CHECK_DOWNLOAD_LABEL not in labels:
return False return False
status = str(row.get("status") or "").lower() 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" 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): if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
return False 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 "")) 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 "")) row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
return True return True
@@ -183,7 +173,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
complete = _row_progress_complete(row) complete = _row_progress_complete(row)
try: try:
if complete: 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) start_result = start_or_resume_hash(c, h)
clear_post_check_download_label(c, h, str(row.get("label") or "")) 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 ""))}) 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: if POST_CHECK_DOWNLOAD_LABEL not in labels:
labels.append(POST_CHECK_DOWNLOAD_LABEL) labels.append(POST_CHECK_DOWNLOAD_LABEL)
label_value = _label_value(labels) label_value = _label_value(labels)
# Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
c.call("d.stop", h) c.call("d.stop", h)
try: try:
c.call("d.close", h) c.call("d.close", h)
@@ -229,7 +217,6 @@ LIVE_TORRENT_FIELDS = [
def human_duration(seconds: int) -> str: 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)) seconds = max(0, int(seconds or 0))
if seconds <= 0: if seconds <= 0:
return '-' return '-'
@@ -256,12 +243,8 @@ def normalize_row(row: list) -> dict:
base_path = str(row[15] or "") base_path = str(row[15] or "")
state = int(row[2] or 0) state = int(row[2] or 0)
complete = int(row[3] 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 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 != "/": if base_path and base_path != "/":
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/" display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent 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) 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 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): 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()) last_activity = int(time.time())
completed_at = int(row[26] or 0) if len(row) > 26 else 0 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) 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) 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_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 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" 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 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 { return {
"hash": str(row[0] or ""), "hash": str(row[0] or ""),
"name": str(row[1] or ""), "name": str(row[1] or ""),
@@ -338,7 +316,6 @@ def normalize_row(row: list) -> dict:
def normalize_live_row(row: list) -> dict: def normalize_live_row(row: list) -> dict:
"""Normalize the small row used by the fast live stats poller.""" """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) size = int(row[3] or 0)
completed = int(row[4] or 0) completed = int(row[4] or 0)
complete = int(row[2] or 0) complete = int(row[2] or 0)
@@ -406,13 +383,10 @@ def list_torrents(profile: dict) -> list[dict]:
try: try:
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS)) rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
except Exception: except Exception:
# Keep compatibility with older rTorrent builds that do not expose optional timestamp fields.
rows = c.d.multicall2("", "main", *TORRENT_FIELDS) rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
return [normalize_row(list(row)) for row in rows] return [normalize_row(list(row)) for row in rows]
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]: def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
fields = [ fields = [
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", "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 return peers
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict: def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
errors = [] errors = []
for method, args in candidates: for method, args in candidates:
@@ -457,7 +429,6 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d
raise RuntimeError("; ".join(errors)) raise RuntimeError("; ".join(errors))
def _tracker_domain(url: str) -> str: def _tracker_domain(url: str) -> str:
raw = str(url or '').strip() raw = str(url or '').strip()
if not raw: 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: 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.""" """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()] hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
if not hashes: if not hashes:
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')] 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 __future__ import annotations
from collections import Counter from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import json import json
import os import os
import time import time
from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
from ..db import connect, default_user_id, utcnow from ..db import connect, default_user_id, utcnow
from . import rtorrent from . import rtorrent
-2
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import threading import threading
from typing import Any from typing import Any
from ..db import connect, utcnow from ..db import connect, utcnow
from .rtorrent import human_rate from .rtorrent import human_rate
-2
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import threading import threading
from time import monotonic from time import monotonic
from ..db import connect from ..db import connect
from . import operation_logs, rtorrent from . import operation_logs, rtorrent
-2
View File
@@ -1,11 +1,9 @@
from __future__ import annotations from __future__ import annotations
from threading import RLock from threading import RLock
from time import time from time import time
from . import rtorrent, operation_logs 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"} _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"} _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 from __future__ import annotations
import hashlib import hashlib
import os import os
import time import time
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any from typing import Any
-2
View File
@@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import threading import threading
import time import time
from typing import Any from typing import Any
from ..db import connect, utcnow from ..db import connect, utcnow
from . import rtorrent from . import rtorrent
from .torrent_cache import torrent_cache from .torrent_cache import torrent_cache
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from threading import RLock from threading import RLock
from time import time from time import time
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import json import json
import mimetypes import mimetypes
import re import re
@@ -11,7 +10,6 @@ import urllib.parse
import urllib.request import urllib.request
from html.parser import HTMLParser from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
from ..config import BASE_DIR from ..config import BASE_DIR
from ..db import connect, utcnow from ..db import connect, utcnow
-2
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
from ..db import connect, utcnow from ..db import connect, utcnow
from . import retention from . import retention
-12
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import threading import threading
import time import time
import json import json
@@ -17,7 +16,6 @@ def _profile_room(profile_id: int) -> str:
def _poller_profiles() -> list[dict]: 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(): if not auth.enabled():
profile = active_profile() profile = active_profile()
return [profile] if profile else [] 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: 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)} scoped_payload = {**(payload or {}), "profile_id": int(profile_id)}
socketio.emit(event, scoped_payload, to=_profile_room(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) emit_profile_event(socketio, event, payload, profile_id)
def _apply_configured_speed_limits(profile: dict) -> None: def _apply_configured_speed_limits(profile: dict) -> None:
limits = profile_speed_limits.get_limits(int(profile.get("id") or 0)) limits = profile_speed_limits.get_limits(int(profile.get("id") or 0))
if not limits.get("configured"): if not limits.get("configured"):
return 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")) rtorrent.set_limits(profile, limits.get("down"), limits.get("up"))
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
state = poller_control.state_for(profile_id) 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()) profile_user_id = int(profile.get("user_id") or default_user_id())
try: try:
try: try:
@@ -67,7 +60,6 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
except Exception as exc: except Exception as exc:
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id) _emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try: try:
# Note: Automations are profile-scoped; each queued job still runs as the rule owner.
auto_result = automation_rules.check(profile, force=False) auto_result = automation_rules.check(profile, force=False)
if auto_result.get("applied") or auto_result.get("batches"): if auto_result.get("applied") or auto_result.get("batches"):
_emit_profile(socketio, "automation_update", auto_result, profile_id) _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: 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 []) 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 []) up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
return { return {
@@ -184,7 +175,6 @@ def register_socketio_handlers(socketio):
else: else:
skipped_emissions += 1 skipped_emissions += 1
if live.get("requires_full_refresh"): 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 state.last_list_at = 0.0
run_list = True run_list = True
else: else:
@@ -218,7 +208,6 @@ def register_socketio_handlers(socketio):
rtorrent_call_count += 1 rtorrent_call_count += 1
if bool(profile.get("is_remote")): if bool(profile.get("is_remote")):
try: try:
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
usage = rtorrent.remote_system_usage(profile) usage = rtorrent.remote_system_usage(profile)
status.update(usage) status.update(usage)
status["usage_available"] = True status["usage_available"] = True
@@ -272,7 +261,6 @@ def register_socketio_handlers(socketio):
global _started global _started
with _start_lock: with _start_lock:
if not _started: 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) socketio.start_background_task(poller)
_started = True _started = True
+1 -25
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import json import json
import threading import threading
import time import time
@@ -43,7 +42,6 @@ def _emit(name: str, payload: dict):
return return
profile_id = payload.get("profile_id") profile_id = payload.get("profile_id")
if 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)}") _socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
else: else:
_socketio.emit(name, payload) _socketio.emit(name, payload)
@@ -102,7 +100,6 @@ def _job_payload(row) -> dict:
def _is_ordered_job(row) -> bool: def _is_ordered_job(row) -> bool:
payload = _job_payload(row) payload = _job_payload(row)
action = str((row or {}).get("action") or "") 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")) 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 job_id = uuid.uuid4().hex
if force: if force:
payload = dict(payload or {}) 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['force_job'] = True
payload['priority_job'] = True payload['priority_job'] = True
now = utcnow() 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(?,?,?,?,?,?,?,?,?,?,?)", "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), (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) 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"}) _emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
_submit_job(job_id, action_name) _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") source = str(ctx.get("source") or payload.get("source") or "user")
meta = {"source": source} meta = {"source": source}
if source == "automation": if source == "automation":
# Note: Socket operation toasts use this flag so automation notifications respect user preferences.
meta["automation"] = True meta["automation"] = True
meta["source_label"] = str(ctx.get("rule_name") or "automation") meta["source_label"] = str(ctx.get("rule_name") or "automation")
if ctx.get("rule_id") is not None: 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: 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. # Note: Disk usage refreshes only when a remove job actually requested data deletion.
if str(action_name or "") != "remove": 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: def _clear_disk_refresh_cache(profile_id: int) -> None:
try: 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)) rtorrent.clear_profile_runtime_caches(int(profile_id))
except Exception: except Exception:
pass 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: 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) _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", { _emit("disk_refresh_requested", {
"profile_id": int(profile_id), "profile_id": int(profile_id),
"hash_count": int(hash_count or 0), "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) old_timer = _disk_refresh_timers.get(key)
if old_timer: if old_timer:
old_timer.cancel() 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 = threading.Timer(float(delay_seconds), _run_delayed_disk_refresh, args=(profile_id, int(delay_seconds)))
timer.daemon = True timer.daemon = True
_disk_refresh_timers[key] = timer _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): def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
if action_name == "smart_queue_check": if action_name == "smart_queue_check":
from . import smart_queue 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) return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
if action_name == "add_magnet": if action_name == "add_magnet":
if bool(payload.get("start", True)): if bool(payload.get("start", True)):
@@ -363,7 +352,6 @@ def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
else: else:
_emit("rtorrent_error", {**diff, "profile_id": profile_id}) _emit("rtorrent_error", {**diff, "profile_id": profile_id})
except Exception as exc: 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)}) _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 return
def delayed_refresh(): 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) sleep_fn = getattr(_socketio, "sleep", time.sleep)
for delay in (0.75, 1.75): for delay in (0.75, 1.75):
sleep_fn(delay) sleep_fn(delay)
@@ -395,7 +382,6 @@ def _run(job_id: str):
profile = get_profile(int(job["profile_id"]), int(job["user_id"])) profile = get_profile(int(job["profile_id"]), int(job["user_id"]))
if not profile: if not profile:
_set_job(job_id, "failed", "rTorrent profile does not exist", finished=True) _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") 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"}) _emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"})
return return
@@ -422,16 +408,13 @@ def _run(job_id: str):
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts}) _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)) result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0))
fresh = _job_row(job_id) 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": if fresh and fresh["status"] != "running":
return return
_set_job(job_id, "done", result=result, finished=True) _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)) 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}) _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 "") action_name = str(job["action"] or "")
_emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result 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) _emit_torrent_refresh(profile, action_name)
_schedule_delayed_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}) _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
@@ -495,7 +478,6 @@ def _timeout_running_jobs() -> None:
continue continue
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds" message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
_set_job(row["id"], "failed", message, finished=True) _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) 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("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}) _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: if not profile:
continue continue
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at")) 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: if last_seen_ts is not None and now_ts - last_seen_ts < 90:
continue continue
with connect() as conn: with connect() as conn:
@@ -524,7 +505,6 @@ def _resubmit_interrupted_running_jobs() -> None:
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]), ("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
) )
if int(cur.rowcount or 0): 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)) 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}) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True})
_submit_job(row["id"], row.get("action")) _submit_job(row["id"], row.get("action"))
@@ -547,7 +527,6 @@ def _resubmit_stale_pending_jobs() -> None:
continue continue
with connect() as conn: 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"])) 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)) 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}) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True})
_submit_job(row["id"], row.get("action")) _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) count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
parts = [] parts = []
if ctx.get("bulk_label"): 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')}") parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
if count: if count:
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)") 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) row = _job_row(job_id)
if not row or row["status"] not in {"pending", "running"}: if not row or row["status"] not in {"pending", "running"}:
return False 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) _set_job(job_id, "cancelled", finished=True)
payload = _job_payload(row) 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)) 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: def emergency_clear_jobs() -> int:
# Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list.
now = utcnow() now = utcnow()
where, params = _job_scope_sql(writable=True) where, params = _job_scope_sql(writable=True)
status_clause = "status IN ('pending', 'running')" 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; 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 { .planner-current-summary ul {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -5746,39 +5779,6 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
width: 1rem; 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, .diagnostic-separator,
.modal-meta-separator { .modal-meta-separator {
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
from pathlib import Path from pathlib import Path