fix planner

This commit is contained in:
Mateusz Gruszczyński
2026-06-17 09:02:41 +02:00
parent 99692ef217
commit b98505fd31
65 changed files with 82 additions and 279 deletions
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from importlib import import_module
API_ROUTE_MODULES = (
"torrents",
"profiles",
"rss",
"automations",
"smart_queue",
"system",
"backup",
"operation_logs",
"planner",
)
def load_api_route_modules() -> None:
"""Import API route modules so their shared blueprint decorators are registered."""
for module_name in API_ROUTE_MODULES:
import_module(f"{__name__}.{module_name}")
+16 -19
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
import base64
import os
import platform
@@ -19,7 +18,6 @@ import threading
from pathlib import Path
from urllib.parse import quote
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for
# Note: url_for is exported through this shared module for API routes that build temporary in-app links.
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
from ..db import connect, utcnow
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write, require_admin, is_admin
@@ -34,23 +32,33 @@ bp = Blueprint("api", __name__, url_prefix="/api")
MOVE_BULK_MAX_HASHES = 100
from .auth_api import register_auth_routes
register_auth_routes(bp)
def _request_profile_selector() -> tuple[int | None, str]:
"""Return the optional profile selector supplied by external API clients."""
"""Return the optional rTorrent profile selector supplied by external API clients."""
payload = {}
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
try:
payload = request.get_json(silent=True) or {}
except Exception:
payload = {}
profile_id = request.args.get("profile_id") or request.form.get("profile_id") or payload.get("profile_id") or request.headers.get("X-PyTorrent-Profile-Id")
profile_name = request.args.get("profile_name") or request.form.get("profile_name") or payload.get("profile_name") or request.headers.get("X-PyTorrent-Profile-Name") or ""
profile_id = (
request.args.get("profile_id")
or request.form.get("profile_id")
or payload.get("rtorrent_profile_id")
or request.headers.get("X-PyTorrent-Profile-Id")
)
profile_name = (
request.args.get("profile_name")
or request.form.get("profile_name")
or payload.get("rtorrent_profile_name")
or request.headers.get("X-PyTorrent-Profile-Name")
or ""
)
try:
return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip())
except (TypeError, ValueError):
@@ -123,13 +131,9 @@ def ok(payload=None):
return jsonify(data)
from ..services.port_check import port_check_status
def _safe_len(callable_obj) -> int | None:
try:
return len(callable_obj())
@@ -261,13 +265,11 @@ def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict:
def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]:
# Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable.
safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES))
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]:
# Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions.
base_payload = enrich_bulk_payload(profile, action_name, data)
hashes = base_payload.get("hashes") or []
chunks = _chunk_hashes(hashes)
@@ -297,17 +299,14 @@ def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict
def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]:
# Note: Keep the old public move helper while using the same partitioning logic.
return enqueue_bulk_parts(profile, "move", data)
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]:
# Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load.
return enqueue_bulk_parts(profile, "remove", data)
def _user_disk_status(profile: dict) -> dict:
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
try:
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
@@ -321,6 +320,4 @@ def _user_disk_status(profile: dict) -> dict:
)
# Note: Route modules import shared helpers with wildcard imports; include private helper names intentionally.
__all__ = [name for name in globals() if not name.startswith('__')]
+2 -9
View File
@@ -1,15 +1,8 @@
from __future__ import annotations
from ._shared import bp
from . import load_api_route_modules
# Note: Route modules are imported for their decorators; this keeps the public API unchanged.
from . import torrents as _torrents_routes
from . import profiles as _profiles_routes
from . import rss as _rss_routes
from . import automations as _automations_routes
from . import smart_queue as _smart_queue_routes
from . import system as _system_routes
from . import backup as _backup_routes
from . import operation_logs as _operation_logs_routes
load_api_route_modules()
__all__ = ["bp"]
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from flask import abort, jsonify, request
from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from ._shared import *
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from ._shared import *
from ..services import auth
@@ -53,7 +52,6 @@ def backup_create_app():
@bp.post("/backup")
def backup_create():
# Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings.
return backup_create_profile()
-4
View File
@@ -12,8 +12,6 @@ from ..services.preferences import get_preferences, list_profiles, active_profil
from ..services import auth, pdf_preview_links, rtorrent
from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
from ..services.frontend_assets import asset_path
# for favicon
from flask import current_app, send_from_directory
bp = Blueprint("main", __name__)
@@ -24,8 +22,6 @@ def _asset_url(key: str) -> str:
return path if path.startswith("http") else url_for("static", filename=path)
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
safe = Path(download_name or "download.bin").name or "download.bin"
safe_disposition = "inline" if disposition == "inline" else "attachment"
-1
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from ._shared import *
from ..services import operation_logs
+4 -7
View File
@@ -1,14 +1,11 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request
from flask import jsonify, request
from ._shared import request_profile
from ..services import preferences, download_planner, poller_control
from ._shared import bp, request_profile
from ..services import download_planner, poller_control
from ..services.auth import current_user_id
bp = Blueprint("planner_api", __name__, url_prefix="/api")
def ok(payload=None):
data = {"ok": True}
if payload:
@@ -33,7 +30,7 @@ def download_planner_get():
@bp.post("/download-planner")
def download_planner_save():
# Note: Planner settings are saved through one canonical endpoint to avoid hidden frontend/backend fallbacks.
# Note: Planner settings are saved through one canonical endpoint to keep the frontend/backend contract explicit.
profile, error = _profile_or_error()
if error:
return error
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from ._shared import *
from ..services.rtorrent.diagnostics import profile_diagnostics
from ..services import auth
@@ -26,7 +25,6 @@ def profiles_create():
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.put("/profiles/<int:profile_id>")
def profiles_update(profile_id: int):
try:
-2
View File
@@ -1,8 +1,6 @@
from __future__ import annotations
from ._shared import *
def _active_profile_or_400():
profile = request_profile()
if not profile:
+1 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from ._shared import *
@bp.get('/smart-queue')
def smart_queue_get():
from ..services import smart_queue
@@ -19,7 +19,6 @@ def smart_queue_get():
return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []})
@bp.post('/smart-queue')
def smart_queue_save():
from ..services import smart_queue
-3
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from ._shared import *
import posixpath
from ..services import operation_logs
@@ -27,7 +26,6 @@ def system_status():
status["disk"] = _user_disk_status(profile)
if bool(profile.get("is_remote")):
try:
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
usage = rtorrent.remote_system_usage(profile)
status.update(usage)
status["usage_available"] = True
@@ -40,7 +38,6 @@ def system_status():
status["ram"] = psutil.virtual_memory().percent
status["usage_source"] = "local"
status["usage_available"] = True
# Note: REST status returns the latest records without waiting for the next Socket.IO message.
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
return ok({"status": status})
except Exception as exc:
-2
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from ._shared import *
from ..services import profile_speed_limits
from ..services import pdf_preview_links, torrent_creator
@@ -20,7 +19,6 @@ def torrents():
@bp.get("/trackers/summary")
def trackers_summary():
profile = request_profile()