Profile id api #30
@@ -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
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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('__')]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
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 *
|
||||||
from .files import *
|
from .files import *
|
||||||
from .config import *
|
from .config import *
|
||||||
from .torrents import *
|
from .torrents import *
|
||||||
from .chunks import *
|
from .chunks import *
|
||||||
@@ -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,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import errno
|
import errno
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 *
|
||||||
|
|||||||
@@ -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,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')]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
@@ -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,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user