Merge pull request 'Profile id api' (#30) from profile_id_api into master
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1700
-96
File diff suppressed because it is too large
Load Diff
@@ -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}")
|
||||||
+84
-15
@@ -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,11 +18,10 @@ 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
|
||||||
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
|
from ..services import auth, preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
|
||||||
from ..services.torrent_cache import torrent_cache
|
from ..services.torrent_cache import torrent_cache
|
||||||
from ..services.torrent_summary import cached_summary
|
from ..services.torrent_summary import cached_summary
|
||||||
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
|
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
|
||||||
@@ -34,11 +32,93 @@ 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]:
|
||||||
|
"""Return the optional rTorrent profile selector supplied by external API clients."""
|
||||||
|
payload = {}
|
||||||
|
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||||
|
try:
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
profile_id = (
|
||||||
|
request.args.get("profile_id")
|
||||||
|
or request.form.get("profile_id")
|
||||||
|
or payload.get("rtorrent_profile_id")
|
||||||
|
or request.headers.get("X-PyTorrent-Profile-Id")
|
||||||
|
)
|
||||||
|
profile_name = (
|
||||||
|
request.args.get("profile_name")
|
||||||
|
or request.form.get("profile_name")
|
||||||
|
or payload.get("rtorrent_profile_name")
|
||||||
|
or request.headers.get("X-PyTorrent-Profile-Name")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("profile_id must be an integer")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_by_name(profile_name: str, user_id: int | None = None):
|
||||||
|
name = str(profile_name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
user_id = user_id or default_user_id()
|
||||||
|
visible = auth.visible_profile_ids(user_id)
|
||||||
|
with connect() as conn:
|
||||||
|
if visible is None:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||||
|
(name,),
|
||||||
|
).fetchone()
|
||||||
|
if not visible:
|
||||||
|
return None
|
||||||
|
placeholders = ",".join("?" for _ in visible)
|
||||||
|
return conn.execute(
|
||||||
|
f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||||
|
(*tuple(visible), name),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def request_profile(require_write: bool = False):
|
||||||
|
"""Resolve API profile context from profile_id/profile_name, then active profile for compatibility."""
|
||||||
|
try:
|
||||||
|
profile_id, profile_name = _request_profile_selector()
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
user_id = default_user_id()
|
||||||
|
profile = None
|
||||||
|
if profile_id:
|
||||||
|
profile = preferences.get_profile(int(profile_id), user_id)
|
||||||
|
elif profile_name:
|
||||||
|
profile = _profile_by_name(profile_name, user_id)
|
||||||
|
else:
|
||||||
|
profile = preferences.active_profile(user_id)
|
||||||
|
if not profile and auth.can_access_profile(1, user_id):
|
||||||
|
profile = preferences.get_profile(1, user_id)
|
||||||
|
if not profile and (profile_id or profile_name):
|
||||||
|
abort(404)
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
pid = int(profile["id"])
|
||||||
|
if require_write and not auth.can_write_profile(pid, user_id):
|
||||||
|
abort(403)
|
||||||
|
if not require_write and not auth.can_access_profile(pid, user_id):
|
||||||
|
abort(403)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def request_profile_id(require_write: bool = False) -> int | None:
|
||||||
|
profile = request_profile(require_write=require_write)
|
||||||
|
return int(profile["id"]) if profile else None
|
||||||
|
|
||||||
|
|
||||||
def _job_profile_id(job_id: str) -> int | None:
|
def _job_profile_id(job_id: str) -> int | None:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
|
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
@@ -51,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())
|
||||||
@@ -189,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)
|
||||||
@@ -225,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 []
|
||||||
@@ -249,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 *
|
||||||
|
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ def _automation_user_id() -> int:
|
|||||||
@bp.get('/automations')
|
@bp.get('/automations')
|
||||||
def automations_get():
|
def automations_get():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'rules': [], 'history': [], 'error': 'No profile'})
|
return ok({'rules': [], 'history': [], 'error': 'No profile'})
|
||||||
try:
|
try:
|
||||||
@@ -26,7 +25,7 @@ def automations_get():
|
|||||||
@bp.get('/automations/export')
|
@bp.get('/automations/export')
|
||||||
def automations_export():
|
def automations_export():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -39,7 +38,7 @@ def automations_export():
|
|||||||
@bp.post('/automations/import')
|
@bp.post('/automations/import')
|
||||||
def automations_import():
|
def automations_import():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -55,7 +54,7 @@ def automations_import():
|
|||||||
@bp.post('/automations')
|
@bp.post('/automations')
|
||||||
def automations_save():
|
def automations_save():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -69,7 +68,7 @@ def automations_save():
|
|||||||
@bp.delete('/automations/<int:rule_id>')
|
@bp.delete('/automations/<int:rule_id>')
|
||||||
def automations_delete(rule_id: int):
|
def automations_delete(rule_id: int):
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -83,7 +82,7 @@ def automations_delete(rule_id: int):
|
|||||||
@bp.post('/automations/<int:rule_id>/run')
|
@bp.post('/automations/<int:rule_id>/run')
|
||||||
def automations_run_rule(rule_id: int):
|
def automations_run_rule(rule_id: int):
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -100,7 +99,7 @@ def automations_run_rule(rule_id: int):
|
|||||||
@bp.post('/automations/check')
|
@bp.post('/automations/check')
|
||||||
def automations_check():
|
def automations_check():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -117,7 +116,7 @@ def automations_check():
|
|||||||
@bp.delete('/automations/history')
|
@bp.delete('/automations/history')
|
||||||
def automations_history_clear():
|
def automations_history_clear():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
from ..services import auth
|
from ..services import auth
|
||||||
|
|
||||||
|
|
||||||
def _active_profile_id() -> int | None:
|
def _active_profile_id(require_write: bool = False) -> int | None:
|
||||||
profile = preferences.active_profile()
|
profile = request_profile(require_write=require_write)
|
||||||
return int(profile["id"]) if profile else None
|
return int(profile["id"]) if profile else None
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ def backup_list():
|
|||||||
@bp.post("/backup/profile")
|
@bp.post("/backup/profile")
|
||||||
def backup_create_profile():
|
def backup_create_profile():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
pid = _active_profile_id()
|
pid = _active_profile_id(require_write=True)
|
||||||
if not pid:
|
if not pid:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ def profile_backup_settings_get():
|
|||||||
@bp.post("/backup/profile/settings")
|
@bp.post("/backup/profile/settings")
|
||||||
def profile_backup_settings_save():
|
def profile_backup_settings_save():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
pid = _active_profile_id()
|
pid = _active_profile_id(require_write=True)
|
||||||
if not pid:
|
if not pid:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -104,7 +102,7 @@ def backup_preview(backup_id: int):
|
|||||||
@bp.post("/backup/<int:backup_id>/restore")
|
@bp.post("/backup/<int:backup_id>/restore")
|
||||||
def backup_restore(backup_id: int):
|
def backup_restore(backup_id: int):
|
||||||
try:
|
try:
|
||||||
pid = _active_profile_id()
|
pid = _active_profile_id(require_write=True)
|
||||||
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
|
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
||||||
|
|||||||
@@ -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,11 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
from ..services import operation_logs
|
from ..services import operation_logs
|
||||||
|
|
||||||
|
|
||||||
def _active_profile_or_400():
|
def _active_profile_or_400():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return None
|
return None
|
||||||
return profile
|
return profile
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import jsonify, request
|
||||||
|
|
||||||
from ..services import preferences, download_planner, poller_control
|
from ._shared import bp, request_profile
|
||||||
|
from ..services import download_planner, poller_control
|
||||||
from ..services.auth import current_user_id
|
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:
|
||||||
@@ -16,7 +14,7 @@ def ok(payload=None):
|
|||||||
|
|
||||||
|
|
||||||
def _profile_or_error():
|
def _profile_or_error():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
|
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
|
||||||
return profile, None
|
return profile, None
|
||||||
@@ -32,6 +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 keep the frontend/backend contract explicit.
|
||||||
profile, error = _profile_or_error()
|
profile, error = _profile_or_error()
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
@@ -95,7 +94,8 @@ def poller_settings_get():
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
pid = int(profile["id"])
|
pid = int(profile["id"])
|
||||||
return ok({"settings": poller_control.get_settings(pid), "runtime": poller_control.snapshot(pid)})
|
settings = poller_control.get_settings(pid)
|
||||||
|
return ok({"settings": settings, "runtime": poller_control.snapshot(pid, settings)})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/poller/settings")
|
@bp.post("/poller/settings")
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
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
|
||||||
|
|
||||||
@bp.get("/profiles")
|
@bp.get("/profiles")
|
||||||
def profiles_list():
|
def profiles_list():
|
||||||
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
|
profiles = []
|
||||||
|
for row in preferences.list_profiles():
|
||||||
|
item = dict(row)
|
||||||
|
settings = backup_service.get_auto_backup_settings(default_user_id(), "profile", int(item.get("id") or 0))
|
||||||
|
item["profile_backup_enabled"] = bool(settings.get("enabled"))
|
||||||
|
item["profile_backup_interval_hours"] = settings.get("interval_hours")
|
||||||
|
item["profile_backup_retention_days"] = settings.get("retention_days")
|
||||||
|
profiles.append(item)
|
||||||
|
return ok({"profiles": profiles, "active": preferences.active_profile()})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -18,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:
|
||||||
@@ -89,25 +95,25 @@ def profiles_import():
|
|||||||
|
|
||||||
@bp.get("/preferences")
|
@bp.get("/preferences")
|
||||||
def prefs_get():
|
def prefs_get():
|
||||||
return ok({"preferences": preferences.get_preferences()})
|
return ok({"preferences": preferences.get_preferences(profile_id=request_profile_id())})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/preferences")
|
@bp.post("/preferences")
|
||||||
def prefs_save():
|
def prefs_save():
|
||||||
return ok({"preferences": preferences.save_preferences(request.json or {})})
|
return ok({"preferences": preferences.save_preferences(request.json or {}, profile_id=request_profile_id(require_write=True))})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/preferences/table-columns/recommended")
|
@bp.post("/preferences/table-columns/recommended")
|
||||||
def prefs_table_columns_recommended():
|
def prefs_table_columns_recommended():
|
||||||
# Note: Applies the backend-owned recommended desktop and mobile column layout.
|
# Note: Applies the backend-owned recommended desktop and mobile column layout.
|
||||||
return ok({"preferences": preferences.apply_recommended_table_columns()})
|
return ok({"preferences": preferences.apply_recommended_table_columns(profile_id=request_profile_id(require_write=True))})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/labels")
|
@bp.get("/labels")
|
||||||
def labels_list():
|
def labels_list():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
pid = profile["id"] if profile else None
|
pid = profile["id"] if profile else None
|
||||||
if not pid:
|
if not pid:
|
||||||
return ok({"labels": []})
|
return ok({"labels": []})
|
||||||
@@ -128,7 +134,7 @@ def labels_list():
|
|||||||
|
|
||||||
@bp.post("/labels")
|
@bp.post("/labels")
|
||||||
def labels_save():
|
def labels_save():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -150,7 +156,7 @@ def labels_save():
|
|||||||
|
|
||||||
@bp.delete("/labels/<int:label_id>")
|
@bp.delete("/labels/<int:label_id>")
|
||||||
def labels_delete(label_id: int):
|
def labels_delete(label_id: int):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
pid = profile["id"] if profile else None
|
pid = profile["id"] if profile else None
|
||||||
if not pid or not auth.can_write_profile(int(pid), default_user_id()):
|
if not pid or not auth.can_write_profile(int(pid), default_user_id()):
|
||||||
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||||
@@ -162,7 +168,7 @@ def labels_delete(label_id: int):
|
|||||||
|
|
||||||
@bp.get("/ratio-groups")
|
@bp.get("/ratio-groups")
|
||||||
def ratio_groups_list():
|
def ratio_groups_list():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
pid = profile["id"] if profile else None
|
pid = profile["id"] if profile else None
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -182,7 +188,7 @@ def ratio_groups_list():
|
|||||||
|
|
||||||
@bp.post("/ratio-groups")
|
@bp.post("/ratio-groups")
|
||||||
def ratio_groups_save():
|
def ratio_groups_save():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -210,9 +216,25 @@ def ratio_groups_save():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/ratio-groups/<int:group_id>")
|
||||||
|
def ratio_groups_delete(group_id: int):
|
||||||
|
profile = request_profile()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
if not auth.can_write_profile(int(profile["id"]), default_user_id()):
|
||||||
|
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||||
|
with connect() as conn:
|
||||||
|
# Note: Deleting a ratio group removes only the group definition and its assignment links; history stays as an audit trail.
|
||||||
|
deleted = conn.execute("DELETE FROM ratio_groups WHERE id=? AND profile_id=?", (int(group_id), int(profile["id"]))).rowcount
|
||||||
|
conn.execute("DELETE FROM ratio_assignments WHERE group_id=? AND profile_id=?", (int(group_id), int(profile["id"])))
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({"ok": False, "error": "Ratio group not found"}), 404
|
||||||
|
return ratio_groups_list()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/ratio-groups/check")
|
@bp.post("/ratio-groups/check")
|
||||||
def ratio_groups_check():
|
def ratio_groups_check():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok({"result": ratio_rules.check(profile, default_user_id())})
|
return ok({"result": ratio_rules.check(profile, default_user_id())})
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
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 = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return None
|
return None
|
||||||
return profile
|
return profile
|
||||||
@@ -117,7 +115,7 @@ def rss_rule_test():
|
|||||||
|
|
||||||
@bp.post("/rss/check")
|
@bp.post("/rss/check")
|
||||||
def rss_check():
|
def rss_check():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok(rss_service.check(profile, only_due=False))
|
return ok(rss_service.check(profile, only_due=False))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
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
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
|
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
|
||||||
try:
|
try:
|
||||||
@@ -19,11 +19,10 @@ 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
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'settings': {}, 'error': 'No profile'})
|
return ok({'settings': {}, 'error': 'No profile'})
|
||||||
try:
|
try:
|
||||||
@@ -37,7 +36,7 @@ def smart_queue_save():
|
|||||||
|
|
||||||
@bp.post('/smart-queue/check')
|
@bp.post('/smart-queue/check')
|
||||||
def smart_queue_check():
|
def smart_queue_check():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'result': {'ok': False, 'error': 'No profile'}})
|
return ok({'result': {'ok': False, 'error': 'No profile'}})
|
||||||
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
|
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
|
||||||
@@ -66,7 +65,7 @@ def smart_queue_check():
|
|||||||
@bp.post('/smart-queue/exclusion')
|
@bp.post('/smart-queue/exclusion')
|
||||||
def smart_queue_exclusion():
|
def smart_queue_exclusion():
|
||||||
from ..services import smart_queue
|
from ..services import smart_queue
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -79,7 +78,7 @@ def smart_queue_exclusion():
|
|||||||
@bp.delete('/smart-queue/history')
|
@bp.delete('/smart-queue/history')
|
||||||
def smart_queue_history_clear():
|
def smart_queue_history_clear():
|
||||||
from ..services import smart_queue
|
from ..services import smart_queue
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
|
|||||||
+25
-22
@@ -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
|
||||||
@@ -7,7 +6,7 @@ from ..services.frontend_assets import static_hash
|
|||||||
|
|
||||||
@bp.get("/system/disk")
|
@bp.get("/system/disk")
|
||||||
def system_disk():
|
def system_disk():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"})
|
return jsonify({"ok": False, "error": "No profile"})
|
||||||
try:
|
try:
|
||||||
@@ -19,7 +18,7 @@ def system_disk():
|
|||||||
|
|
||||||
@bp.get("/system/status")
|
@bp.get("/system/status")
|
||||||
def system_status():
|
def system_status():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"})
|
return jsonify({"ok": False, "error": "No profile"})
|
||||||
try:
|
try:
|
||||||
@@ -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:
|
||||||
@@ -80,7 +77,7 @@ def health_check_nagios():
|
|||||||
@bp.get("/app/status")
|
@bp.get("/app/status")
|
||||||
def app_status():
|
def app_status():
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
proc = psutil.Process(os.getpid())
|
proc = psutil.Process(os.getpid())
|
||||||
try:
|
try:
|
||||||
jobs = list_jobs(10, 0)
|
jobs = list_jobs(10, 0)
|
||||||
@@ -120,6 +117,12 @@ def app_status():
|
|||||||
status["speed_peaks"] = speed_peaks.current(profile["id"])
|
status["speed_peaks"] = speed_peaks.current(profile["id"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status["speed_peaks"] = {"error": str(exc)}
|
status["speed_peaks"] = {"error": str(exc)}
|
||||||
|
try:
|
||||||
|
# Note: App status carries poller settings and runtime so the panel still renders when the separate poller endpoint is unavailable.
|
||||||
|
poller_settings = poller_control.get_settings(int(profile["id"]))
|
||||||
|
status["poller"] = {"settings": poller_settings, "runtime": poller_control.snapshot(int(profile["id"]), poller_settings)}
|
||||||
|
except Exception as exc:
|
||||||
|
status["poller"] = {"settings": {}, "runtime": {}, "error": str(exc)}
|
||||||
try:
|
try:
|
||||||
prefs = preferences.get_preferences()
|
prefs = preferences.get_preferences()
|
||||||
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
|
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
|
||||||
@@ -178,7 +181,7 @@ def cleanup_status():
|
|||||||
|
|
||||||
@bp.post("/cleanup/cache")
|
@bp.post("/cleanup/cache")
|
||||||
def cleanup_profile_cache():
|
def cleanup_profile_cache():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
@@ -225,7 +228,7 @@ def cleanup_database_vacuum():
|
|||||||
|
|
||||||
@bp.post("/cleanup/smart-queue")
|
@bp.post("/cleanup/smart-queue")
|
||||||
def cleanup_smart_queue():
|
def cleanup_smart_queue():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
@@ -243,7 +246,7 @@ def cleanup_smart_queue():
|
|||||||
|
|
||||||
@bp.post("/cleanup/operation-logs")
|
@bp.post("/cleanup/operation-logs")
|
||||||
def cleanup_operation_logs():
|
def cleanup_operation_logs():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
|
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
|
||||||
@@ -254,7 +257,7 @@ def cleanup_operation_logs():
|
|||||||
|
|
||||||
@bp.post("/cleanup/planner")
|
@bp.post("/cleanup/planner")
|
||||||
def cleanup_planner():
|
def cleanup_planner():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
|
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
|
||||||
@@ -264,7 +267,7 @@ def cleanup_planner():
|
|||||||
|
|
||||||
@bp.post("/cleanup/automations")
|
@bp.post("/cleanup/automations")
|
||||||
def cleanup_automations():
|
def cleanup_automations():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
@@ -284,7 +287,7 @@ def cleanup_automations():
|
|||||||
|
|
||||||
@bp.post("/cleanup/poller-diagnostics")
|
@bp.post("/cleanup/poller-diagnostics")
|
||||||
def cleanup_poller_diagnostics():
|
def cleanup_poller_diagnostics():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
@@ -295,7 +298,7 @@ def cleanup_poller_diagnostics():
|
|||||||
@bp.post("/cleanup/all")
|
@bp.post("/cleanup/all")
|
||||||
def cleanup_all():
|
def cleanup_all():
|
||||||
deleted_jobs = clear_jobs()
|
deleted_jobs = clear_jobs()
|
||||||
active_profile = preferences.active_profile()
|
active_profile = request_profile()
|
||||||
active_profile_id = int(active_profile["id"]) if active_profile else 0
|
active_profile_id = int(active_profile["id"]) if active_profile else 0
|
||||||
deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0
|
deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0
|
||||||
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
|
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
|
||||||
@@ -371,7 +374,7 @@ def _annotate_path_directories(profile: dict, payload: dict) -> dict:
|
|||||||
|
|
||||||
@bp.get("/path/default")
|
@bp.get("/path/default")
|
||||||
def path_default():
|
def path_default():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -383,7 +386,7 @@ def path_default():
|
|||||||
|
|
||||||
@bp.get("/path/browse")
|
@bp.get("/path/browse")
|
||||||
def path_browse():
|
def path_browse():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
base = request.args.get("path") or ""
|
base = request.args.get("path") or ""
|
||||||
@@ -395,7 +398,7 @@ def path_browse():
|
|||||||
|
|
||||||
@bp.post("/path/directories")
|
@bp.post("/path/directories")
|
||||||
def path_directory_create():
|
def path_directory_create():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
require_profile_write(profile.get("id"))
|
require_profile_write(profile.get("id"))
|
||||||
@@ -410,7 +413,7 @@ def path_directory_create():
|
|||||||
|
|
||||||
@bp.post("/path/directories/rename")
|
@bp.post("/path/directories/rename")
|
||||||
def path_directory_rename():
|
def path_directory_rename():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
require_profile_write(profile.get("id"))
|
require_profile_write(profile.get("id"))
|
||||||
@@ -429,7 +432,7 @@ def path_directory_rename():
|
|||||||
|
|
||||||
@bp.get('/rtorrent-config')
|
@bp.get('/rtorrent-config')
|
||||||
def rtorrent_config_get():
|
def rtorrent_config_get():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -440,7 +443,7 @@ def rtorrent_config_get():
|
|||||||
|
|
||||||
@bp.post('/rtorrent-config')
|
@bp.post('/rtorrent-config')
|
||||||
def rtorrent_config_save():
|
def rtorrent_config_save():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -457,7 +460,7 @@ def rtorrent_config_save():
|
|||||||
|
|
||||||
@bp.post('/rtorrent-config/reset')
|
@bp.post('/rtorrent-config/reset')
|
||||||
def rtorrent_config_reset():
|
def rtorrent_config_reset():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -468,7 +471,7 @@ def rtorrent_config_reset():
|
|||||||
|
|
||||||
@bp.post('/rtorrent-config/generate')
|
@bp.post('/rtorrent-config/generate')
|
||||||
def rtorrent_config_generate():
|
def rtorrent_config_generate():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -481,7 +484,7 @@ def rtorrent_config_generate():
|
|||||||
@bp.get('/traffic/history')
|
@bp.get('/traffic/history')
|
||||||
def traffic_history_get():
|
def traffic_history_get():
|
||||||
from ..services import traffic_history
|
from ..services import traffic_history
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
|
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
|
||||||
range_name = request.args.get('range') or '7d'
|
range_name = request.args.get('range') or '7d'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,7 +6,7 @@ from ..services.reverse_dns import attach_reverse_dns
|
|||||||
|
|
||||||
@bp.get("/torrents")
|
@bp.get("/torrents")
|
||||||
def torrents():
|
def torrents():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
|
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
|
||||||
rows = torrent_cache.snapshot(profile["id"])
|
rows = torrent_cache.snapshot(profile["id"])
|
||||||
@@ -20,10 +19,9 @@ def torrents():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/trackers/summary")
|
@bp.get("/trackers/summary")
|
||||||
def trackers_summary():
|
def trackers_summary():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
|
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
|
||||||
try:
|
try:
|
||||||
@@ -78,7 +76,7 @@ def tracker_favicon_query():
|
|||||||
|
|
||||||
@bp.get("/torrent-stats")
|
@bp.get("/torrent-stats")
|
||||||
def torrent_stats_get():
|
def torrent_stats_get():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({"stats": {}, "error": "No profile"})
|
return ok({"stats": {}, "error": "No profile"})
|
||||||
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
|
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
|
||||||
@@ -92,7 +90,7 @@ def torrent_stats_get():
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/files")
|
@bp.get("/torrents/<torrent_hash>/files")
|
||||||
def torrent_files(torrent_hash: str):
|
def torrent_files(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
|
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
|
||||||
@@ -101,7 +99,7 @@ def torrent_files(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
|
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
|
||||||
def torrent_file_media_info(torrent_hash: str, file_index: int):
|
def torrent_file_media_info(torrent_hash: str, file_index: int):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -124,7 +122,7 @@ def torrent_file_media_info(torrent_hash: str, file_index: int):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/priority")
|
@bp.post("/torrents/<torrent_hash>/files/priority")
|
||||||
def torrent_file_priority(torrent_hash: str):
|
def torrent_file_priority(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -139,7 +137,7 @@ def torrent_file_priority(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/files/tree")
|
@bp.get("/torrents/<torrent_hash>/files/tree")
|
||||||
def torrent_file_tree(torrent_hash: str):
|
def torrent_file_tree(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
|
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
|
||||||
@@ -148,7 +146,7 @@ def torrent_file_tree(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
|
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
|
||||||
def torrent_folder_priority(torrent_hash: str):
|
def torrent_folder_priority(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -214,7 +212,7 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
|
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
|
||||||
def torrent_file_download_link(torrent_hash: str, file_index: int):
|
def torrent_file_download_link(torrent_hash: str, file_index: int):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -238,7 +236,7 @@ def torrent_file_download_link_from_body(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
|
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
|
||||||
def torrent_files_download_zip_link(torrent_hash: str):
|
def torrent_files_download_zip_link(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -254,7 +252,7 @@ def torrent_files_download_zip_link(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
|
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
|
||||||
def torrent_file_export_link(torrent_hash: str):
|
def torrent_file_export_link(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -267,7 +265,7 @@ def torrent_file_export_link(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/torrent-files.zip/link")
|
@bp.post("/torrents/torrent-files.zip/link")
|
||||||
def torrent_files_export_zip_link():
|
def torrent_files_export_zip_link():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -284,7 +282,7 @@ def torrent_files_export_zip_link():
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
|
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
|
||||||
def torrent_file_download(torrent_hash: str, file_index: int):
|
def torrent_file_download(torrent_hash: str, file_index: int):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -377,7 +375,7 @@ def _stream_torrent_files_zip(profile: dict, items: list[dict]):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/download.zip")
|
@bp.post("/torrents/<torrent_hash>/files/download.zip")
|
||||||
def torrent_files_download_zip(torrent_hash: str):
|
def torrent_files_download_zip(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -393,7 +391,7 @@ def torrent_files_download_zip(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/torrent-file")
|
@bp.get("/torrents/<torrent_hash>/torrent-file")
|
||||||
def torrent_file_export(torrent_hash: str):
|
def torrent_file_export(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -406,7 +404,7 @@ def torrent_file_export(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/torrent-files.zip")
|
@bp.post("/torrents/torrent-files.zip")
|
||||||
def torrent_files_export_zip():
|
def torrent_files_export_zip():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -455,7 +453,7 @@ def torrent_files_export_zip():
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/chunks")
|
@bp.get("/torrents/<torrent_hash>/chunks")
|
||||||
def torrent_chunks(torrent_hash: str):
|
def torrent_chunks(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -467,7 +465,7 @@ def torrent_chunks(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
|
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
|
||||||
def torrent_chunk_action(torrent_hash: str, action_name: str):
|
def torrent_chunk_action(torrent_hash: str, action_name: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -480,7 +478,7 @@ def torrent_chunk_action(torrent_hash: str, action_name: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/peers")
|
@bp.get("/torrents/<torrent_hash>/peers")
|
||||||
def torrent_peers(torrent_hash: str):
|
def torrent_peers(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
||||||
@@ -496,7 +494,7 @@ def torrent_peers(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/trackers")
|
@bp.get("/torrents/<torrent_hash>/trackers")
|
||||||
def torrent_trackers(torrent_hash: str):
|
def torrent_trackers(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
|
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
|
||||||
@@ -505,7 +503,7 @@ def torrent_trackers(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
|
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
|
||||||
def torrent_tracker_action(torrent_hash: str, action_name: str):
|
def torrent_tracker_action(torrent_hash: str, action_name: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -518,7 +516,7 @@ def torrent_tracker_action(torrent_hash: str, action_name: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<action_name>")
|
@bp.post("/torrents/<action_name>")
|
||||||
def torrent_action(action_name: str):
|
def torrent_action(action_name: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -547,7 +545,7 @@ def torrent_action(action_name: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/create")
|
@bp.post("/torrents/create")
|
||||||
def torrent_create():
|
def torrent_create():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
|
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
|
||||||
@@ -577,7 +575,7 @@ def torrent_create():
|
|||||||
|
|
||||||
@bp.post("/torrents/add")
|
@bp.post("/torrents/add")
|
||||||
def torrent_add():
|
def torrent_add():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
job_ids = []
|
job_ids = []
|
||||||
@@ -634,7 +632,7 @@ def torrent_add():
|
|||||||
|
|
||||||
@bp.post("/torrents/preview")
|
@bp.post("/torrents/preview")
|
||||||
def torrent_preview():
|
def torrent_preview():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
existing_hashes = set()
|
existing_hashes = set()
|
||||||
if profile:
|
if profile:
|
||||||
try:
|
try:
|
||||||
@@ -664,7 +662,7 @@ def torrent_preview():
|
|||||||
|
|
||||||
@bp.post("/speed/limits")
|
@bp.post("/speed/limits")
|
||||||
def speed_limits():
|
def speed_limits():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
|||||||
+37
-12
@@ -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()
|
||||||
@@ -728,12 +720,45 @@ def install_guards(app) -> None:
|
|||||||
def _request_profile_id() -> int | None:
|
def _request_profile_id() -> int | None:
|
||||||
if request.view_args and request.view_args.get("profile_id"):
|
if request.view_args and request.view_args.get("profile_id"):
|
||||||
return int(request.view_args["profile_id"])
|
return int(request.view_args["profile_id"])
|
||||||
|
payload = {}
|
||||||
try:
|
try:
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
if payload.get("profile_id"):
|
|
||||||
return int(payload.get("profile_id"))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
payload = {}
|
||||||
|
raw_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")
|
||||||
|
)
|
||||||
|
if raw_id not in (None, ""):
|
||||||
|
try:
|
||||||
|
return int(raw_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
raw_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")
|
||||||
|
)
|
||||||
|
if raw_name:
|
||||||
|
from . import preferences
|
||||||
|
visible = visible_profile_ids(current_user_id())
|
||||||
|
with connect() as conn:
|
||||||
|
if visible is None:
|
||||||
|
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", (str(raw_name).strip(),)).fetchone()
|
||||||
|
elif visible:
|
||||||
|
placeholders = ",".join("?" for _ in visible)
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT id FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||||
|
(*tuple(visible), str(raw_name).strip()),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = None
|
||||||
|
return int(row["id"]) if row else None
|
||||||
from . import preferences
|
from . import preferences
|
||||||
profile = preferences.active_profile()
|
profile = preferences.active_profile()
|
||||||
return int(profile["id"]) if profile else None
|
if profile:
|
||||||
|
return int(profile["id"])
|
||||||
|
return 1 if can_access_profile(1) else None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -175,8 +174,8 @@ def create_app_backup(name: str, user_id: int | None = None, automatic: bool = F
|
|||||||
|
|
||||||
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
|
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
if not auth.can_access_profile(profile_id, user_id):
|
if not auth.can_write_profile(profile_id, user_id):
|
||||||
raise PermissionError("No access to profile")
|
raise PermissionError("No write access to profile")
|
||||||
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
for table in PROFILE_BACKUP_TABLES:
|
for table in PROFILE_BACKUP_TABLES:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -385,10 +378,19 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
|
|||||||
return dict(state.stats)
|
return dict(state.stats)
|
||||||
|
|
||||||
|
|
||||||
def snapshot(profile_id: int) -> dict:
|
def snapshot(profile_id: int, settings: dict | None = None) -> dict:
|
||||||
state = state_for(profile_id)
|
state = state_for(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})
|
||||||
# Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats.
|
runtime_ready = bool(state.stats) or state.tick_count > 0
|
||||||
|
data.setdefault("runtime_ready", runtime_ready)
|
||||||
|
data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"])))
|
||||||
|
data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting"))
|
||||||
|
data.setdefault("live_stats_interval_seconds", effective_live_interval(effective_settings, state))
|
||||||
|
data.setdefault("torrent_list_interval_seconds", effective_list_interval(effective_settings, state))
|
||||||
|
data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS)
|
||||||
|
if not runtime_ready:
|
||||||
|
data["last_ok"] = None
|
||||||
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"],
|
||||||
@@ -491,9 +488,9 @@ def get_preferences(user_id: int | None = None, profile_id: int | None = None):
|
|||||||
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
def save_preferences(data: dict, user_id: int | None = None):
|
def save_preferences(data: dict, user_id: int | None = None, profile_id: int | None = None):
|
||||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
profile_id = _active_profile_id_for_user(user_id)
|
profile_id = profile_id or _active_profile_id_for_user(user_id)
|
||||||
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
|
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
|
||||||
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
|
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
|
||||||
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
|
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const rt=poller.runtime||{}, ps=poller.settings||{};\n // Note: App status now keeps only unique operational diagnostics; storage, jobs, planner and queue details stay in their dedicated tools.\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'),\n diagCard('Mode', rt.adaptive_mode||'-'),\n diagCard('Live interval', `${rt.live_stats_interval_seconds ?? ps.live_stats_interval_seconds ?? '-'}s`),\n diagCard('List interval', `${rt.torrent_list_interval_seconds ?? ps.torrent_list_interval_seconds ?? '-'}s`),\n diagCard('Last tick', `${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', rt.rtorrent_call_count||0)\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`<div class=\"column-manager-tabs appstatus-tabs\"><ul class=\"nav nav-pills\">${panes.map((p,i)=>`<li class=\"nav-item\"><button class=\"nav-link ${i?'':'active'}\" type=\"button\" data-appstatus-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`<div class=\"appstatus-pane ${i?'d-none':''}\" data-appstatus-panel=\"${p[0]}\">${p[2]}</div>`).join('')}${scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''}`;\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';";
|
export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,pollerResponse]=await Promise.all([\n fetch('/api/app/status',{cache:'no-store'}).then(r=>r.json()),\n fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const pollerBundle=(pollerResponse && pollerResponse.ok!==false) ? pollerResponse : (st.poller||{});\n const rt=pollerBundle.runtime||{}, ps=pollerBundle.settings||{};\n // Note: App status uses embedded poller data as a fallback, so one failing endpoint cannot leave Runtime poller empty.\n const intervalValue=(runtimeKey,settingsKey)=>rt[runtimeKey] ?? ps[settingsKey] ?? '-';\n const runtimeReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0 || Number(rt.last_tick_ms||0)>0);\n const waiting=!runtimeReady && rt.runtime_ready===false;\n const mode=waiting?'waiting':(rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal'));\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', mode),\n diagCard('Live interval', `${intervalValue('live_stats_interval_seconds','live_stats_interval_seconds')}s`),\n diagCard('List interval', `${intervalValue('torrent_list_interval_seconds','torrent_list_interval_seconds')}s`),\n diagCard('Last tick', waiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', waiting?'waiting':`${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', waiting?'waiting':fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', waiting?'waiting':(rt.rtorrent_call_count||0))\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`<div class=\"column-manager-tabs appstatus-tabs\"><ul class=\"nav nav-pills\">${panes.map((p,i)=>`<li class=\"nav-item\"><button class=\"nav-link ${i?'':'active'}\" type=\"button\" data-appstatus-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`<div class=\"appstatus-pane ${i?'d-none':''}\" data-appstatus-panel=\"${p[0]}\">${p[2]}</div>`).join('')}${scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''}`;\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';";
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
|
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0);\n const pollerWaiting=!pollerReady && rt.runtime_ready===false;\n const pollerCards=[diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'), diagCard('Mode', pollerWaiting?'waiting':(rt.adaptive_mode||'-')), diagCard('Effective interval', `${rt.effective_interval_seconds??rt.live_stats_interval_seconds??ps.live_stats_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', pollerWaiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', pollerWaiting?'waiting':`${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', pollerWaiting?'waiting':fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', pollerWaiting?'waiting':(rt.rtorrent_call_count||0)), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
export const profileListSource = " function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=profileDiagnosticStatusLabel(d.status || 'unknown'); const cls=profileDiagnosticStatusClass(st); const response=d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` · slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; return `<div class=\"profile-row ${isActive?'active':''}\" data-profile-id=\"${esc(p.id)}\" aria-current=\"${isActive?'true':'false'}\"><b>${esc(p.name)} <span data-active-profile-badge class='badge text-bg-primary ms-1 ${isActive?'':'d-none'}'>active</span> ${p.is_remote?\"<span class='badge text-bg-secondary ms-1'>remote</span>\":''} <span class=\"badge text-bg-${cls}\">${esc(st)}</span></b><span>${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}</span><div class=\"profile-actions\"><button class=\"btn btn-xs btn-outline-primary\" data-use-profile=\"${p.id}\"><i class=\"fa-solid fa-plug-circle-check\"></i> use</button><button class=\"btn btn-xs btn-outline-info\" data-test-saved-profile=\"${p.id}\" title=\"Diagnostics\"><i class=\"fa-solid fa-stethoscope\"></i></button><button class=\"btn btn-xs btn-outline-secondary\" data-edit-profile=\"${p.id}\" title=\"Edit\"><i class=\"fa-solid fa-pen-to-square\"></i></button><button class=\"btn btn-xs btn-outline-danger\" data-del-profile=\"${p.id}\" title=\"Delete\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div></div>`; }).join('')||'No profiles.'; }\n";
|
export const profileListSource = " function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){\n const j=await (await fetch('/api/profiles')).json();\n profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p]));\n const active=String(j.active?.id ?? activeProfileId ?? '');\n const rows=j.profiles||[];\n const statusMap=new Map();\n try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){}\n $('profileList').innerHTML=rows.map(p=>{\n const d=statusMap.get(String(p.id))||{};\n const st=profileDiagnosticStatusLabel(d.status || 'unknown');\n const cls=profileDiagnosticStatusClass(st);\n const response=d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:'';\n const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:'';\n const isActive=String(p.id)===active;\n const backupIcon=p.profile_backup_enabled?`<span class=\"profile-backup-icon\" title=\"Automatic profile backup enabled\" aria-label=\"Automatic profile backup enabled\"><i class=\"fa-solid fa-floppy-disk\"></i></span>`:'';\n return `<div class=\"profile-row ${isActive?'active':''}\" data-profile-id=\"${esc(p.id)}\" aria-current=\"${isActive?'true':'false'}\"><b><span class=\"profile-id-badge\">#${esc(p.id)}</span> ${esc(p.name)} <span data-active-profile-badge class='badge text-bg-primary ms-1 ${isActive?'':'d-none'}'>active</span> ${p.is_remote?\"<span class='badge text-bg-secondary ms-1'>remote</span>\":''} <span class=\"badge text-bg-${cls}\">${esc(st)}</span></b><span>ID ${esc(p.id)} \u00b7 ${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}</span><div class=\"profile-actions\">${backupIcon}<button class=\"btn btn-xs btn-outline-primary\" data-use-profile=\"${p.id}\"><i class=\"fa-solid fa-plug-circle-check\"></i> use</button><button class=\"btn btn-xs btn-outline-info\" data-test-saved-profile=\"${p.id}\" title=\"Diagnostics\"><i class=\"fa-solid fa-stethoscope\"></i></button><button class=\"btn btn-xs btn-outline-secondary\" data-edit-profile=\"${p.id}\" title=\"Edit\"><i class=\"fa-solid fa-pen-to-square\"></i></button><button class=\"btn btn-xs btn-outline-danger\" data-del-profile=\"${p.id}\" title=\"Delete\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div></div>`;\n }).join('')||'No profiles.';\n }\n";
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.owner_name||'-'),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
|
export const ratioToolsSource = " async function deleteRatioGroup(groupId, groupName){\n if(!groupId) return;\n if(!confirm(`Delete ratio group \"${groupName || groupId}\"? Assigned torrents will lose only this group link.`)) return;\n try{\n await post(`/api/ratio-groups/${encodeURIComponent(groupId)}`,{},'DELETE');\n toast('Ratio group deleted','success');\n await loadRatios();\n }catch(e){ toast(e.message,'danger'); }\n }\n async function loadRatios(){\n const j=await (await fetch('/api/ratio-groups')).json();\n const groups=j.groups||[], history=j.history||[];\n if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join('');\n if($('ratioManager')){\n const groupRows=groups.map(g=>[\n esc(g.name),\n esc(g.owner_name||'-'),\n esc(g.min_ratio),\n esc(g.max_ratio),\n esc(g.seed_time_minutes||g.min_seed_time_minutes||0),\n esc(g.action),\n esc(g.move_path||''),\n esc(g.set_label||''),\n g.enabled?'yes':'no',\n `<button class=\"btn btn-xs btn-outline-danger ratio-group-delete\" type=\"button\" data-ratio-group-id=\"${esc(g.id)}\" data-ratio-group-name=\"${esc(g.name)}\" title=\"Delete ratio group\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`\n ]);\n const historyRows=history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]);\n $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled','Delete'],groupRows)}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],historyRows)}`;\n }\n }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios);\n $('ratioManager')?.addEventListener('click',e=>{ const btn=e.target.closest('.ratio-group-delete'); if(btn) deleteRatioGroup(btn.dataset.ratioGroupId, btn.dataset.ratioGroupName); });\n $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); });\n $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); });\n $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
|
||||||
@@ -1 +1 @@
|
|||||||
export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); ";
|
export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),j.runtime||null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); ";
|
||||||
|
|||||||
+55
-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);
|
||||||
@@ -5884,3 +5884,25 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
gap: 0.45rem 0.85rem;
|
gap: 0.45rem 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.profile-backup-icon {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--bs-info);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-inline: 0.15rem 0.25rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-id-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<label class="form-label" for="profileSelect">Configured rTorrents</label>
|
<label class="form-label" for="profileSelect">Configured rTorrents</label>
|
||||||
<select id="profileSelect" class="form-select profile-select">
|
<select id="profileSelect" class="form-select profile-select">
|
||||||
{% for p in profiles %}<option value="{{ p.id }}" {% if active_profile and active_profile.id == p.id %}selected{% endif %}>{{ p.name }}</option>{% endfor %}
|
{% for p in profiles %}<option value="{{ p.id }}" {% if active_profile and active_profile.id == p.id %}selected{% endif %}>#{{ p.id }} — {{ p.name }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">Changing rTorrent reloads the live torrent snapshot.</div>
|
<div class="form-text">Changing rTorrent reloads the live torrent snapshot.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
<div id="toolRtconfig" class="d-none"><div class="surface-section"><div class="section-title"><i class="fa-solid fa-gears"></i> rTorrent config</div><div class="small text-muted mb-2">Grouped rTorrent runtime settings with inline recommendations and compatibility status.</div><div class="alert alert-secondary py-2 small rt-config-note">Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.</div><div class="rt-config-toolbar"><span id="rtConfigChangedCount" class="badge text-bg-secondary">No changes</span><label class="form-check form-switch mb-0"><input id="rtConfigApplyOnStart" class="form-check-input" type="checkbox"><span class="form-check-label">Apply saved changes 60s after pyTorrent start</span></label></div><div id="rtConfigManager"><span class="spinner-border spinner-border-sm"></span> Loading config...</div><div class="rt-config-actions mt-3"><button id="rtConfigReloadBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-rotate"></i> Reload</button><button id="rtConfigResetBtn" class="btn btn-sm btn-outline-danger ms-2"><i class="fa-solid fa-rotate-left"></i> Reset UI settings</button><button id="rtConfigGenerateBtn" class="btn btn-sm btn-outline-secondary ms-2" disabled><i class="fa-solid fa-file-code"></i> Generate config</button><button id="rtConfigSaveBtn" class="btn btn-sm btn-primary ms-2"><i class="fa-solid fa-floppy-disk"></i> Save config</button></div><textarea id="rtConfigOutput" class="form-control form-control-sm mt-3 rt-config-output" rows="7" readonly placeholder="Generated rTorrent config changes will appear here."></textarea></div></div>
|
<div id="toolRtconfig" class="d-none"><div class="surface-section"><div class="section-title"><i class="fa-solid fa-gears"></i> rTorrent config</div><div class="small text-muted mb-2">Grouped rTorrent runtime settings with inline recommendations and compatibility status.</div><div class="alert alert-secondary py-2 small rt-config-note">Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.</div><div class="rt-config-toolbar"><span id="rtConfigChangedCount" class="badge text-bg-secondary">No changes</span><label class="form-check form-switch mb-0"><input id="rtConfigApplyOnStart" class="form-check-input" type="checkbox"><span class="form-check-label">Apply saved changes 60s after pyTorrent start</span></label></div><div id="rtConfigManager"><span class="spinner-border spinner-border-sm"></span> Loading config...</div><div class="rt-config-actions mt-3"><button id="rtConfigReloadBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-rotate"></i> Reload</button><button id="rtConfigResetBtn" class="btn btn-sm btn-outline-danger ms-2"><i class="fa-solid fa-rotate-left"></i> Reset UI settings</button><button id="rtConfigGenerateBtn" class="btn btn-sm btn-outline-secondary ms-2" disabled><i class="fa-solid fa-file-code"></i> Generate config</button><button id="rtConfigSaveBtn" class="btn btn-sm btn-primary ms-2"><i class="fa-solid fa-floppy-disk"></i> Save config</button></div><textarea id="rtConfigOutput" class="form-control form-control-sm mt-3 rt-config-output" rows="7" readonly placeholder="Generated rTorrent config changes will appear here."></textarea></div></div>
|
||||||
<div id="toolCleanup" class="d-none"><div class="surface-section"><div class="section-title"><i class="fa-solid fa-broom"></i> Cleanup / retention</div><div class="tool-note mb-3">One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.</div><div id="cleanupManager"><span class="spinner-border spinner-border-sm"></span> Loading cleanup data...</div></div></div>
|
<div id="toolCleanup" class="d-none"><div class="surface-section"><div class="section-title"><i class="fa-solid fa-broom"></i> Cleanup / retention</div><div class="tool-note mb-3">One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.</div><div id="cleanupManager"><span class="spinner-border spinner-border-sm"></span> Loading cleanup data...</div></div></div>
|
||||||
<div id="toolBackup" class="d-none"><div class="column-manager-tabs mb-3"><ul class="nav nav-pills" role="tablist"><li class="nav-item" role="presentation"><button class="nav-link active backup-scope-tab" data-backup-pane="profile" type="button"><i class="fa-solid fa-server"></i> Profile backup</button></li><li class="nav-item" role="presentation"><button class="nav-link backup-scope-tab" data-backup-pane="app" type="button"><i class="fa-solid fa-shield-halved"></i> Application backup</button></li></ul></div><div class="surface-section"><div class="section-title"><i class="fa-solid fa-box-archive"></i> Backup / restore</div><div class="tool-note mb-3">Profile backup restores only the active profile context. Application backup restores global application data and is available only to admins.</div><div class="backup-pane" data-backup-panel="profile"><div class="tool-note mb-3">Creates and restores settings for the currently selected profile. User-scoped preferences are remapped to the current user where needed.</div><div class="backup-settings-grid mb-3"><label class="form-check form-switch backup-auto-switch"><input id="profileBackupAutoEnabled" class="form-check-input" type="checkbox"><span class="form-check-label">Enable automatic profile backups</span></label><label class="form-field"><span>Every X hours</span><input id="profileBackupAutoInterval" class="form-control form-control-sm" type="number" min="1" value="24"></label><label class="form-field"><span>Retention days</span><input id="profileBackupRetentionDays" class="form-control form-control-sm" type="number" min="1" value="30"></label><button id="profileBackupSettingsSaveBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-floppy-disk"></i> Save schedule</button></div><div class="input-group input-group-sm mb-3 backup-create-row"><input id="profileBackupName" class="form-control" placeholder="Profile backup name"><button id="profileBackupCreateBtn" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> Create profile backup</button></div><div id="profileBackupManager"></div></div><div class="backup-pane d-none" data-backup-panel="app"><div class="tool-note mb-3">Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.</div><div class="backup-settings-grid mb-3"><label class="form-check form-switch backup-auto-switch"><input id="backupAutoEnabled" class="form-check-input" type="checkbox"><span class="form-check-label">Enable automatic application backups</span></label><label class="form-field"><span>Every X hours</span><input id="backupAutoInterval" class="form-control form-control-sm" type="number" min="1" value="24"></label><label class="form-field"><span>Retention days</span><input id="backupRetentionDays" class="form-control form-control-sm" type="number" min="1" value="30"></label><button id="backupSettingsSaveBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-floppy-disk"></i> Save schedule</button></div><div class="input-group input-group-sm mb-3 backup-create-row"><input id="appBackupName" class="form-control" placeholder="Application backup name"><button id="appBackupCreateBtn" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> Create application backup</button></div><div id="appBackupManager"></div></div><div id="backupPreview" class="backup-preview d-none"></div></div></div>
|
<div id="toolBackup" class="d-none"><div class="column-manager-tabs mb-3"><ul class="nav nav-pills" role="tablist"><li class="nav-item" role="presentation"><button class="nav-link active backup-scope-tab" data-backup-pane="profile" type="button"><i class="fa-solid fa-server"></i> Profile backup</button></li><li class="nav-item" role="presentation"><button class="nav-link backup-scope-tab" data-backup-pane="app" type="button"><i class="fa-solid fa-shield-halved"></i> Application backup</button></li></ul></div><div class="surface-section"><div class="section-title"><i class="fa-solid fa-box-archive"></i> Backup / restore</div><div class="tool-note mb-3">Profile backup restores only the active profile context. Application backup restores global application data and is available only to admins.</div><div class="backup-pane" data-backup-panel="profile"><div class="tool-note mb-3">Creates and restores settings for the currently selected profile. User-scoped preferences are remapped to the current user where needed.</div><div class="backup-settings-grid mb-3"><label class="form-check form-switch backup-auto-switch"><input id="profileBackupAutoEnabled" class="form-check-input" type="checkbox"><span class="form-check-label">Enable automatic profile backups</span></label><label class="form-field"><span>Every X hours</span><input id="profileBackupAutoInterval" class="form-control form-control-sm" type="number" min="1" value="24"></label><label class="form-field"><span>Retention days</span><input id="profileBackupRetentionDays" class="form-control form-control-sm" type="number" min="1" value="30"></label><button id="profileBackupSettingsSaveBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-floppy-disk"></i> Save schedule</button></div><div class="input-group input-group-sm mb-3 backup-create-row"><input id="profileBackupName" class="form-control" placeholder="Profile backup name"><button id="profileBackupCreateBtn" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> Create profile backup</button></div><div id="profileBackupManager"></div></div><div class="backup-pane d-none" data-backup-panel="app"><div class="tool-note mb-3">Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.</div><div class="backup-settings-grid mb-3"><label class="form-check form-switch backup-auto-switch"><input id="backupAutoEnabled" class="form-check-input" type="checkbox"><span class="form-check-label">Enable automatic application backups</span></label><label class="form-field"><span>Every X hours</span><input id="backupAutoInterval" class="form-control form-control-sm" type="number" min="1" value="24"></label><label class="form-field"><span>Retention days</span><input id="backupRetentionDays" class="form-control form-control-sm" type="number" min="1" value="30"></label><button id="backupSettingsSaveBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-floppy-disk"></i> Save schedule</button></div><div class="input-group input-group-sm mb-3 backup-create-row"><input id="appBackupName" class="form-control" placeholder="Application backup name"><button id="appBackupCreateBtn" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> Create application backup</button></div><div id="appBackupManager"></div></div><div id="backupPreview" class="backup-preview d-none"></div></div></div>
|
||||||
<div id="toolAppstatus" class="d-none"><div id="appStatusTabs"></div><div class="surface-section"><div class="section-title"><i class="fa-solid fa-heart-pulse"></i> pyTorrent status</div><div class="small text-muted mb-2">Focused diagnostics for the pyTorrent process and active SCGI/XML-RPC connection. Duplicated storage, jobs and queue details are managed in their dedicated tools.</div><div class="mb-2"><button id="appStatusRefreshBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-rotate"></i> Refresh</button></div><div id="appStatusManager">Open this tab to load diagnostics.</div></div></div>
|
<div id="toolAppstatus" class="d-none"><div id="appStatusTabs"></div><div class="surface-section"><div class="section-title"><i class="fa-solid fa-heart-pulse"></i> pyTorrent status</div><div class="small text-muted mb-2">Focused diagnostics for the pyTorrent process and active SCGI/XML-RPC connection. Duplicated storage, jobs and queue details are managed in their dedicated tools.</div><div id="appStatusManager">Open this tab to load diagnostics.</div></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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