fix planner
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
import secrets
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
@@ -39,8 +36,6 @@ RTORRENT_WRITE_PREFIXES = (
|
||||
)
|
||||
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
|
||||
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
|
||||
# Note: API reads that expose rTorrent/profile data must also respect profile permissions.
|
||||
# Note: Planner, poller and operation-log endpoints are profile-scoped and must follow the active profile context.
|
||||
PROFILE_READ_PREFIXES = (
|
||||
"/api/torrents",
|
||||
"/api/torrent-stats",
|
||||
@@ -101,7 +96,6 @@ def _host_matches_bypass(host: str) -> bool:
|
||||
|
||||
|
||||
def auth_bypassed_request() -> bool:
|
||||
# Note: Allows trusted direct-IP access to keep auth enabled for reverse-proxy traffic.
|
||||
if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context():
|
||||
return False
|
||||
return _host_matches_bypass(request.host)
|
||||
@@ -115,7 +109,6 @@ def bypass_user_id() -> int:
|
||||
row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
# Note: Keep direct-IP access usable after old installs, but never choose an inactive fallback.
|
||||
row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
@@ -126,7 +119,6 @@ def current_user_id() -> int:
|
||||
if not enabled():
|
||||
return default_user_id()
|
||||
if not has_request_context():
|
||||
# Note: Background jobs and schedulers do not have Flask request/session state.
|
||||
return 0
|
||||
if auth_bypassed_request():
|
||||
return bypass_user_id()
|
||||
|
||||
@@ -23,8 +23,6 @@ def _check_lock(profile_id: int, rule_id: int | None = None) -> threading.Lock:
|
||||
return _CHECK_LOCKS[key]
|
||||
|
||||
|
||||
|
||||
|
||||
def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None = None) -> int:
|
||||
"""Return a safe user id for rule ownership or background execution."""
|
||||
if user_id:
|
||||
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, default_user_id
|
||||
from . import automation_rules, operation_logs, poller_control, rtorrent
|
||||
from .websocket import emit_profile_event
|
||||
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, default_user_id
|
||||
from . import port_check, preferences, rtorrent, tracker_cache
|
||||
from .torrent_cache import torrent_cache
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..config import DB_PATH
|
||||
|
||||
_VACUUM_LOCK = threading.Lock()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import download_planner
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import psutil
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import auth, operation_logs, rtorrent
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR, USE_OFFLINE_LIBS
|
||||
|
||||
LIBS_STATIC_DIR = "libs"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from ..config import GEOIP_DB
|
||||
|
||||
try:
|
||||
import geoip2.database
|
||||
except Exception: # pragma: no cover
|
||||
except Exception:
|
||||
geoip2 = None
|
||||
|
||||
_reader = None
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
@@ -18,7 +17,6 @@ def _cleanup_expired(now: float | None = None) -> None:
|
||||
|
||||
def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict:
|
||||
"""Create a short-lived in-app link target used by preview and download routes."""
|
||||
# Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI.
|
||||
now = time.time()
|
||||
token = secrets.token_urlsafe(24)
|
||||
with _TEMPORARY_LINK_LOCK:
|
||||
@@ -35,7 +33,6 @@ def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: di
|
||||
|
||||
def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a short-lived in-app PDF preview link without exposing the API download URL."""
|
||||
# Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader.
|
||||
return _create_temporary_link(
|
||||
"pdf_preview",
|
||||
profile_id,
|
||||
@@ -46,7 +43,6 @@ def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int,
|
||||
|
||||
def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for one torrent file."""
|
||||
# Note: File downloads use /download/<token> in the UI, but the backend keeps the same rTorrent streaming logic.
|
||||
return _create_temporary_link(
|
||||
"file_download",
|
||||
profile_id,
|
||||
@@ -57,7 +53,6 @@ def create_file_download_link(torrent_hash: str, file_index: int, profile_id: in
|
||||
|
||||
def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for a ZIP of torrent files."""
|
||||
# Note: Selected indexes are stored with the token so the final /download route does not need an API body.
|
||||
clean_indexes = None if indexes is None else [int(index) for index in indexes]
|
||||
return _create_temporary_link(
|
||||
"file_zip_download",
|
||||
@@ -69,7 +64,6 @@ def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None,
|
||||
|
||||
def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for an exported .torrent file."""
|
||||
# Note: The token hides the stable export API URL from browser-visible download actions.
|
||||
return _create_temporary_link(
|
||||
"torrent_file_download",
|
||||
profile_id,
|
||||
@@ -80,7 +74,6 @@ def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_i
|
||||
|
||||
def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for a ZIP of exported .torrent files."""
|
||||
# Note: Hashes are copied into the token target after the API validates that the request is non-empty.
|
||||
return _create_temporary_link(
|
||||
"torrent_files_zip_download",
|
||||
profile_id,
|
||||
@@ -91,7 +84,6 @@ def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, u
|
||||
|
||||
def get_temporary_link(token: str) -> dict | None:
|
||||
"""Return a temporary target if the link is still valid."""
|
||||
# Note: Expired links are removed on read so stale browser tabs stop resolving automatically.
|
||||
clean = str(token or "").strip()
|
||||
if not clean:
|
||||
return None
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
|
||||
|
||||
@@ -81,7 +79,6 @@ def normalize_settings(data: dict | None) -> dict:
|
||||
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
|
||||
}
|
||||
if settings["safe_fallback_enabled"]:
|
||||
# Note: Safe fallback keeps existing functionality, but prevents very aggressive polling from overloading rTorrent or the browser.
|
||||
for key, minimum in SAFE_FALLBACK_MINIMUMS.items():
|
||||
settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum))
|
||||
return settings
|
||||
@@ -91,7 +88,6 @@ def get_settings(profile_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
|
||||
if not row:
|
||||
# Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read.
|
||||
legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||
if legacy:
|
||||
try:
|
||||
@@ -240,7 +236,6 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change
|
||||
|
||||
def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", updated_count: int = 0, requires_full_refresh: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
# Note: Live poller diagnostics track only lightweight speed/status refreshes, not the full torrent snapshot loop.
|
||||
state.live_poll_count += 1
|
||||
state.last_live_duration_ms = round((now - started_at) * 1000.0, 2)
|
||||
state.last_live_updated_count = int(updated_count or 0)
|
||||
@@ -254,7 +249,6 @@ def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error:
|
||||
|
||||
def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", added_count: int = 0, updated_count: int = 0, removed_count: int = 0) -> None:
|
||||
now = time.monotonic()
|
||||
# Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation.
|
||||
state.list_poll_count += 1
|
||||
state.last_list_duration_ms = round((now - started_at) * 1000.0, 2)
|
||||
state.last_list_added_count = int(added_count or 0)
|
||||
@@ -269,7 +263,6 @@ def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error:
|
||||
|
||||
def reset_runtime_stats(profile_id: int) -> dict:
|
||||
state = state_for(profile_id)
|
||||
# Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged.
|
||||
state.tick_count = 0
|
||||
state.last_tick_ms = 0.0
|
||||
state.last_tick_gap_ms = 0.0
|
||||
@@ -390,7 +383,6 @@ def snapshot(profile_id: int, settings: dict | None = None) -> dict:
|
||||
effective_settings = normalize_settings(settings) if settings is not None else get_settings(profile_id)
|
||||
data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
||||
runtime_ready = bool(state.stats) or state.tick_count > 0
|
||||
# Note: Snapshot includes saved intervals even before the first runtime tick so diagnostics never render as an empty zero-only panel.
|
||||
data.setdefault("runtime_ready", runtime_ready)
|
||||
data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"])))
|
||||
data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting"))
|
||||
@@ -399,7 +391,6 @@ def snapshot(profile_id: int, settings: dict | None = None) -> dict:
|
||||
data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS)
|
||||
if not runtime_ready:
|
||||
data["last_ok"] = None
|
||||
# Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats.
|
||||
data.update({
|
||||
"live_poll_count": state.live_poll_count,
|
||||
"list_poll_count": state.list_poll_count,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
@@ -8,7 +7,6 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect
|
||||
from . import preferences, rtorrent
|
||||
|
||||
@@ -44,7 +42,6 @@ def _public_ip(profile: dict | None = None, force: bool = False) -> str:
|
||||
|
||||
def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
|
||||
"""Return valid incoming port candidates from rTorrent network.port_range."""
|
||||
# Note: rTorrent can keep a range/list and pick a random port on start, so the checker tests all safe candidates.
|
||||
ports: list[int] = []
|
||||
seen: set[int] = set()
|
||||
truncated = False
|
||||
@@ -136,7 +133,6 @@ def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
|
||||
|
||||
def port_check_status(profile: dict | None = None, force: bool = False, user_id: int | None = None) -> dict:
|
||||
"""Return cached or freshly checked incoming-port status for one rTorrent profile."""
|
||||
# Note: This service is shared by UI routes and the background worker, so browser startup is not required.
|
||||
profile = profile or preferences.active_profile(user_id)
|
||||
prefs = preferences.get_preferences(user_id, int(profile.get("id"))) if profile else preferences.get_preferences(user_id)
|
||||
enabled = bool((prefs or {}).get("port_check_enabled"))
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth
|
||||
from .frontend_assets import BOOTSTRAP_THEME_LABELS
|
||||
@@ -28,7 +26,6 @@ FONT_FAMILIES = {
|
||||
"adwaita-mono": "Adwaita Mono",
|
||||
}
|
||||
|
||||
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
||||
RECOMMENDED_TABLE_COLUMNS = {
|
||||
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
|
||||
"shown": ["down_total", "to_download", "up_total", "created"],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..db import connect, utcnow
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth, rtorrent
|
||||
from .workers import enqueue
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
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
|
||||
|
||||
# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith.
|
||||
# All rTorrent code belongs in this package directory.
|
||||
|
||||
# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent.
|
||||
# Compatibility note: module __all__ definitions include selected private helpers used by existing routes.
|
||||
from .client import *
|
||||
from .system import *
|
||||
from .diagnostics import *
|
||||
from .files import *
|
||||
from .config import *
|
||||
from .torrents import *
|
||||
from .chunks import *
|
||||
from .chunks import *
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from .client import *
|
||||
@@ -11,13 +10,11 @@ _HEX_RE = re.compile(r"[0-9a-fA-F]")
|
||||
|
||||
def _clean_hex_bitfield(value) -> str:
|
||||
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
||||
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
|
||||
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
||||
|
||||
|
||||
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
||||
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
||||
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
|
||||
bits: list[int] = []
|
||||
for char in _clean_hex_bitfield(value):
|
||||
nibble = int(char, 16)
|
||||
@@ -47,7 +44,6 @@ def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
|
||||
|
||||
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
||||
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
|
||||
if max_cells <= 0 or len(cells) <= max_cells:
|
||||
return cells
|
||||
grouped: list[dict] = []
|
||||
@@ -79,7 +75,6 @@ def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
|
||||
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
||||
"""Create one raw cell per real torrent piece."""
|
||||
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
|
||||
cells: list[dict] = []
|
||||
for idx in range(max(0, int(total_chunks or 0))):
|
||||
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
||||
@@ -101,7 +96,6 @@ def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[
|
||||
|
||||
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
||||
"""Return ruTorrent-like visual chunk data for one torrent."""
|
||||
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
|
||||
c = client_for(profile)
|
||||
values = {
|
||||
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
||||
@@ -177,7 +171,6 @@ def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk
|
||||
|
||||
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
||||
"""Run safe actions related to visual chunk selection."""
|
||||
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
|
||||
payload = payload or {}
|
||||
action = str(action or "").strip().lower()
|
||||
c = client_for(profile)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
|
||||
RTORRENT_CONFIG_FIELDS = [
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
from .. import poller_control
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
from ...config import BASE_DIR
|
||||
|
||||
@@ -25,7 +24,6 @@ def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
|
||||
|
||||
def torrent_file_tree(profile: dict, torrent_hash: str) -> dict:
|
||||
# Note: The tree is built from rTorrent file paths without changing the existing flat file API.
|
||||
root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
parts = [part for part in str(item.get("path") or "").split("/") if part]
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Note: Backward-compatible internal alias for modules created during refactor.
|
||||
from .client import *
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from threading import RLock
|
||||
|
||||
from .client import *
|
||||
from .config import default_download_path
|
||||
from ...utils import human_size
|
||||
@@ -10,7 +8,6 @@ from ...utils import human_size
|
||||
|
||||
def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions."""
|
||||
# Note: Directory browsing stays remote-side, matching the original monolithic service behavior.
|
||||
c = client_for(profile)
|
||||
base = _remote_clean_path(path or default_download_path(profile))
|
||||
script = (
|
||||
@@ -44,7 +41,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
name, full_path = parts[0], parts[1]
|
||||
is_empty = len(parts) > 2 and parts[2] == "1"
|
||||
if name not in {".", ".."}:
|
||||
# Note: Empty status is returned with every directory so the path picker can enable safe inline rename.
|
||||
dirs.append({"name": name, "path": full_path, "empty": is_empty})
|
||||
elif marker == "M" and "\t" in rest:
|
||||
first, second = rest.split("\t", 1)
|
||||
@@ -67,7 +63,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
parent = posixpath.dirname(base.rstrip("/")) or "/"
|
||||
if parent == base:
|
||||
parent = base
|
||||
# Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts.
|
||||
return {
|
||||
"path": base,
|
||||
"parent": parent,
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
from .system import disk_usage_for_default_path
|
||||
|
||||
|
||||
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _parse_xmlrpc_size_limit(value) -> int:
|
||||
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K."""
|
||||
# Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes.
|
||||
text = str(value or '').strip().lower()
|
||||
if not text:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
@@ -29,7 +25,6 @@ def _parse_xmlrpc_size_limit(value) -> int:
|
||||
|
||||
def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
"""Return the current rTorrent XML-RPC request size limit."""
|
||||
# Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC.
|
||||
try:
|
||||
raw = client_for(profile).call('network.xmlrpc.size_limit')
|
||||
limit = _parse_xmlrpc_size_limit(raw)
|
||||
@@ -40,7 +35,6 @@ def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
|
||||
def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int:
|
||||
"""Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file."""
|
||||
# Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file.
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f'd.directory.set={directory}')
|
||||
@@ -93,7 +87,6 @@ def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
|
||||
if age > _POST_CHECK_WATCH_TTL_SECONDS:
|
||||
_clear_post_check_watch(profile_id, torrent_hash)
|
||||
return False
|
||||
# Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet.
|
||||
return age >= _POST_CHECK_WATCH_MIN_SECONDS
|
||||
|
||||
|
||||
@@ -124,7 +117,6 @@ def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, cu
|
||||
labels = _label_names(str(label_source or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
# Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue.
|
||||
c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
|
||||
return True
|
||||
|
||||
@@ -151,11 +143,9 @@ def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
status = str(row.get("status") or "").lower()
|
||||
# Note: rTorrent may report state=1 after a recheck even when the download is not really active yet.
|
||||
started_after_wait = bool(int(row.get("state") or 0)) and bool(int(row.get("active") or 0)) and status != "checking"
|
||||
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
|
||||
return False
|
||||
# Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
|
||||
clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
|
||||
row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
|
||||
return True
|
||||
@@ -183,7 +173,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
||||
complete = _row_progress_complete(row)
|
||||
try:
|
||||
if complete:
|
||||
# Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
|
||||
start_result = start_or_resume_hash(c, h)
|
||||
clear_post_check_download_label(c, h, str(row.get("label") or ""))
|
||||
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
|
||||
@@ -193,7 +182,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
labels.append(POST_CHECK_DOWNLOAD_LABEL)
|
||||
label_value = _label_value(labels)
|
||||
# Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
|
||||
c.call("d.stop", h)
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
@@ -229,7 +217,6 @@ LIVE_TORRENT_FIELDS = [
|
||||
|
||||
|
||||
def human_duration(seconds: int) -> str:
|
||||
# Note: Download ETA is derived locally from remaining bytes and current download speed.
|
||||
seconds = max(0, int(seconds or 0))
|
||||
if seconds <= 0:
|
||||
return '-'
|
||||
@@ -256,12 +243,8 @@ def normalize_row(row: list) -> dict:
|
||||
base_path = str(row[15] or "")
|
||||
state = int(row[2] or 0)
|
||||
complete = int(row[3] or 0)
|
||||
# Note: is_multi_file is needed before status calculation because the display path hides the torrent root for multi-file payloads.
|
||||
is_multi_file = int(row[24] or 0) if len(row) > 24 else 0
|
||||
|
||||
# Show the selected download location only. Hide the torrent root
|
||||
# directory for multi-file torrents and the filename for single-file
|
||||
# torrents. Data deletion still uses the full d.base_path elsewhere.
|
||||
if base_path and base_path != "/":
|
||||
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
@@ -280,20 +263,15 @@ def normalize_row(row: list) -> dict:
|
||||
is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state)
|
||||
last_activity = int(row[25] or 0) if len(row) > 25 else 0
|
||||
if not last_activity and (down_rate > 0 or up_rate > 0):
|
||||
# Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp.
|
||||
last_activity = int(time.time())
|
||||
completed_at = int(row[26] or 0) if len(row) > 26 else 0
|
||||
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
|
||||
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
|
||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
|
||||
# Note: rTorrent exposes queued/inactive torrents with the same runtime flags that older UI code called paused.
|
||||
# The app marks only explicit user Pause requests with py_manual_pause so queued rows stay separate.
|
||||
is_paused = manual_pause and not is_checking and not post_check
|
||||
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
|
||||
# Note: Post-check and Queued are application-level UI statuses; rTorrent itself mainly exposes flags.
|
||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
to_download_bytes = remaining_bytes if not complete else 0
|
||||
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
|
||||
|
||||
return {
|
||||
"hash": str(row[0] or ""),
|
||||
"name": str(row[1] or ""),
|
||||
@@ -338,7 +316,6 @@ def normalize_row(row: list) -> dict:
|
||||
|
||||
def normalize_live_row(row: list) -> dict:
|
||||
"""Normalize the small row used by the fast live stats poller."""
|
||||
# Note: The live poller intentionally reads only volatile fields so the main list poller can run less often.
|
||||
size = int(row[3] or 0)
|
||||
completed = int(row[4] or 0)
|
||||
complete = int(row[2] or 0)
|
||||
@@ -406,13 +383,10 @@ def list_torrents(profile: dict) -> list[dict]:
|
||||
try:
|
||||
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
|
||||
except Exception:
|
||||
# Keep compatibility with older rTorrent builds that do not expose optional timestamp fields.
|
||||
rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
|
||||
return [normalize_row(list(row)) for row in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
fields = [
|
||||
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=",
|
||||
@@ -444,8 +418,6 @@ def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
return peers
|
||||
|
||||
|
||||
|
||||
|
||||
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
|
||||
errors = []
|
||||
for method, args in candidates:
|
||||
@@ -457,7 +429,6 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
|
||||
|
||||
def _tracker_domain(url: str) -> str:
|
||||
raw = str(url or '').strip()
|
||||
if not raw:
|
||||
@@ -471,7 +442,6 @@ def _tracker_domain(url: str) -> str:
|
||||
|
||||
def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict:
|
||||
"""Return tracker domains grouped by torrent for the sidebar filter."""
|
||||
# Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list.
|
||||
hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
|
||||
if not hashes:
|
||||
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')]
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from .rtorrent import human_rate
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from time import monotonic
|
||||
|
||||
from ..db import connect
|
||||
from . import operation_logs, rtorrent
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import RLock
|
||||
from time import time
|
||||
from . import rtorrent, operation_logs
|
||||
|
||||
_LIVE_KEYS = {"state", "active", "paused", "complete", "completed_bytes", "progress", "ratio", "up_rate", "up_rate_h", "down_rate", "down_rate_h", "eta_seconds", "eta_h", "up_total", "up_total_h", "down_total", "down_total_h", "to_download", "to_download_h", "peers", "seeds", "message", "status", "post_check", "hashing"}
|
||||
|
||||
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from . import rtorrent
|
||||
from .torrent_cache import torrent_cache
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from threading import RLock
|
||||
from time import time
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
@@ -11,7 +10,6 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR
|
||||
from ..db import connect, utcnow
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect, utcnow
|
||||
from . import retention
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
@@ -17,7 +16,6 @@ def _profile_room(profile_id: int) -> str:
|
||||
|
||||
|
||||
def _poller_profiles() -> list[dict]:
|
||||
# Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms.
|
||||
if not auth.enabled():
|
||||
profile = active_profile()
|
||||
return [profile] if profile else []
|
||||
@@ -27,7 +25,6 @@ def _poller_profiles() -> list[dict]:
|
||||
|
||||
|
||||
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
# Note: Profile-scoped events always go to the selected profile room, even when authentication is disabled.
|
||||
scoped_payload = {**(payload or {}), "profile_id": int(profile_id)}
|
||||
socketio.emit(event, scoped_payload, to=_profile_room(profile_id))
|
||||
|
||||
@@ -36,19 +33,15 @@ def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
emit_profile_event(socketio, event, payload, profile_id)
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_configured_speed_limits(profile: dict) -> None:
|
||||
limits = profile_speed_limits.get_limits(int(profile.get("id") or 0))
|
||||
if not limits.get("configured"):
|
||||
return
|
||||
# Note: Profile-level speed limits are re-applied when the profile is opened so they are not tied to a specific user session.
|
||||
rtorrent.set_limits(profile, limits.get("down"), limits.get("up"))
|
||||
|
||||
|
||||
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
state = poller_control.state_for(profile_id)
|
||||
# Note: Background checks keep the profile owner so bypass/admin profiles do not enqueue jobs as the fallback user.
|
||||
profile_user_id = int(profile.get("user_id") or default_user_id())
|
||||
try:
|
||||
try:
|
||||
@@ -67,7 +60,6 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
# Note: Automations are profile-scoped; each queued job still runs as the rule owner.
|
||||
auto_result = automation_rules.check(profile, force=False)
|
||||
if auto_result.get("applied") or auto_result.get("batches"):
|
||||
_emit_profile(socketio, "automation_update", auto_result, profile_id)
|
||||
@@ -94,7 +86,6 @@ def _is_active_rows(rows: list[dict]) -> bool:
|
||||
|
||||
|
||||
def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict:
|
||||
# Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats.
|
||||
down_rate = sum(int(row.get("down_rate") or 0) for row in rows or [])
|
||||
up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
|
||||
return {
|
||||
@@ -184,7 +175,6 @@ def register_socketio_handlers(socketio):
|
||||
else:
|
||||
skipped_emissions += 1
|
||||
if live.get("requires_full_refresh"):
|
||||
# Note: Missing or unknown hashes mean the next slow list tick must reconcile rows.
|
||||
state.last_list_at = 0.0
|
||||
run_list = True
|
||||
else:
|
||||
@@ -218,7 +208,6 @@ def register_socketio_handlers(socketio):
|
||||
rtorrent_call_count += 1
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
@@ -272,7 +261,6 @@ def register_socketio_handlers(socketio):
|
||||
global _started
|
||||
with _start_lock:
|
||||
if not _started:
|
||||
# The poller starts with the app, so Smart Queue, planner and automations work without an open UI.
|
||||
socketio.start_background_task(poller)
|
||||
_started = True
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
@@ -43,7 +42,6 @@ def _emit(name: str, payload: dict):
|
||||
return
|
||||
profile_id = payload.get("profile_id")
|
||||
if profile_id:
|
||||
# Note: Job/socket events are profile-room scoped so modals and toasts do not leak between rTorrent profiles.
|
||||
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
|
||||
else:
|
||||
_socketio.emit(name, payload)
|
||||
@@ -102,7 +100,6 @@ def _job_payload(row) -> dict:
|
||||
def _is_ordered_job(row) -> bool:
|
||||
payload = _job_payload(row)
|
||||
action = str((row or {}).get("action") or "")
|
||||
# Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work.
|
||||
return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
|
||||
|
||||
|
||||
@@ -195,7 +192,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non
|
||||
job_id = uuid.uuid4().hex
|
||||
if force:
|
||||
payload = dict(payload or {})
|
||||
# Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation.
|
||||
payload['force_job'] = True
|
||||
payload['priority_job'] = True
|
||||
now = utcnow()
|
||||
@@ -205,7 +201,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non
|
||||
"INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now),
|
||||
)
|
||||
# Note: Queued jobs are now written to operation logs so work is visible before a worker starts it.
|
||||
operation_logs.record_job_event(profile_id, action_name, "queued", payload, job_id=job_id, user_id=user_id)
|
||||
_emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
|
||||
_submit_job(job_id, action_name)
|
||||
@@ -217,7 +212,6 @@ def _job_event_meta(payload: dict) -> dict:
|
||||
source = str(ctx.get("source") or payload.get("source") or "user")
|
||||
meta = {"source": source}
|
||||
if source == "automation":
|
||||
# Note: Socket operation toasts use this flag so automation notifications respect user preferences.
|
||||
meta["automation"] = True
|
||||
meta["source_label"] = str(ctx.get("rule_name") or "automation")
|
||||
if ctx.get("rule_id") is not None:
|
||||
@@ -226,7 +220,6 @@ def _job_event_meta(payload: dict) -> dict:
|
||||
|
||||
|
||||
|
||||
|
||||
def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | None = None) -> bool:
|
||||
# Note: Disk usage refreshes only when a remove job actually requested data deletion.
|
||||
if str(action_name or "") != "remove":
|
||||
@@ -239,7 +232,6 @@ def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | Non
|
||||
|
||||
def _clear_disk_refresh_cache(profile_id: int) -> None:
|
||||
try:
|
||||
# Note: Remove-with-data jobs invalidate disk cache before notifying browsers, otherwise /api/system/disk may return stale values.
|
||||
rtorrent.clear_profile_runtime_caches(int(profile_id))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -247,7 +239,6 @@ def _clear_disk_refresh_cache(profile_id: int) -> None:
|
||||
|
||||
def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None:
|
||||
_clear_disk_refresh_cache(profile_id)
|
||||
# Note: The browser performs the fresh /api/system/disk read so profile-scoped disk monitor preferences stay respected.
|
||||
_emit("disk_refresh_requested", {
|
||||
"profile_id": int(profile_id),
|
||||
"hash_count": int(hash_count or 0),
|
||||
@@ -282,7 +273,6 @@ def _schedule_profile_disk_refresh(profile_id: int, hash_count: int = 0) -> None
|
||||
old_timer = _disk_refresh_timers.get(key)
|
||||
if old_timer:
|
||||
old_timer.cancel()
|
||||
# Note: Repeated delete jobs share one delayed refresh per profile and delay, preventing timer storms during bulk cleanup.
|
||||
timer = threading.Timer(float(delay_seconds), _run_delayed_disk_refresh, args=(profile_id, int(delay_seconds)))
|
||||
timer.daemon = True
|
||||
_disk_refresh_timers[key] = timer
|
||||
@@ -301,7 +291,6 @@ def _emit_disk_refresh_requested(profile_id: int, action_name: str, payload: dic
|
||||
def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
|
||||
if action_name == "smart_queue_check":
|
||||
from . import smart_queue
|
||||
# Note: Worker execution uses the job owner instead of Flask session state.
|
||||
return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
|
||||
if action_name == "add_magnet":
|
||||
if bool(payload.get("start", True)):
|
||||
@@ -363,7 +352,6 @@ def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
|
||||
else:
|
||||
_emit("rtorrent_error", {**diff, "profile_id": profile_id})
|
||||
except Exception as exc:
|
||||
# Note: A failed live refresh must not change the already completed job result.
|
||||
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
|
||||
|
||||
|
||||
@@ -372,7 +360,6 @@ def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None:
|
||||
return
|
||||
|
||||
def delayed_refresh():
|
||||
# Note: rTorrent may expose state changes one poll later than the XML-RPC action result.
|
||||
sleep_fn = getattr(_socketio, "sleep", time.sleep)
|
||||
for delay in (0.75, 1.75):
|
||||
sleep_fn(delay)
|
||||
@@ -395,7 +382,6 @@ def _run(job_id: str):
|
||||
profile = get_profile(int(job["profile_id"]), int(job["user_id"]))
|
||||
if not profile:
|
||||
_set_job(job_id, "failed", "rTorrent profile does not exist", finished=True)
|
||||
# Note: Profile lookup failures used to appear only in the job queue; they are now persisted in operation logs too.
|
||||
operation_logs.record_worker_event(int(job.get("profile_id") or 0), str(job.get("action") or ""), "failed", "Job failed: rTorrent profile does not exist", job_id=job_id, user_id=int(job.get("user_id") or 0), error="profile not found")
|
||||
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"})
|
||||
return
|
||||
@@ -422,16 +408,13 @@ def _run(job_id: str):
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
||||
result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0))
|
||||
fresh = _job_row(job_id)
|
||||
# Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state.
|
||||
if fresh and fresh["status"] != "running":
|
||||
return
|
||||
_set_job(job_id, "done", result=result, finished=True)
|
||||
operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
|
||||
# Note: Remove-with-data jobs ask connected browsers to refresh disk usage immediately after filesystem deletion finishes.
|
||||
action_name = str(job["action"] or "")
|
||||
_emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result or {})
|
||||
# Note: Completed jobs must publish a fresh torrent snapshot/patch so removed or moved torrents disappear without a page reload.
|
||||
_emit_torrent_refresh(profile, action_name)
|
||||
_schedule_delayed_torrent_refresh(profile, action_name)
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
||||
@@ -495,7 +478,6 @@ def _timeout_running_jobs() -> None:
|
||||
continue
|
||||
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
|
||||
_set_job(row["id"], "failed", message, finished=True)
|
||||
# Note: Watchdog timeouts are stored in operation logs because no normal worker exception may be raised.
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "timeout", message, job_id=row["id"], user_id=int(row.get("user_id") or 0), error=message)
|
||||
_emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"})
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message})
|
||||
@@ -514,8 +496,7 @@ def _resubmit_interrupted_running_jobs() -> None:
|
||||
if not profile:
|
||||
continue
|
||||
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at"))
|
||||
# Note: After process restart there is no in-memory runner for this job.
|
||||
# A short grace avoids stealing work from another still-alive Gunicorn worker.
|
||||
|
||||
if last_seen_ts is not None and now_ts - last_seen_ts < 90:
|
||||
continue
|
||||
with connect() as conn:
|
||||
@@ -524,7 +505,6 @@ def _resubmit_interrupted_running_jobs() -> None:
|
||||
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
|
||||
)
|
||||
if int(cur.rowcount or 0):
|
||||
# Note: Interrupted jobs returned to the queue are logged so restart recovery is auditable.
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Interrupted job resubmitted from checkpoint", job_id=row["id"], user_id=int(row.get("user_id") or 0))
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
@@ -547,7 +527,6 @@ def _resubmit_stale_pending_jobs() -> None:
|
||||
continue
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"]))
|
||||
# Note: Stale pending resubmits are logged to explain duplicated queue attempts after watchdog recovery.
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Stale pending job resubmitted by watchdog", job_id=row["id"], user_id=int(row.get("user_id") or 0))
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
@@ -586,7 +565,6 @@ def _job_summary(row: dict, payload: dict, result: dict) -> str:
|
||||
count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
parts = []
|
||||
if ctx.get("bulk_label"):
|
||||
# Note: Shows which generated bulk part is being displayed in the job queue.
|
||||
parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
|
||||
if count:
|
||||
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)")
|
||||
@@ -652,7 +630,6 @@ def cancel_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row["status"] not in {"pending", "running"}:
|
||||
return False
|
||||
# Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup.
|
||||
_set_job(job_id, "cancelled", finished=True)
|
||||
payload = _job_payload(row)
|
||||
operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "cancelled", payload, error="Cancelled by user", job_id=job_id, user_id=int(row.get("user_id") or 0))
|
||||
@@ -670,7 +647,6 @@ def clear_jobs() -> int:
|
||||
|
||||
|
||||
def emergency_clear_jobs() -> int:
|
||||
# Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list.
|
||||
now = utcnow()
|
||||
where, params = _job_scope_sql(writable=True)
|
||||
status_clause = "status IN ('pending', 'running')"
|
||||
|
||||
Reference in New Issue
Block a user