Merge pull request 'Move between profiles' (#31) from move_between_profiles into master

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
gru
2026-06-20 21:27:04 +02:00
26 changed files with 920 additions and 84 deletions
+19
View File
@@ -113,6 +113,25 @@ CREATE TABLE IF NOT EXISTS rtorrent_profiles (
);
CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS profile_runtime_stats (
profile_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
torrent_count INTEGER DEFAULT 0,
total_size_bytes INTEGER DEFAULT 0,
completed_bytes INTEGER DEFAULT 0,
downloaded_bytes INTEGER DEFAULT 0,
uploaded_bytes INTEGER DEFAULT 0,
active_count INTEGER DEFAULT 0,
seeding_count INTEGER DEFAULT 0,
downloading_count INTEGER DEFAULT 0,
stopped_count INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_profile_runtime_stats_user ON profile_runtime_stats(user_id, profile_id);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
+25
View File
@@ -146,11 +146,36 @@ def migrate_profile_speed_limits_table(conn: sqlite3.Connection) -> bool:
return existing is None
def migrate_profile_runtime_stats_table(conn: sqlite3.Connection) -> bool:
existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_runtime_stats'").fetchone()
conn.execute("""
CREATE TABLE IF NOT EXISTS profile_runtime_stats (
profile_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
torrent_count INTEGER DEFAULT 0,
total_size_bytes INTEGER DEFAULT 0,
completed_bytes INTEGER DEFAULT 0,
downloaded_bytes INTEGER DEFAULT 0,
uploaded_bytes INTEGER DEFAULT 0,
active_count INTEGER DEFAULT 0,
seeding_count INTEGER DEFAULT 0,
downloading_count INTEGER DEFAULT 0,
stopped_count INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_profile_runtime_stats_user ON profile_runtime_stats(user_id, profile_id)")
return existing is None
MIGRATIONS: tuple[Migration, ...] = (
migrate_disk_monitor_preferences_to_profile_scope,
migrate_profile_preferences_sidebar_columns,
migrate_operation_log_split_retention,
migrate_profile_speed_limits_table,
migrate_profile_runtime_stats_table,
)
+5
View File
@@ -261,6 +261,11 @@ def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict:
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
if action_name == "remove":
payload["job_context"]["remove_data"] = bool(payload.get("remove_data"))
if action_name == "profile_transfer":
payload["job_context"]["target_profile_id"] = int(payload.get("target_profile_id") or 0)
payload["job_context"]["target_path"] = str(payload.get("target_path") or payload.get("path") or "")
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
payload["job_context"]["move_data_downgraded"] = bool(payload.get("move_data_downgraded"))
return payload
+21 -1
View File
@@ -2,12 +2,22 @@ from __future__ import annotations
from ._shared import *
from ..services.rtorrent.diagnostics import profile_diagnostics
from ..services import auth
from ..utils import human_size
@bp.get("/profiles")
def profiles_list():
profiles = []
for row in preferences.list_profiles():
item = dict(row)
# Note: Frontend actions can hide write-only operations without trusting this flag; backend still enforces permissions.
item["can_write"] = auth.can_write_profile(int(item.get("id") or 0), auth.current_user_id() or default_user_id())
stats = preferences.get_profile_runtime_stats(int(item.get("id") or 0))
if stats:
stats["total_size_h"] = human_size(stats.get("total_size_bytes"))
stats["completed_h"] = human_size(stats.get("completed_bytes"))
stats["downloaded_h"] = human_size(stats.get("downloaded_bytes"))
stats["uploaded_h"] = human_size(stats.get("uploaded_bytes"))
item["runtime_stats"] = stats
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")
@@ -44,7 +54,17 @@ def profiles_delete(profile_id: int):
@bp.post("/profiles/<int:profile_id>/activate")
def profiles_activate(profile_id: int):
try:
return ok({"profile": preferences.activate_profile(profile_id)})
profile = preferences.activate_profile(profile_id)
stats_error = ""
try:
# Note: Profile overview metrics are cached only on user-initiated profile switch, not on every profile list render.
preferences.save_profile_runtime_stats(profile, rtorrent.list_torrents(profile), user_id=auth.current_user_id() or default_user_id())
except Exception as exc:
stats_error = str(exc)
response = {"profile": profile}
if stats_error:
response["stats_error"] = stats_error
return ok(response)
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 404
+16 -6
View File
@@ -372,9 +372,21 @@ def _annotate_path_directories(profile: dict, payload: dict) -> dict:
return payload
def _path_profile_from_request(*, require_write_access: bool = False):
profile_id = 0
try:
profile_id = int((request.args.get("profile_id") if request.method == "GET" else (request.get_json(silent=True) or {}).get("profile_id")) or 0)
except Exception:
profile_id = 0
profile = preferences.get_profile(profile_id, auth.current_user_id() or default_user_id()) if profile_id else request_profile()
if profile and require_write_access:
require_profile_write(profile.get("id"))
return profile
@bp.get("/path/default")
def path_default():
profile = request_profile()
profile = _path_profile_from_request()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -386,7 +398,7 @@ def path_default():
@bp.get("/path/browse")
def path_browse():
profile = request_profile()
profile = _path_profile_from_request()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
base = request.args.get("path") or ""
@@ -398,10 +410,9 @@ def path_browse():
@bp.post("/path/directories")
def path_directory_create():
profile = request_profile()
profile = _path_profile_from_request(require_write_access=True)
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
require_profile_write(profile.get("id"))
data = request.get_json(silent=True) or {}
try:
# Note: This endpoint only creates an empty directory and does not alter any torrent state.
@@ -413,10 +424,9 @@ def path_directory_create():
@bp.post("/path/directories/rename")
def path_directory_rename():
profile = request_profile()
profile = _path_profile_from_request(require_write_access=True)
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
require_profile_write(profile.get("id"))
data = request.get_json(silent=True) or {}
path = str(data.get("path") or "").strip()
if _path_has_cached_torrents(int(profile.get("id") or 0), path):
+143 -3
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from ._shared import *
import json
import posixpath
from ..services import profile_speed_limits
from ..services import pdf_preview_links, torrent_creator
from ..services.reverse_dns import attach_reverse_dns
@@ -514,17 +516,153 @@ def torrent_tracker_action(torrent_hash: str, action_name: str):
def _clean_remote_transfer_path(path: str) -> str:
clean = posixpath.normpath(str(path or "").strip())
if not clean or clean in {".", "/"} or not clean.startswith("/") or "\x00" in clean:
raise ValueError("Unsafe target path")
return clean
def _path_inside_root(path: str, root: str) -> bool:
path = _clean_remote_transfer_path(path)
root = _clean_remote_transfer_path(root)
return path == root or path.startswith(root.rstrip("/") + "/")
def _target_profile_allowed_roots(target_profile: dict, user_id: int) -> list[str]:
roots = []
try:
roots.append(_clean_remote_transfer_path(rtorrent.default_download_path(target_profile)))
except Exception:
pass
try:
prefs = preferences.get_disk_monitor_preferences(int(target_profile.get("id") or 0), user_id=user_id)
for item in json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]"):
try:
roots.append(_clean_remote_transfer_path(str(item or "")))
except Exception:
continue
selected = str((prefs or {}).get("disk_monitor_selected_path") or "").strip()
if selected:
roots.append(_clean_remote_transfer_path(selected))
except Exception:
pass
seen = []
for root in roots:
if root not in seen:
seen.append(root)
return seen
def _profile_transfer_payload(source_profile: dict, data: dict, *, require_hashes: bool = True) -> dict:
user_id = auth.current_user_id() or default_user_id()
source_id = int(source_profile.get("id") or 0)
if not auth.can_write_profile(source_id, user_id):
raise PermissionError("No write access to source profile")
hashes = [str(h).strip() for h in (data.get("hashes") or []) if str(h).strip()]
if require_hashes and not hashes:
raise ValueError("No torrents selected")
target_id = int(data.get("target_profile_id") or 0)
if not target_id or target_id == source_id:
raise ValueError("Choose a different target profile")
if not auth.can_write_profile(target_id, user_id):
raise PermissionError("No write access to target profile")
target_profile = preferences.get_profile(target_id, user_id)
if not target_profile:
raise ValueError("Target profile does not exist")
roots = _target_profile_allowed_roots(target_profile, user_id)
default_target_path = roots[0] if roots else _clean_remote_transfer_path(rtorrent.default_download_path(target_profile))
requested_target_path = str(data.get("target_path") or data.get("path") or "").strip()
target_path = _clean_remote_transfer_path(requested_target_path or default_target_path)
inside_allowed_root = bool(roots and any(_path_inside_root(target_path, root) for root in roots))
if not inside_allowed_root:
# Note: A chosen target path must stay inside the target profile roots even for metadata-only transfers.
if requested_target_path:
raise ValueError("Target path is outside the target profile download roots")
target_path = default_target_path
inside_allowed_root = bool(roots and any(_path_inside_root(target_path, root) for root in roots))
requested_move_data = bool(data.get("move_data"))
move_data = requested_move_data
write_check = {"ok": False, "message": "not requested"}
downgrade_reason = ""
if requested_move_data:
if not inside_allowed_root:
move_data = False
downgrade_reason = "Target path is outside the target profile download roots"
write_check = {"ok": False, "message": downgrade_reason, "path": target_path}
else:
# Note: Data moves are allowed only when the source rTorrent OS user can write to the target profile path.
write_check = rtorrent.remote_can_write_directory(source_profile, target_path)
move_data = bool(write_check.get("ok"))
if not move_data:
downgrade_reason = str(write_check.get("message") or write_check.get("error") or "Target path is not writable by the source rTorrent user")
return {
"hashes": hashes,
"target_profile_id": target_id,
"target_path": target_path,
"path": target_path,
"move_data": move_data,
"move_data_requested": requested_move_data,
"move_data_downgraded": bool(requested_move_data and not move_data),
"move_data_downgrade_reason": downgrade_reason,
"target_allowed_roots": roots,
"target_write_check": write_check,
"label_mode": str(data.get("label_mode") or "none").strip(),
"label_value": str(data.get("label_value") or "").strip(),
"post_action": str(data.get("post_action") or "current").strip(),
}
def _validated_profile_transfer_payload(source_profile: dict, data: dict) -> dict:
return _profile_transfer_payload(source_profile, data, require_hashes=True)
@bp.post("/torrents/profile_transfer/validate")
def profile_transfer_validate():
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
payload = _profile_transfer_payload(profile, request.get_json(silent=True) or {}, require_hashes=False)
target_profile = preferences.get_profile(int(payload["target_profile_id"]), auth.current_user_id() or default_user_id())
return ok({
"target_profile_id": payload["target_profile_id"],
"target_path": payload["target_path"],
"move_data_requested": payload["move_data_requested"],
"move_data_allowed": bool(payload["move_data"]),
"move_data_downgraded": bool(payload["move_data_downgraded"]),
"move_data_downgrade_reason": payload.get("move_data_downgrade_reason") or "",
"target_write_check": payload.get("target_write_check") or {},
"disk": rtorrent.disk_usage_for_paths(target_profile, [payload["target_path"]], mode="selected", selected_path=payload["target_path"]),
"target_allowed_roots": payload.get("target_allowed_roots") or [],
})
except PermissionError as exc:
return jsonify({"ok": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/<action_name>")
def torrent_action(action_name: str):
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"}
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "profile_transfer", "set_label", "set_ratio_group"}
if action_name not in allowed:
return jsonify({"ok": False, "error": "Unknown action"}), 400
if action_name in {"move", "remove"}:
# Note: Large move/remove requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
if action_name == "profile_transfer":
try:
data = _validated_profile_transfer_payload(profile, data)
except PermissionError as exc:
return jsonify({"ok": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
if action_name in {"move", "remove", "profile_transfer"}:
# Note: Large move/remove/profile-transfer requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
jobs = enqueue_bulk_parts(profile, action_name, data)
first_job_id = jobs[0]["job_id"] if jobs else None
total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs)
@@ -536,6 +674,8 @@ def torrent_action(action_name: str):
"bulk": total_hashes > 1,
"bulk_parts": len(jobs),
"chunk_size": MOVE_BULK_MAX_HASHES,
"transfer_move_data_downgraded": bool(data.get("move_data_downgraded")),
"transfer_move_data_downgrade_reason": str(data.get("move_data_downgrade_reason") or ""),
})
payload = enrich_bulk_payload(profile, action_name, data)
job_id = enqueue(action_name, profile["id"], payload)
+83 -1
View File
@@ -5,7 +5,7 @@ import json
import threading
from ..db import connect, default_user_id, utcnow
from . import rtorrent, auth
from .preferences import active_profile
from .preferences import active_profile, get_profile, get_disk_monitor_preferences
from .workers import enqueue
AUTOMATION_JOB_CHUNK_SIZE = 100
@@ -369,6 +369,8 @@ def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], actio
extra.update({'bulk_label': f'automation-{index}', 'bulk_part': index, 'bulk_parts': len(chunks), 'parent_hash_count': len(hashes)})
if action_name == 'move':
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
if action_name == 'profile_transfer':
extra.update({'target_profile_id': int(part_payload.get('target_profile_id') or 0), 'target_path': str(part_payload.get('target_path') or ''), 'move_data': bool(part_payload.get('move_data')), 'post_action': str(part_payload.get('post_action') or 'current')})
if action_name == 'remove':
extra.update({'remove_data': bool(part_payload.get('remove_data'))})
effect_type = str(context_extra.get('effect_type') if context_extra else action_name)
@@ -377,6 +379,81 @@ def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], actio
return job_ids
def _safe_remote_path(value: str) -> str:
path = str(value or '').strip().replace('\\', '/')
while '//' in path:
path = path.replace('//', '/')
if path.endswith('/') and path != '/':
path = path.rstrip('/')
return path
def _path_inside_root(path: str, root: str) -> bool:
path = _safe_remote_path(path)
root = _safe_remote_path(root)
return bool(path and root and (path == root or path.startswith(root.rstrip('/') + '/')))
def _automation_profile_transfer_payload(profile: dict[str, Any], eff: dict[str, Any], user_id: int) -> dict[str, Any]:
# Note: Automation profile transfers reuse server-side permission checks; UI values are not trusted.
source_id = int(profile.get('id') or 0)
if not auth.can_write_profile(source_id, user_id):
raise ValueError('Rule owner has no write access to source profile')
target_id = int(eff.get('target_profile_id') or 0)
if not target_id or target_id == source_id:
raise ValueError('Automation target profile is invalid')
if not auth.can_write_profile(target_id, user_id):
raise ValueError('Rule owner has no write access to target profile')
target_profile = get_profile(target_id, user_id)
if not target_profile:
raise ValueError('Automation target profile does not exist')
default_path = _safe_remote_path(rtorrent.default_download_path(target_profile))
requested_target_path = _safe_remote_path(str(eff.get('target_path') or eff.get('path') or ''))
target_path = requested_target_path or default_path
roots = [default_path]
try:
prefs = get_disk_monitor_preferences(target_id, user_id=user_id)
for item in json.loads((prefs or {}).get('disk_monitor_paths_json') or '[]'):
clean = _safe_remote_path(str(item or ''))
if clean and clean not in roots:
roots.append(clean)
selected = _safe_remote_path(str((prefs or {}).get('disk_monitor_selected_path') or ''))
if selected and selected not in roots:
roots.append(selected)
except Exception:
pass
target_roots = [r for r in roots if r]
if not any(_path_inside_root(target_path, root) for root in target_roots):
if requested_target_path:
raise ValueError('Automation target path is outside the target profile download roots')
target_path = default_path
requested_move_data = bool(eff.get('move_data'))
move_data = False
downgrade_reason = ''
if requested_move_data:
check = rtorrent.remote_can_write_directory(profile, target_path)
move_data = bool(check.get('ok'))
if not move_data:
downgrade_reason = str(check.get('message') or check.get('error') or 'target path is not writable by source rTorrent user')
post_action = str(eff.get('post_action') or 'current').strip().lower()
if post_action not in {'none', 'current', 'start', 'stop', 'pause', 'check', 'recheck'}:
post_action = 'current'
label_mode = str(eff.get('label_mode') or 'none').strip().lower()
if label_mode not in {'none', 'custom', 'moved_from', 'moved_to'}:
label_mode = 'none'
return {
'target_profile_id': target_id,
'target_path': target_path,
'path': target_path,
'move_data': move_data,
'move_data_requested': requested_move_data,
'move_data_downgraded': bool(requested_move_data and not move_data),
'move_data_downgrade_reason': downgrade_reason,
'post_action': post_action,
'label_mode': label_mode,
'label_value': str(eff.get('label_value') or '').strip(),
}
def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]], rule: dict[str, Any], user_id: int | None = None) -> list[dict[str, Any]]:
hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')]
torrents_by_hash = {str(t.get('hash') or ''): t for t in torrents if str(t.get('hash') or '')}
@@ -395,6 +472,11 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
}
job_ids = _enqueue_automation_job(profile, rule, 'move', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'move'})
applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'job_ids': job_ids})
elif typ == 'profile_transfer':
owner_id = int(user_id or rule.get('user_id') or rule.get('owner_user_id') or default_user_id())
payload = _automation_profile_transfer_payload(profile, eff, owner_id)
job_ids = _enqueue_automation_job(profile, rule, 'profile_transfer', hashes, payload, torrents_by_hash, owner_id, {'effect_type': 'profile_transfer'})
applied.append({'type': 'profile_transfer', 'target_profile_id': payload['target_profile_id'], 'target_path': payload['target_path'], 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'move_data_requested': payload['move_data_requested'], 'move_data_downgraded': payload['move_data_downgraded'], 'post_action': payload['post_action'], 'label_mode': payload['label_mode'], 'label': payload['label_value'], 'job_ids': job_ids})
elif typ == 'add_label':
label = str(eff.get('label') or '').strip()
if label:
+47 -13
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import threading
import time
import psutil
from datetime import datetime, timezone
@@ -46,6 +47,29 @@ _LAST_RUN: dict[int, float] = {}
_LAST_LIMITS: dict[int, tuple[int, int]] = {}
_HIGH_CPU_SINCE: dict[int, float] = {}
_PLANNER_CONNECTION_STATUS: dict[int, str] = {}
_SCHEDULER_STARTED = False
_SCHEDULER_LOCK = threading.Lock()
_PROFILE_LOCKS: dict[int, threading.Lock] = {}
_PROFILE_LOCKS_GUARD = threading.Lock()
def _profile_lock(profile_id: int) -> threading.Lock:
"""Keep one planner run per profile active at a time."""
with _PROFILE_LOCKS_GUARD:
if profile_id not in _PROFILE_LOCKS:
_PROFILE_LOCKS[profile_id] = threading.Lock()
return _PROFILE_LOCKS[profile_id]
def _all_profiles() -> list[dict]:
"""Read every configured profile directly from DB for browser-independent background work."""
with connect() as conn:
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
def _owner_user_id(profile: dict) -> int:
"""Use the profile owner for background planner checks."""
return int(profile.get("user_id") or default_user_id())
def _rtorrent_ready(profile: dict) -> tuple[bool, str]:
@@ -580,32 +604,42 @@ def preview(profile: dict, user_id: int | None = None) -> dict:
def start_scheduler(socketio=None) -> None:
"""Start the browser-independent planner loop for every configured profile."""
global _SCHEDULER_STARTED
with _SCHEDULER_LOCK:
if _SCHEDULER_STARTED:
return
_SCHEDULER_STARTED = True
def loop():
while True:
try:
from .preferences import active_profile
from .websocket import emit_profile_event
from . import auth
profiles: list[dict]
if auth.enabled():
with connect() as conn:
profiles = conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
else:
profile = active_profile()
profiles = [profile] if profile else []
for profile in profiles:
for profile in _all_profiles():
profile_id = int(profile.get("id") or 0)
if not profile_id:
continue
lock = _profile_lock(profile_id)
if not lock.acquire(blocking=False):
continue
try:
result = enforce(profile, force=False)
# Note: Background planner runs per configured profile with the profile owner, not only for the active UI profile.
result = enforce(profile, force=False, user_id=_owner_user_id(profile))
if socketio and result.get("enabled") and not result.get("skipped"):
emit_profile_event(socketio, "download_plan_update", result, int(profile["id"]))
emit_profile_event(socketio, "download_plan_update", result, profile_id)
except Exception as exc:
if socketio:
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": int(profile.get("id") or 0), "error": str(exc)}, int(profile.get("id") or 0))
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
finally:
lock.release()
except Exception:
pass
if socketio:
socketio.sleep(30)
else:
time.sleep(30)
if socketio:
socketio.start_background_task(loop)
else:
threading.Thread(target=loop, daemon=True, name="pytorrent-download-planner-scheduler").start()
+4 -1
View File
@@ -80,7 +80,7 @@ def _details_summary(details: dict) -> str:
priority = [
"status", "job_id", "attempt", "attempts", "count", "hash_count", "action",
"source", "source_label", "directory", "label", "target_path", "remove_data",
"move_data", "keep_seeding", "error", "error_count", "result_count",
"move_data", "target_profile_id", "move_data_downgraded", "keep_seeding", "error", "error_count", "result_count",
]
parts: list[str] = []
for key in priority:
@@ -315,6 +315,7 @@ def _job_action_label(action: str) -> str:
"set_ratio_group": "Set ratio group",
"set_limits": "Set speed limits",
"smart_queue_check": "Smart Queue check",
"profile_transfer": "Move to another profile",
}
return labels.get(str(action or ""), str(action or "job"))
@@ -354,6 +355,8 @@ def record_job_event(profile_id: int, action: str, status: str, payload: dict |
"target_path": ctx.get("target_path") or payload.get("path"),
"remove_data": ctx.get("remove_data") or payload.get("remove_data"),
"move_data": ctx.get("move_data") or payload.get("move_data"),
"target_profile_id": ctx.get("target_profile_id") or payload.get("target_profile_id"),
"move_data_downgraded": ctx.get("move_data_downgraded") or payload.get("move_data_downgraded"),
"keep_seeding": payload.get("keep_seeding"),
"hash_count": len(hashes),
"error": error,
+74
View File
@@ -577,3 +577,77 @@ def save_preferences(data: dict, user_id: int | None = None, profile_id: int | N
if disk_payload is not None:
save_disk_monitor_preferences(profile_id, disk_payload, user_id)
return get_preferences(user_id, profile_id)
def _row_int(row: dict, key: str) -> int:
try:
return int(float(row.get(key) or 0))
except (TypeError, ValueError):
return 0
def profile_runtime_stats_from_rows(profile: dict, rows: list[dict], user_id: int | None = None) -> dict:
# Note: Stored profile stats are intentionally approximate and updated only when the user switches to that profile.
user_id = user_id or auth.current_user_id() or default_user_id()
total_size = completed = downloaded = uploaded = active = seeding = downloading = stopped = 0
for row in rows or []:
size = _row_int(row, 'size')
total_size += size
completed += min(size, _row_int(row, 'completed_bytes')) if size else _row_int(row, 'completed_bytes')
downloaded += _row_int(row, 'down_total')
uploaded += _row_int(row, 'up_total')
status = str(row.get('status') or '').strip().lower()
state = bool(row.get('state'))
complete = bool(row.get('complete'))
if state:
active += 1
if complete and state:
seeding += 1
if not complete and state and status != 'queued':
downloading += 1
if not state:
stopped += 1
return {
'profile_id': int(profile.get('id') or 0),
'user_id': int(user_id),
'torrent_count': len(rows or []),
'total_size_bytes': total_size,
'completed_bytes': completed,
'downloaded_bytes': downloaded,
'uploaded_bytes': uploaded,
'active_count': active,
'seeding_count': seeding,
'downloading_count': downloading,
'stopped_count': stopped,
'updated_at': utcnow(),
}
def save_profile_runtime_stats(profile: dict, rows: list[dict], user_id: int | None = None) -> dict:
stats = profile_runtime_stats_from_rows(profile, rows, user_id=user_id)
with connect() as conn:
conn.execute(
"""
INSERT INTO profile_runtime_stats(
profile_id,user_id,torrent_count,total_size_bytes,completed_bytes,downloaded_bytes,uploaded_bytes,
active_count,seeding_count,downloading_count,stopped_count,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET
user_id=excluded.user_id, torrent_count=excluded.torrent_count, total_size_bytes=excluded.total_size_bytes,
completed_bytes=excluded.completed_bytes, downloaded_bytes=excluded.downloaded_bytes, uploaded_bytes=excluded.uploaded_bytes,
active_count=excluded.active_count, seeding_count=excluded.seeding_count, downloading_count=excluded.downloading_count,
stopped_count=excluded.stopped_count, updated_at=excluded.updated_at
""",
(
stats['profile_id'], stats['user_id'], stats['torrent_count'], stats['total_size_bytes'], stats['completed_bytes'],
stats['downloaded_bytes'], stats['uploaded_bytes'], stats['active_count'], stats['seeding_count'],
stats['downloading_count'], stats['stopped_count'], stats['updated_at'],
),
)
return stats
def get_profile_runtime_stats(profile_id: int) -> dict | None:
with connect() as conn:
row = conn.execute("SELECT * FROM profile_runtime_stats WHERE profile_id=?", (int(profile_id),)).fetchone()
return dict(row) if row else None
+4 -2
View File
@@ -137,10 +137,12 @@ def start_scheduler(socketio=None) -> None:
profile_id = int(row["profile_id"])
with connect() as conn:
owner = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
profile = get_profile(profile_id, int(owner["user_id"] if owner and owner.get("user_id") else default_user_id()))
owner_id = int(owner["user_id"] if owner and owner.get("user_id") else default_user_id())
profile = get_profile(profile_id, owner_id)
if not profile:
continue
result = check(profile)
# Note: Ratio rules are evaluated per profile owner, not the active browser user.
result = check(profile, user_id=owner_id)
if socketio and result.get("applied"):
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
except Exception:
+7 -2
View File
@@ -200,9 +200,14 @@ def start_scheduler(socketio=None) -> None:
with connect() as conn:
profiles = conn.execute("SELECT DISTINCT profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
for row in profiles:
profile = get_profile(int(row["profile_id"]))
profile_id = int(row["profile_id"])
with connect() as conn:
owner = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
owner_id = int(owner["user_id"] if owner and owner.get("user_id") else default_user_id())
profile = get_profile(profile_id, owner_id)
if profile:
result = check(profile, only_due=True)
# Note: RSS jobs run with the profile owner in background mode, independent of browser activity.
result = check(profile, user_id=owner_id, only_due=True)
if socketio and result.get("queued"):
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
except Exception:
+24
View File
@@ -345,6 +345,30 @@ def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0)
raise RuntimeError(output)
def remote_can_write_directory(profile: dict, path: str) -> dict:
"""Return whether the source rTorrent OS user can write to a remote directory safely."""
clean = _remote_clean_path(path)
# Note: Profile transfers may touch filesystem paths, so only absolute non-root directories are probed.
if not clean.startswith("/") or clean in {"/", "."}:
return {"ok": False, "path": clean, "error": "unsafe destination path"}
script = (
'p=$1; '
'case "$p" in /*) ;; *) echo "NO\tunsafe path"; exit 0;; esac; '
'if [ -d "$p" ]; then '
' if [ -w "$p" ]; then echo "OK\tdirectory writable"; else echo "NO\tdirectory not writable"; fi; '
' exit 0; '
'fi; '
'parent=${p%/*}; [ -n "$parent" ] || parent=/; '
'if [ -d "$parent" ] && [ -w "$parent" ]; then echo "OK\tparent writable"; else echo "NO\tparent not writable"; fi'
)
try:
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script, "pytorrent-transfer-write-check", clean) or "").strip()
except Exception as exc:
return {"ok": False, "path": clean, "error": str(exc)}
ok = output.startswith("OK")
return {"ok": ok, "path": clean, "message": output.split("\t", 1)[1] if "\t" in output else output}
def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
data_path = _safe_rm_rf_path(_torrent_data_path(c, torrent_hash))
try:
+135 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import time
from .client import *
from .files import set_file_priorities
from .files import export_torrent_file, iter_remote_file_chunks, set_file_priorities
from .system import disk_usage_for_default_path
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
@@ -804,6 +804,140 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
result['ok'] = result.get('ok', True)
return result
def _read_exported_torrent_bytes(profile: dict, torrent_hash: str) -> tuple[bytes, dict]:
item = export_torrent_file(profile, torrent_hash)
if item.get("local"):
return LocalPath(str(item.get("path") or "")).read_bytes(), item
data = b"".join(bytes(chunk) for chunk in iter_remote_file_chunks(profile, str(item.get("path") or "")) if chunk)
if not data:
raise RuntimeError(f"Cannot read exported torrent file for {torrent_hash}")
return data, item
def _move_profile_transfer_data(source_client: ScgiRtorrentClient, torrent_hash: str, target_path: str) -> dict:
"""Move one torrent data path for a profile transfer after backend permission checks."""
src = _remote_clean_path(_torrent_data_path(source_client, torrent_hash))
if not src:
raise ValueError(f"Cannot determine source path for {torrent_hash}")
dst = _remote_join(target_path, posixpath.basename(src.rstrip("/")))
try:
source_client.call("d.stop", torrent_hash)
except Exception:
pass
try:
source_client.call("d.close", torrent_hash)
except Exception:
pass
if src == dst:
return {"skipped_data_move": "source and destination are the same"}
_run_remote_move(source_client, src, dst)
return {"moved_from": src, "moved_to": dst}
def transfer_profile(source_profile: dict, target_profile: dict, torrent_hashes: list[str], payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
"""Move torrent entries between rTorrent profiles; data moving is delegated to a separate helper."""
payload = payload or {}
resume_state = resume_state or {}
target_path = _remote_clean_path(payload.get("target_path") or payload.get("path") or "")
move_data = bool(payload.get("move_data"))
post_action = str(payload.get("post_action") or "none").strip().lower()
if post_action not in {"none", "current", "start", "stop", "pause", "check", "recheck"}:
raise ValueError("Unsupported post-transfer action")
label_mode = str(payload.get("label_mode") or "none").strip().lower()
label_value = str(payload.get("label_value") or "").strip()
if label_mode not in {"none", "custom", "moved_from", "moved_to"}:
label_mode = "none"
if label_mode == "moved_from":
label_value = f"Moved from {source_profile.get('name') or source_profile.get('id') or 'profile'}"
elif label_mode == "moved_to":
label_value = f"Moved to {target_profile.get('name') or target_profile.get('id') or 'profile'}"
elif label_mode != "custom":
label_value = ""
if len(label_value) > 120:
label_value = label_value[:120]
if not target_path or not target_path.startswith("/") or target_path == "/":
raise ValueError("Missing or unsafe target path")
completed_hashes = set(str(x) for x in (resume_state.get("completed_hashes") or []))
previous_results = list(resume_state.get("results") or [])
source_client = client_for(source_profile)
target_client = client_for(target_profile)
def mark_done(torrent_hash: str, results: list) -> None:
completed_hashes.add(str(torrent_hash))
if checkpoint:
checkpoint({"completed_hashes": sorted(completed_hashes), "results": results}, len(completed_hashes), len(torrent_hashes))
results = previous_results
for h in [x for x in torrent_hashes if str(x) not in completed_hashes]:
item = {
"hash": h,
"source_profile_id": int(source_profile.get("id") or 0),
"target_profile_id": int(target_profile.get("id") or 0),
"target_path": target_path,
"move_data": move_data,
"move_data_requested": bool(payload.get("move_data_requested")),
"move_data_downgraded": bool(payload.get("move_data_downgraded")),
}
data, exported = _read_exported_torrent_bytes(source_profile, h)
item["exported_from"] = exported.get("path")
limit = validate_torrent_upload_size(target_profile, data, False, target_path, "")
if not limit.get("ok"):
raise RuntimeError(f"Target profile XML-RPC limit is too small for {h}: {limit.get('request_h')} > {limit.get('limit_h')}")
try:
label = str(source_client.call("d.custom1", h) or "")
except Exception:
label = ""
target_label = label_value if label_value else label
try:
was_state = int(source_client.call("d.state", h) or 0)
except Exception:
was_state = 0
try:
was_active = int(source_client.call("d.is_active", h) or 0)
except Exception:
was_active = was_state
moved_to = ""
if move_data:
move_result = _move_profile_transfer_data(source_client, h, target_path)
item.update(move_result)
moved_to = str(move_result.get("moved_to") or "")
# Note: The default keeps the torrent status from the source profile; explicit actions override it.
start_on_target = bool(was_state or was_active) if post_action in {"none", "current"} else post_action == "start"
try:
added = add_torrent_raw(target_profile, data, start_on_target, target_path, target_label)
if not added.get("ok"):
raise RuntimeError(added.get("error") or "target add failed")
except Exception:
if move_data and moved_to:
try:
source_client.call("d.directory.set", h, target_path)
if was_state or was_active:
source_client.call("d.start", h)
item["rollback"] = "source torrent kept and pointed at moved data"
except Exception as rollback_exc:
item["rollback_error"] = str(rollback_exc)
raise
if post_action in {"stop", "pause", "check", "recheck"}:
try:
if post_action == "stop":
target_client.call("d.stop", h)
elif post_action == "pause":
pause_hash(target_client, h)
else:
target_client.call("d.check_hash", h)
item["post_action_applied"] = post_action
except Exception as post_exc:
item["post_action_error"] = str(post_exc)
source_client.call("d.erase", h)
item["target_started"] = start_on_target
item["label"] = target_label
item["previous_label"] = label
item["post_action"] = post_action
results.append(item)
mark_done(h, results)
return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "target_profile_id": int(target_profile.get("id") or 0), "target_path": target_path, "label": label_value, "post_action": post_action, "results": results}
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
payload = payload or {}
resume_state = resume_state or {}
+5 -2
View File
@@ -74,9 +74,12 @@ def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_secon
socketio.sleep(max(0, int(delay_seconds)))
started_at = monotonic()
while True:
failed_profile_id = 0
try:
profiles = _profiles()
for profile in profiles:
failed_profile_id = int(profile.get("id") or 0)
# Note: Startup config applies per profile after connectivity is detected; it does not depend on the active UI profile.
_apply_profile(socketio, profile)
pending = [int(profile.get("id") or 0) for profile in profiles if int(profile.get("id") or 0) not in _applied_profiles]
if not pending or monotonic() - started_at >= max(0, int(max_wait_seconds)):
@@ -87,7 +90,7 @@ def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_secon
return
except Exception as exc:
operation_logs.record(
None,
failed_profile_id or None,
"rtorrent_config_startup",
f"rTorrent startup config scheduler failed: {exc}",
severity="warning",
@@ -95,7 +98,7 @@ def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_secon
action="rtorrent_config",
details={"error": str(exc)},
)
socketio.emit("rtorrent_config_applied", {"ok": False, "profile_id": int(profile_id or 0), "error": str(exc)}, to=f"profile:{int(profile_id or 0)}" if profile_id else None)
socketio.emit("rtorrent_config_applied", {"ok": False, "profile_id": int(failed_profile_id or 0), "error": str(exc)}, to=f"profile:{int(failed_profile_id)}" if failed_profile_id else None)
socketio.sleep(max(5, int(retry_seconds)))
socketio.start_background_task(runner)
+17 -8
View File
@@ -16,11 +16,9 @@ def _profile_room(profile_id: int) -> str:
def _poller_profiles() -> list[dict]:
if not auth.enabled():
profile = active_profile()
return [profile] if profile else []
from ..db import connect
with connect() as conn:
# Note: Background polling must be profile-scoped and browser-independent, even when auth is disabled.
return conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
@@ -33,11 +31,20 @@ 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))
_speed_limits_applied: dict[int, tuple[int, int]] = {}
def _apply_configured_speed_limits(profile: dict, *, force: bool = False) -> None:
profile_id = int(profile.get("id") or 0)
limits = profile_speed_limits.get_limits(profile_id)
if not limits.get("configured"):
return
key = (int(limits.get("down") or 0), int(limits.get("up") or 0))
if not force and _speed_limits_applied.get(profile_id) == key:
return
# Note: Persisted per-profile limits are applied by the backend poller, not only after browser profile selection.
rtorrent.set_limits(profile, limits.get("down"), limits.get("up"))
_speed_limits_applied[profile_id] = key
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
@@ -60,7 +67,7 @@ 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:
auto_result = automation_rules.check(profile, force=False)
auto_result = automation_rules.check(profile, user_id=profile_user_id, force=False)
if auto_result.get("applied") or auto_result.get("batches"):
_emit_profile(socketio, "automation_update", auto_result, profile_id)
except Exception as exc:
@@ -145,6 +152,8 @@ def register_socketio_handlers(socketio):
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
try:
# Note: This keeps per-profile runtime limits active after app start, without waiting for UI contact.
_apply_configured_speed_limits(profile)
rows = torrent_cache.snapshot(pid)
speed_status = _speed_status_from_rows(pid, rows)
@@ -280,7 +289,7 @@ def register_socketio_handlers(socketio):
emit("profile_required", {"ok": True, "profiles": []})
return
try:
_apply_configured_speed_limits(profile)
_apply_configured_speed_limits(profile, force=True)
except Exception as exc:
emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)})
rows = torrent_cache.snapshot(profile["id"])
@@ -306,7 +315,7 @@ def register_socketio_handlers(socketio):
return
join_room(_profile_room(profile_id))
try:
_apply_configured_speed_limits(profile)
_apply_configured_speed_limits(profile, force=True)
except Exception as exc:
emit("rtorrent_error", {"profile_id": profile_id, "error": str(exc)})
diff = torrent_cache.refresh(profile)
+69 -21
View File
@@ -100,7 +100,7 @@ def _job_payload(row) -> dict:
def _is_ordered_job(row) -> bool:
payload = _job_payload(row)
action = str((row or {}).get("action") or "")
return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
return action in {"move", "remove", "profile_transfer", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
def _is_priority_job(row) -> bool:
@@ -112,24 +112,55 @@ def _is_light_job(row) -> bool:
return _is_light_action(str((row or {}).get("action") or ""))
def _has_prior_ordered_jobs(profile_id: int, rowid: int) -> bool:
def _ordered_profile_ids(row) -> set[int]:
"""Return every profile touched by an ordered job."""
# Note: Profile-transfer jobs touch both source and target profiles, so they must be ordered across both sides.
ids: set[int] = set()
try:
profile_id = int((row or {}).get("profile_id") or 0)
if profile_id:
ids.add(profile_id)
except Exception:
pass
try:
payload = _job_payload(row)
target_id = int(payload.get("target_profile_id") or 0)
if str((row or {}).get("action") or "") == "profile_transfer" and target_id:
ids.add(target_id)
except Exception:
pass
return ids
def _ordered_locks_for(row) -> list[threading.Lock]:
"""Acquire locks in stable order to avoid deadlocks between cross-profile jobs."""
return [_get_exclusive_lock(profile_id) for profile_id in sorted(_ordered_profile_ids(row))]
def _has_prior_ordered_jobs(profile_ids: set[int], rowid: int) -> bool:
if not profile_ids:
return False
with connect() as conn:
rows = conn.execute(
"""
SELECT rowid AS _rowid, action, payload_json
SELECT rowid AS _rowid, profile_id, action, payload_json
FROM jobs
WHERE profile_id=?
AND rowid<?
WHERE rowid<?
AND status IN ('pending', 'running')
ORDER BY rowid
""",
(profile_id, rowid),
(rowid,),
).fetchall()
return any(_is_ordered_job(row) and not _is_priority_job(row) for row in rows)
for row in rows:
if not _is_ordered_job(row) or _is_priority_job(row):
continue
if profile_ids.intersection(_ordered_profile_ids(row)):
return True
return False
def _wait_for_prior_ordered_jobs(job_id: str, profile_id: int, rowid: int) -> bool:
while _has_prior_ordered_jobs(profile_id, rowid):
def _wait_for_prior_ordered_jobs(job_id: str, profile_ids: set[int], rowid: int) -> bool:
while _has_prior_ordered_jobs(profile_ids, rowid):
fresh = _job_row(job_id)
if not fresh or fresh["status"] == "cancelled":
return False
@@ -289,6 +320,12 @@ def _emit_disk_refresh_requested(profile_id: int, action_name: str, payload: dic
_schedule_profile_disk_refresh(int(profile_id), len((payload or {}).get("hashes") or []))
def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
def checkpoint(next_state: dict, current: int, total: int):
# Note: Checkpoint is defined before every action branch so profile-transfer jobs can resume safely.
job_id = payload.get("__job_id")
if job_id:
_checkpoint_job(str(job_id), next_state, current, total)
if action_name == "smart_queue_check":
from . import smart_queue
return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
@@ -302,6 +339,12 @@ def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None
if bool(payload.get("start", True)):
disk_guard.assert_can_start_download(profile)
return rtorrent.add_torrent_raw(profile, raw, bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""), payload.get("file_priorities") or None)
if action_name == "profile_transfer":
# Note: Target profile is resolved inside the worker with the original user's permissions, not trusted from the request payload.
target_profile = get_profile(int(payload.get("target_profile_id") or 0), user_id or default_user_id())
if not target_profile:
raise ValueError("Target profile does not exist or is not accessible")
return rtorrent.transfer_profile(profile, target_profile, payload.get("hashes") or [], payload, checkpoint=checkpoint, resume_state=payload.get("__resume_state") or {})
if action_name == "set_limits":
return rtorrent.set_limits(profile, payload.get("down"), payload.get("up"))
hashes = payload.get("hashes") or []
@@ -309,11 +352,6 @@ def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None
disk_guard.assert_can_start_download(profile)
state = payload.get("__resume_state") or {}
def checkpoint(next_state: dict, current: int, total: int):
job_id = payload.get("__job_id")
if job_id:
_checkpoint_job(str(job_id), next_state, current, total)
return rtorrent.action(profile, hashes, action_name, payload, checkpoint=checkpoint, resume_state=state)
@@ -341,7 +379,7 @@ def _mark_running(job_id: str, attempts: int) -> bool:
def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
if action_name not in {"add_magnet", "add_torrent_raw", "remove", "move", "start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "recheck"}:
if action_name not in {"add_magnet", "add_torrent_raw", "remove", "move", "profile_transfer", "start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "recheck"}:
return
try:
diff = torrent_cache.refresh(profile)
@@ -372,7 +410,7 @@ def _run(job_id: str):
if not _claim_runner(job_id):
return
sem = None
ordered_lock = None
ordered_locks: list[threading.Lock] = []
job = {}
payload = {}
try:
@@ -387,10 +425,12 @@ def _run(job_id: str):
return
profile_id = int(profile["id"])
if _is_ordered_job(job) and not _is_priority_job(job):
if not _wait_for_prior_ordered_jobs(job_id, profile_id, int(job["_rowid"])):
involved_profile_ids = _ordered_profile_ids(job)
if not _wait_for_prior_ordered_jobs(job_id, involved_profile_ids, int(job["_rowid"])):
return
ordered_lock = _get_exclusive_lock(profile_id)
ordered_lock.acquire()
ordered_locks = _ordered_locks_for(job)
for lock in ordered_locks:
lock.acquire()
sem = _get_sem(profile, light=_is_light_job(job))
sem.acquire()
job = _job_row(job_id)
@@ -416,6 +456,14 @@ def _run(job_id: str):
action_name = str(job["action"] or "")
_emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result or {})
_emit_torrent_refresh(profile, action_name)
if action_name == "profile_transfer":
# Note: Refresh the destination profile cache as well so users see transferred torrents immediately after switching.
try:
target_profile = get_profile(int(payload.get("target_profile_id") or 0), int(job.get("user_id") or 0))
if target_profile:
_emit_torrent_refresh(target_profile, action_name)
except Exception:
pass
_schedule_delayed_torrent_refresh(profile, action_name)
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
except Exception as exc:
@@ -439,8 +487,8 @@ def _run(job_id: str):
finally:
if sem:
sem.release()
if ordered_lock:
ordered_lock.release()
for lock in reversed(ordered_locks):
lock.release()
_release_runner(job_id)
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
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
+163 -6
View File
@@ -514,8 +514,13 @@ body {
padding-bottom: 0;
padding-top: 0;
text-align: center;
vertical-align: middle;
width: 34px;
}
.torrent-table thead .sel input[type="checkbox"] {
display: block;
margin: 0 auto;
}
.torrent-table .torrent-select-cell {
align-items: center;
display: flex;
@@ -3332,7 +3337,7 @@ body.mobile-mode .mobile-filter-bar {
.torrent-preview {
display: grid;
gap: .75rem;
gap: 0.75rem;
}
.torrent-preview-title {
@@ -3358,7 +3363,7 @@ body.mobile-mode .mobile-filter-bar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: .5rem;
gap: 0.5rem;
}
.preview-file-table {
@@ -3712,7 +3717,7 @@ body.mobile-mode .mobile-filter-bar {
.smart-view-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: .75rem;
gap: 0.75rem;
}
.health-card,
.smart-view-card,
@@ -3730,7 +3735,7 @@ body.mobile-mode .mobile-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
gap: 0.75rem;
margin-bottom: .25rem;
}
.health-card > small,
@@ -3779,7 +3784,7 @@ body.mobile-mode .mobile-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
gap: 0.75rem;
margin-bottom: .75rem;
}
.notification-list {
@@ -3815,7 +3820,7 @@ body.mobile-mode .mobile-filter-bar {
/* Diagnostics layout */
.diagnostics-section {
display: grid;
gap: .75rem;
gap: 0.75rem;
margin-bottom: 1rem;
}
.diagnostics-section:last-child {
@@ -5907,3 +5912,155 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
font-size: 0.72rem;
font-weight: 600;
}
/* Profile picker and profile transfer cards. */
.profile-choice-list,
.profile-transfer-list {
display: grid;
gap: 0.5rem;
}
.profile-choice-card,
.profile-transfer-card {
align-items: center;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.65rem;
color: var(--bs-body-color);
display: flex;
justify-content: space-between;
padding: 0.65rem 0.75rem;
text-align: left;
width: 100%;
}
.profile-choice-card:hover,
.profile-choice-card.active,
.profile-transfer-card:hover,
.profile-transfer-card.active {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
}
.profile-choice-card span,
.profile-transfer-card span {
align-items: center;
display: inline-flex;
gap: 0.5rem;
min-width: 0;
}
.profile-choice-card small,
.profile-transfer-card small {
color: var(--bs-secondary-color);
}
.profile-transfer-grid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.85fr);
}
.profile-transfer-switch {
align-items: center;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.65rem;
display: flex;
gap: 0.75rem;
min-height: 2.75rem;
padding: 0.55rem 0.85rem;
}
.profile-transfer-switch .form-check-input {
flex: 0 0 auto;
margin-left: 0;
}
.profile-transfer-torrents {
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.65rem;
display: grid;
gap: 0.35rem;
max-height: 8rem;
overflow: auto;
padding: 0.65rem 0.75rem;
}
.profile-transfer-torrents div {
align-items: center;
display: grid;
gap: 0.5rem;
grid-template-columns: auto minmax(0, 1fr) auto;
min-width: 0;
}
.profile-transfer-torrents span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-transfer-disk {
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.65rem;
font-size: 0.85rem;
padding: 0.65rem 0.75rem;
}
.profile-transfer-permission {
min-height: 1.2rem;
}
.profile-choice-main {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.profile-choice-stats {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: flex-end;
margin-left: 1rem;
}
.profile-choice-stats span {
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 999px;
color: var(--bs-secondary-color);
font-size: 0.75rem;
padding: 0.15rem 0.45rem;
}
.profile-transfer-path-hints {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
@media (max-width: 767.98px) {
.profile-choice-card {
align-items: flex-start;
flex-direction: column;
gap: 0.5rem;
}
.profile-choice-stats {
justify-content: flex-start;
margin-left: 0;
}
}
@media (max-width: 767.98px) {
.profile-transfer-grid {
grid-template-columns: 1fr;
}
}
File diff suppressed because one or more lines are too long