From e6733d6a275a1d1ff03155082210df085029f27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 20 Jun 2026 16:47:54 +0200 Subject: [PATCH 1/4] move to anther profile --- pytorrent/routes/_shared.py | 5 + pytorrent/routes/profiles.py | 2 + pytorrent/routes/torrents.py | 144 +++++++++++++++++++++++- pytorrent/services/automation_rules.py | 81 ++++++++++++- pytorrent/services/operation_logs.py | 5 +- pytorrent/services/rtorrent/client.py | 24 ++++ pytorrent/services/rtorrent/torrents.py | 136 +++++++++++++++++++++- pytorrent/services/workers.py | 18 ++- pytorrent/static/js/api.js | 2 +- pytorrent/static/js/automationRules.js | 2 +- pytorrent/static/js/profileActions.js | 2 +- pytorrent/static/js/profileSelection.js | 2 +- pytorrent/static/js/torrentAdd.js | 2 +- pytorrent/static/styles.css | 121 +++++++++++++++++++- pytorrent/templates/index.html | 58 ++++++++-- 15 files changed, 576 insertions(+), 28 deletions(-) diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py index 48acf07..cbec557 100644 --- a/pytorrent/routes/_shared.py +++ b/pytorrent/routes/_shared.py @@ -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 diff --git a/pytorrent/routes/profiles.py b/pytorrent/routes/profiles.py index 021351b..d1233dd 100644 --- a/pytorrent/routes/profiles.py +++ b/pytorrent/routes/profiles.py @@ -8,6 +8,8 @@ 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()) 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") diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index 2ab9cc6..6283006 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -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,151 @@ 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 metadata-only profile transfer does not require source-user write access, but it still uses a safe target default. + 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 "none").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/") 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 +672,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) diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index 4e86848..8579f2a 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -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 'none')}) 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,78 @@ 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)) + target_path = _safe_remote_path(str(eff.get('target_path') or eff.get('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): + 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 'none').strip().lower() + if post_action not in {'none', 'start', 'stop', 'pause', 'check', 'recheck'}: + post_action = 'none' + 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 +469,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: diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py index 8118653..3d27054 100644 --- a/pytorrent/services/operation_logs.py +++ b/pytorrent/services/operation_logs.py @@ -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, diff --git a/pytorrent/services/rtorrent/client.py b/pytorrent/services/rtorrent/client.py index 44d3fa2..3af4c16 100644 --- a/pytorrent/services/rtorrent/client.py +++ b/pytorrent/services/rtorrent/client.py @@ -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: diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py index 7f4e6e4..dd16795 100644 --- a/pytorrent/services/rtorrent/torrents.py +++ b/pytorrent/services/rtorrent/torrents.py @@ -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", "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: Explicit post-transfer actions override state restoration and keep command effects predictable. + start_on_target = bool(move_data and (was_state or was_active)) if post_action == "none" 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 {} diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index e91eec9..b7311b6 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -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: @@ -302,6 +302,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 [] @@ -341,7 +347,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) @@ -416,6 +422,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: diff --git a/pytorrent/static/js/api.js b/pytorrent/static/js/api.js index 2edebbd..a8ca136 100644 --- a/pytorrent/static/js/api.js +++ b/pytorrent/static/js/api.js @@ -1 +1 @@ -export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST', label='Preparing download...'){\n // Note: Link creation is intentionally light; real file work starts when the browser opens the temporary /download URL.\n setBusy(true, label);\n try{\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span) span.textContent='Starting browser download...';\n // Note: Do not call setBusy(true) again here; this updates the active loader without increasing the busy counter.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n setTimeout(()=>setBusy(false), 1200);\n return json;\n } catch(e) {\n setBusy(false);\n throw e;\n }\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1){\n return openTemporaryDownload(\n `/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`,\n null,\n 'GET',\n 'Preparing .torrent file...'\n ).catch(e=>toast(e.message,'danger'));\n }\n return openTemporaryDownload(\n '/api/torrents/torrent-files.zip/link',\n {hashes:list},\n 'POST',\n `Preparing torrent ZIP (${list.length})...`\n ).catch(e=>toast(e.message,'danger'));\n }\n"; +export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } if(action==='profile_transfer'){ openProfileTransferModal(); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function profileTransferTargetRow(profile){\n const name=profile.name||('rTorrent '+profile.id);\n return ``;\n }\n function selectedTorrentSummaryRows(hashes){\n const rows=hashes.slice(0,6).map(h=>{\n const t=torrents.get(h)||{};\n return `
${esc(t.name||h)}${esc(t.size_h||'')}
`;\n }).join('');\n const more=hashes.length>6?`+${hashes.length-6} more`:'';\n return rows+more;\n }\n function selectedTorrentBytes(hashes){\n return hashes.reduce((sum,h)=>sum+Number((torrents.get(h)||{}).size||0),0);\n }\n function humanBytes(bytes){\n let value=Number(bytes||0); const units=['B','KB','MB','GB','TB','PB']; let idx=0;\n while(value>=1024 && idx=10||idx===0?0:1)} ${units[idx]}`;\n }\n function setProfileTransferPermission(message, tone='muted'){\n const el=$('profileTransferPermissionNote');\n if(!el) return;\n el.className=`profile-transfer-permission form-text mt-2 text-${tone}`;\n el.textContent=message;\n }\n function setProfileTransferDiskInfo(html){\n const el=$('profileTransferDiskInfo');\n if(el) el.innerHTML=html;\n }\n function profileTransferPayloadBase(){\n return {\n target_profile_id:Number($('profileTransferTargetId')?.value||0),\n move_data:!!($('profileTransferMoveData')?.checked),\n label_mode:$('profileTransferLabelMode')?.value||'none',\n label_value:$('profileTransferLabelValue')?.value||'',\n post_action:$('profileTransferPostAction')?.value||'none'\n };\n }\n async function validateProfileTransferSelection(){\n const payload=profileTransferPayloadBase();\n const selectedSize=selectedTorrentBytes(selectedHashes());\n if(!payload.target_profile_id){\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n return null;\n }\n setProfileTransferPermission(payload.move_data?'Checking data-move permissions...':'Only torrent metadata will be moved. Data files stay in the current location.', 'muted');\n try{\n const j=await post('/api/torrents/profile_transfer/validate',payload);\n const disk=j.disk||{};\n const free=Number(disk.free||0);\n const enough=!selectedSize || !free || free>=selectedSize;\n const diskTone=disk.ok?(enough?'success':'warning'):'warning';\n setProfileTransferDiskInfo(`
Destination: ${esc(j.target_path||'-')}
Free: ${esc(disk.free_h||'-')} / ${esc(disk.total_h||'-')} \u00b7 Selected: ${esc(humanBytes(selectedSize))}
${disk.warning?`
${esc(disk.warning)}
`:''}`);\n if(payload.move_data && j.move_data_allowed){\n setProfileTransferPermission(enough?'Data move is allowed for the destination path.':'Data move is allowed, but free space may be lower than selected torrent size.', enough?'success':'warning');\n } else if(payload.move_data){\n setProfileTransferPermission(`Data move is not allowed, so only torrent metadata will be moved. ${j.move_data_downgrade_reason||''}`.trim(), 'warning');\n }\n return j;\n }catch(e){\n setProfileTransferPermission(`Cannot validate data move. Only torrent metadata will be moved. ${e.message}`, 'warning');\n setProfileTransferDiskInfo('Destination disk space unavailable.');\n return {move_data_allowed:false,error:e.message};\n }\n }\n async function openProfileTransferModal(){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const list=$('profileTransferList');\n const count=$('profileTransferCount');\n const torrentList=$('profileTransferTorrentList');\n if(count) count.textContent=String(hashes.length);\n if(torrentList) torrentList.innerHTML=selectedTorrentSummaryRows(hashes);\n if(list) list.innerHTML='Loading profiles...';\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const activeId=Number(j.active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n const targets=(j.profiles||[]).filter(p=>Number(p.id)!==activeId && p.can_write!==false);\n if(list) list.innerHTML=targets.map(profileTransferTargetRow).join('') || '
No other writable profile is available.
';\n if($('profileTransferTargetId')) $('profileTransferTargetId').value='';\n if($('profileTransferMoveData')) $('profileTransferMoveData').checked=false;\n if($('profileTransferLabelMode')) $('profileTransferLabelMode').value='none';\n if($('profileTransferLabelValue')) { $('profileTransferLabelValue').value=''; $('profileTransferLabelValue').classList.add('d-none'); }\n if($('profileTransferPostAction')) $('profileTransferPostAction').value='none';\n new bootstrap.Modal($('profileTransferModal')).show();\n }catch(e){\n if(list) list.innerHTML=`
${esc(e.message)}
`;\n new bootstrap.Modal($('profileTransferModal')).show();\n }\n }\n async function submitProfileTransfer(){\n const hashes=selectedHashes();\n const payload={hashes,...profileTransferPayloadBase()};\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(!payload.target_profile_id) return toast('Choose target profile.', 'warning');\n const btn=$('profileTransferBtn');\n buttonBusy(btn,true);\n try{\n const j=await post('/api/torrents/profile_transfer',payload);\n markQueuedJobs(j, hashes, 'profile_transfer');\n const parts=Number(j.bulk_parts||1);\n const downgraded=j.transfer_move_data_downgraded?' Data move was not permitted; only torrent metadata will be moved.':'';\n toast(`Move to profile queued (${parts} part${parts===1?'':'s'}).${downgraded}`,'success');\n bootstrap.Modal.getInstance($('profileTransferModal'))?.hide();\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n $('profileTransferList')?.addEventListener('click',e=>{\n const btn=e.target.closest('.profile-transfer-card');\n if(!btn) return;\n document.querySelectorAll('.profile-transfer-card').forEach(x=>x.classList.remove('active'));\n btn.classList.add('active');\n if($('profileTransferTargetId')) $('profileTransferTargetId').value=btn.dataset.profileId||'';\n validateProfileTransferSelection();\n });\n $('profileTransferMoveData')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferPostAction')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferLabelMode')?.addEventListener('change',()=>{\n const custom=$('profileTransferLabelMode')?.value==='custom';\n $('profileTransferLabelValue')?.classList.toggle('d-none', !custom);\n });\n $('profileTransferBtn')?.addEventListener('click',submitProfileTransfer);\n\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST', label='Preparing download...'){\n // Note: Link creation is intentionally light; real file work starts when the browser opens the temporary /download URL.\n setBusy(true, label);\n try{\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span) span.textContent='Starting browser download...';\n // Note: Do not call setBusy(true) again here; this updates the active loader without increasing the busy counter.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n setTimeout(()=>setBusy(false), 1200);\n return json;\n } catch(e) {\n setBusy(false);\n throw e;\n }\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1){\n return openTemporaryDownload(\n `/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`,\n null,\n 'GET',\n 'Preparing .torrent file...'\n ).catch(e=>toast(e.message,'danger'));\n }\n return openTemporaryDownload(\n '/api/torrents/torrent-files.zip/link',\n {hashes:list},\n 'POST',\n `Preparing torrent ZIP (${list.length})...`\n ).catch(e=>toast(e.message,'danger'));\n }\n"; diff --git a/pytorrent/static/js/automationRules.js b/pytorrent/static/js/automationRules.js index 0f50eaa..df2305c 100644 --- a/pytorrent/static/js/automationRules.js +++ b/pytorrent/static/js/automationRules.js @@ -1 +1 @@ -export const automationRulesSource = " function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' \u2192 ')||'no actions';\n return `${cs} \u2192 ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' \u00b7 ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n const owner=r.owner_label?` ${esc(r.owner_label)}`:'';\n return `
${esc(r.name)} ${enabled?'on':'off'} ${owner}
${esc(ruleSummary(r))} \u00b7 cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n"; +export const automationRulesSource = " function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='profile_transfer'){\n eff.target_profile_id=Number($('autoProfileTransferTargetId')?.value||0);\n eff.move_data=!!$('autoProfileTransferMoveData')?.checked;\n eff.post_action=$('autoProfileTransferPostAction')?.value||'none';\n eff.label_mode=$('autoProfileTransferLabelMode')?.value||'none';\n eff.label_value=$('autoProfileTransferLabelValue')?.value||'';\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n if(e.type==='profile_transfer'){\n const flags=[];\n if(e.move_data) flags.push('move data if allowed');\n if(e.post_action && e.post_action!=='none') flags.push(e.post_action);\n if(e.label_mode && e.label_mode!=='none') flags.push(`label ${e.label_mode}`);\n return `move to profile #${e.target_profile_id||'?'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' \u2192 ')||'no actions';\n return `${cs} \u2192 ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' \u00b7 ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n const owner=r.owner_label?` ${esc(r.owner_label)}`:'';\n return `
${esc(r.name)} ${enabled?'on':'off'} ${owner}
${esc(ruleSummary(r))} \u00b7 cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n"; diff --git a/pytorrent/static/js/profileActions.js b/pytorrent/static/js/profileActions.js index 4d27cba..5b012a2 100644 --- a/pytorrent/static/js/profileActions.js +++ b/pytorrent/static/js/profileActions.js @@ -1 +1 @@ -export const profileActionsSource = " async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n clearProfileScopedFooterState();\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n clearProfileScopedTorrentView('Loading torrents...');\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n refreshFooterStatusNow(),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');}); $('profilePickerUseBtn')?.addEventListener('click',async()=>{const select=$('profileSelect');const id=select?.value;if(!id)return;const opt=select.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n"; +export const profileActionsSource = " async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n clearProfileScopedFooterState();\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n clearProfileScopedTorrentView('Loading torrents...');\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n refreshFooterStatusNow(),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent picker is click-to-switch and refreshes its cards every time the modal opens.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n renderProfilePickerChoices(j.profiles||[], j.active||null);\n }catch(e){ renderProfilePickerChoices([], null); }\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings);\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n"; diff --git a/pytorrent/static/js/profileSelection.js b/pytorrent/static/js/profileSelection.js index 5bfb911..1455796 100644 --- a/pytorrent/static/js/profileSelection.js +++ b/pytorrent/static/js/profileSelection.js @@ -1 +1 @@ -export const profileSelectionSource = " function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n"; +export const profileSelectionSource = " function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n function renderProfilePickerChoices(profiles=[], active=null){\n const list=$('profileChoiceList');\n if(!list) return;\n const activeId=Number(active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n list.innerHTML=(profiles||[]).map(p=>{\n const id=Number(p.id||0);\n const activeClass=id===activeId?' active':'';\n return ``;\n }).join('') || '
No profiles configured.
';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n renderProfilePickerChoices(j.profiles||[], j.active||null);\n }catch(e){ renderProfilePickerChoices([], null); }\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n\n $('profileChoiceList')?.addEventListener('click',async e=>{\n const btn=e.target.closest('.profile-choice-card');\n if(!btn) return;\n const id=btn.dataset.profileId;\n if(!id) return;\n await activateProfileAndRefresh(id, btn.querySelector(\"b\")?.textContent || \"rTorrent\");\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n });\n"; diff --git a/pytorrent/static/js/torrentAdd.js b/pytorrent/static/js/torrentAdd.js index ae1c784..0fe3a38 100644 --- a/pytorrent/static/js/torrentAdd.js +++ b/pytorrent/static/js/torrentAdd.js @@ -1 +1 @@ -export const torrentAddSource = " // Note: Add Torrent modal preview, submission and drag/drop upload handling are grouped here.\n const addPreviewState = {items: []};\n function renderTorrentPreview(items=[]){\n addPreviewState.items = items;\n const box=$('torrentPreview');\n if(!box) return;\n if(!items.length){ box.innerHTML=''; return; }\n const cards=items.map(item=>{\n const files=(item.files||[]).map((f,index)=>`${esc(f.path)}${esc(fmtBytes(f.size||0))}`).join('');\n const limitWarn=item.xmlrpc_too_large?`
Too large for current rTorrent XML-RPC upload limit: request ${esc(item.xmlrpc_request_h||'')} exceeds the configured limit. Change network.xmlrpc.size_limit in rTorrent config, e.g. 16M.
`:'';\n return `
${esc(item.name||item.filename)}${item.duplicate?'duplicate':''}${esc(fmtBytes(item.size||0))} · ${esc(item.file_count||0)} files
${esc(item.info_hash||'')}
${limitWarn}
${files}
`;\n }).join('');\n box.innerHTML=`
Preview before adding
${cards}`;\n }\n async function previewTorrentFiles(){\n const input=$('torrentFiles');\n const files=[...(input?.files||[])];\n const info=$('torrentFilesInfo');\n if(info) info.textContent=files.length?`Selected files: ${files.length}`:'No files selected.';\n if(!files.length) return renderTorrentPreview([]);\n const fd=new FormData();\n files.forEach(f=>fd.append('files',f));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n renderTorrentPreview(j.previews||[]);\n }catch(e){ if($('torrentPreview')) $('torrentPreview').innerHTML=`
${esc(e.message)}
`; }\n }\n function collectPreviewPriorities(){\n const out={};\n addPreviewState.items.forEach(item=>{\n const key=item.info_hash||item.filename;\n out[key]=[...(item.files||[])].map((f,index)=>({index,priority:document.querySelector(`.preview-file-priority[data-torrent=\"${CSS.escape(key)}\"][data-index=\"${index}\"]`)?.checked ? 1 : 0}));\n });\n return out;\n }\n function torrentFilesFromDrop(event){\n return [...(event.dataTransfer?.files||[])].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n }\n function dragHasFiles(event){\n const dt=event.dataTransfer;\n if(!dt) return false;\n if([...(dt.types||[])].includes('Files')) return true;\n return [...(dt.items||[])].some(item=>item.kind==='file');\n }\n async function droppedTorrentSummary(files){\n const fd=new FormData();\n files.forEach(file=>fd.append('files',file));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n const names=(j.previews||[]).map(item=>`${item.duplicate?'[duplicate] ':''}${item.name||item.filename}`).filter(Boolean);\n return names.length ? names : files.map(file=>file.name);\n }catch(e){\n return files.map(file=>file.name);\n }\n }\n async function addDroppedTorrentFiles(files){\n const torrentFiles=[...files].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n if(!torrentFiles.length){ toastMessage('toast.dropOnlyTorrents','warning'); return; }\n const names=await droppedTorrentSummary(torrentFiles);\n const preview=names.slice(0,8).join('\\n');\n const suffix=names.length>8?`\\n...and ${names.length-8} more`:'';\n if(!confirm(`Add ${torrentFiles.length} torrent file(s)?\\n\\n${preview}${suffix}`)) return;\n setBusy(true,'Adding dropped torrent files...');\n try{\n const fd=new FormData();\n fd.append('uris','');\n fd.append('directory',await getDefaultDownloadPath());\n fd.append('label','');\n fd.append('start','1');\n torrentFiles.forEach(file=>fd.append('files',file));\n const res=await fetch('/api/torrents/add',{method:'POST',body:fd});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n const queued=(j.job_ids||[]).length;\n if(queued && skipped) toastMessage('toast.droppedAddedSkipped','warning',{queued, skipped});\n else if(queued) toastMessage('toast.droppedAdded','success',{queued});\n else if(skipped) toastMessage('toast.droppedSkipped','warning',{skipped});\n else toastMessage('toast.droppedNone','warning');\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n document.body.classList.remove('dragging-torrent-files');\n }\n }\n function setupTorrentDropZone(){\n const zones=[$('tableWrap'),$('torrentBody'),$('mobileList'),document.querySelector('.content'),document.body].filter(Boolean);\n let dragDepth=0;\n const markActive=()=>document.body.classList.add('dragging-torrent-files');\n const clearActive=()=>document.body.classList.remove('dragging-torrent-files');\n const onDragEnter=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n dragDepth+=1;\n markActive();\n };\n const onDragOver=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n if(event.dataTransfer) event.dataTransfer.dropEffect='copy';\n markActive();\n };\n const onDragLeave=event=>{\n if(!dragHasFiles(event)) return;\n dragDepth=Math.max(0,dragDepth-1);\n if(!dragDepth) clearActive();\n };\n const onDrop=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n event.stopPropagation();\n dragDepth=0;\n clearActive();\n addDroppedTorrentFiles(event.dataTransfer?.files||[]);\n };\n zones.forEach(zone=>{\n if(zone.dataset?.torrentDropZoneBound==='1') return;\n if(zone.dataset) zone.dataset.torrentDropZoneBound='1';\n zone.addEventListener('dragenter',onDragEnter);\n zone.addEventListener('dragover',onDragOver);\n zone.addEventListener('dragleave',onDragLeave);\n zone.addEventListener('drop',onDrop);\n });\n }\n function hasTooLargeTorrentPreview(){\n // Note: Client-side upload blocking mirrors the server validation and gives feedback before the add request.\n return addPreviewState.items.some(item=>item.xmlrpc_too_large);\n }\n function addTorrentPayload(){\n const fd=new FormData();\n fd.append('uris',$('magnetInput')?.value||'');\n fd.append('directory',$('addPath')?.value||'');\n fd.append('label',$('addLabel')?.value||'');\n fd.append('start',$('addStart')?.checked?'1':'0');\n fd.append('file_priorities',JSON.stringify(collectPreviewPriorities()));\n [...($('torrentFiles')?.files||[])].forEach(f=>fd.append('files',f));\n return fd;\n }\n function resetAddTorrentFileInput(){\n // Note: Keeps file input, summary and preview synchronized after clearing or removing selected torrents.\n if($('torrentFiles')) $('torrentFiles').value='';\n if($('torrentFilesInfo')) $('torrentFilesInfo').textContent='No files selected.';\n renderTorrentPreview([]);\n }\n function resetAddTorrentForm(){\n // Note: Clears only the Add tab fields and preserves the Create Torrent tab state.\n if($('magnetInput')) $('magnetInput').value='';\n if($('addPath')) $('addPath').value='';\n if($('addLabel')) $('addLabel').value='';\n if($('addStart')) $('addStart').checked=true;\n resetAddTorrentFileInput();\n }\n function removeTorrentPreviewItem(card){\n // Note: Removes one selected torrent file from the Add tab without resetting other unfinished form data.\n const input=$('torrentFiles');\n const filename=card?.dataset?.filename || '';\n if(input?.files?.length && filename && typeof DataTransfer !== 'undefined'){\n const nextFiles=new DataTransfer();\n [...input.files].forEach(file=>{\n if(file.name!==filename) nextFiles.items.add(file);\n });\n input.files=nextFiles.files;\n previewTorrentFiles();\n return;\n }\n const key=card?.dataset?.torrent || '';\n renderTorrentPreview(addPreviewState.items.filter(item=>(item.info_hash||item.filename)!==key));\n }\n function clearAddTorrentFormFromModal(){\n // Note: Manual clear action is separate from closing the modal, so draft data still survives normal close/open cycles.\n resetAddTorrentForm();\n toast('Add torrent form cleared.', 'success');\n }\n async function addTorrentFromModal(){\n const btn=$('addBtn');\n buttonBusy(btn,true);\n setBusy(true);\n try{\n if(hasTooLargeTorrentPreview()) throw new Error(appMessage('toast.addTooLarge'));\n const res=await fetch('/api/torrents/add',{method:'POST',body:addTorrentPayload()});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n if(skipped) toastMessage('toast.addQueuedSkipped','warning',{count:skipped});\n else toastMessage('toast.addQueued','success');\n resetAddTorrentForm();\n bootstrap.Modal.getInstance($('addModal'))?.hide();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n buttonBusy(btn,false);\n setBusy(false);\n }\n }\n $('addBtn')?.addEventListener('click',addTorrentFromModal);\n $('clearAddTorrentBtn')?.addEventListener('click',clearAddTorrentFormFromModal);\n $('torrentFiles')?.addEventListener('change',previewTorrentFiles);\n $('torrentPreview')?.addEventListener('click',e=>{\n const card=e.target.closest('.torrent-preview-card');\n if(!card) return;\n if(e.target.closest('.preview-select-all')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=true);\n if(e.target.closest('.preview-select-none')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=false);\n if(e.target.closest('.preview-remove-torrent')) removeTorrentPreviewItem(card);\n });\n"; +export const torrentAddSource = " // Note: Add Torrent modal preview, submission and drag/drop upload handling are grouped here.\n const addPreviewState = {items: []};\n function renderTorrentPreview(items=[]){\n addPreviewState.items = items;\n const box=$('torrentPreview');\n if(!box) return;\n if(!items.length){ box.innerHTML=''; return; }\n const cards=items.map(item=>{\n const files=(item.files||[]).map((f,index)=>`${esc(f.path)}${esc(fmtBytes(f.size||0))}`).join('');\n const limitWarn=item.xmlrpc_too_large?`
Too large for current rTorrent XML-RPC upload limit: request ${esc(item.xmlrpc_request_h||'')} exceeds the configured limit. Change network.xmlrpc.size_limit in rTorrent config, e.g. 16M.
`:'';\n return `
${esc(item.name||item.filename)}${item.duplicate?'duplicate':''}${esc(fmtBytes(item.size||0))} \u00b7 ${esc(item.file_count||0)} files
${esc(item.info_hash||'')}
${limitWarn}
${files}
`;\n }).join('');\n box.innerHTML=`
Preview before adding
${cards}`;\n }\n async function previewTorrentFiles(){\n const input=$('torrentFiles');\n const files=[...(input?.files||[])];\n const info=$('torrentFilesInfo');\n if(info) info.textContent=files.length?`Selected files: ${files.length}`:'No files selected.';\n if(!files.length) return renderTorrentPreview([]);\n const fd=new FormData();\n files.forEach(f=>fd.append('files',f));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n renderTorrentPreview(j.previews||[]);\n }catch(e){ if($('torrentPreview')) $('torrentPreview').innerHTML=`
${esc(e.message)}
`; }\n }\n function collectPreviewPriorities(){\n const out={};\n addPreviewState.items.forEach(item=>{\n const key=item.info_hash||item.filename;\n out[key]=[...(item.files||[])].map((f,index)=>({index,priority:document.querySelector(`.preview-file-priority[data-torrent=\"${CSS.escape(key)}\"][data-index=\"${index}\"]`)?.checked ? 1 : 0}));\n });\n return out;\n }\n function torrentFilesFromDrop(event){\n return [...(event.dataTransfer?.files||[])].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n }\n function dragHasFiles(event){\n const dt=event.dataTransfer;\n if(!dt) return false;\n if([...(dt.types||[])].includes('Files')) return true;\n return [...(dt.items||[])].some(item=>item.kind==='file');\n }\n async function droppedTorrentSummary(files){\n const fd=new FormData();\n files.forEach(file=>fd.append('files',file));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n const names=(j.previews||[]).map(item=>`${item.duplicate?'[duplicate] ':''}${item.name||item.filename}`).filter(Boolean);\n return names.length ? names : files.map(file=>file.name);\n }catch(e){\n return files.map(file=>file.name);\n }\n }\n async function addDroppedTorrentFiles(files){\n const torrentFiles=[...files].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n if(!torrentFiles.length){ toastMessage('toast.dropOnlyTorrents','warning'); return; }\n const names=await droppedTorrentSummary(torrentFiles);\n const preview=names.slice(0,8).join('\\n');\n const suffix=names.length>8?`\\n...and ${names.length-8} more`:'';\n if(!confirm(`Add ${torrentFiles.length} torrent file(s)?\\n\\n${preview}${suffix}`)) return;\n setBusy(true,'Adding dropped torrent files...');\n try{\n const fd=new FormData();\n fd.append('uris','');\n fd.append('directory',await getDefaultDownloadPath());\n fd.append('label','');\n fd.append('start','1');\n torrentFiles.forEach(file=>fd.append('files',file));\n const res=await fetch('/api/torrents/add',{method:'POST',body:fd});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n const queued=(j.job_ids||[]).length;\n if(queued && skipped) toastMessage('toast.droppedAddedSkipped','warning',{queued, skipped});\n else if(queued) toastMessage('toast.droppedAdded','success',{queued});\n else if(skipped) toastMessage('toast.droppedSkipped','warning',{skipped});\n else toastMessage('toast.droppedNone','warning');\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n document.body.classList.remove('dragging-torrent-files');\n }\n }\n function setupTorrentDropZone(){\n const zones=[$('tableWrap'),$('torrentBody'),$('mobileList'),document.querySelector('.content'),document.body].filter(Boolean);\n let dragDepth=0;\n const markActive=()=>document.body.classList.add('dragging-torrent-files');\n const clearActive=()=>document.body.classList.remove('dragging-torrent-files');\n const onDragEnter=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n dragDepth+=1;\n markActive();\n };\n const onDragOver=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n if(event.dataTransfer) event.dataTransfer.dropEffect='copy';\n markActive();\n };\n const onDragLeave=event=>{\n if(!dragHasFiles(event)) return;\n dragDepth=Math.max(0,dragDepth-1);\n if(!dragDepth) clearActive();\n };\n const onDrop=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n event.stopPropagation();\n dragDepth=0;\n clearActive();\n addDroppedTorrentFiles(event.dataTransfer?.files||[]);\n };\n zones.forEach(zone=>{\n if(zone.dataset?.torrentDropZoneBound==='1') return;\n if(zone.dataset) zone.dataset.torrentDropZoneBound='1';\n zone.addEventListener('dragenter',onDragEnter);\n zone.addEventListener('dragover',onDragOver);\n zone.addEventListener('dragleave',onDragLeave);\n zone.addEventListener('drop',onDrop);\n });\n }\n function hasTooLargeTorrentPreview(){\n // Note: Client-side upload blocking mirrors the server validation and gives feedback before the add request.\n return addPreviewState.items.some(item=>item.xmlrpc_too_large);\n }\n function addTorrentPayload(){\n const fd=new FormData();\n fd.append('uris',$('magnetInput')?.value||'');\n fd.append('directory',$('addPath')?.value||'');\n fd.append('label',$('addLabel')?.value||'');\n fd.append('start',$('addStart')?.checked?'1':'0');\n fd.append('file_priorities',JSON.stringify(collectPreviewPriorities()));\n [...($('torrentFiles')?.files||[])].forEach(f=>fd.append('files',f));\n return fd;\n }\n function resetAddTorrentFileInput(){\n // Note: Keeps file input, summary and preview synchronized after clearing or removing selected torrents.\n if($('torrentFiles')) $('torrentFiles').value='';\n if($('torrentFilesInfo')) $('torrentFilesInfo').textContent='No files selected.';\n renderTorrentPreview([]);\n }\n async function resetAddTorrentForm(){\n // Note: Clears only Add tab inputs and keeps Save location useful by restoring the current profile default.\n const previousPath = String($('addPath')?.value || '').trim();\n if($('magnetInput')) $('magnetInput').value='';\n if($('addLabel')) $('addLabel').value='';\n if($('addStart')) $('addStart').checked=true;\n resetAddTorrentFileInput();\n if($('addPath')){\n const defaultPath = await getDefaultDownloadPath();\n $('addPath').value = defaultPath || previousPath;\n }\n }\n function removeTorrentPreviewItem(card){\n // Note: Removes one selected torrent file from the Add tab without resetting other unfinished form data.\n const input=$('torrentFiles');\n const filename=card?.dataset?.filename || '';\n if(input?.files?.length && filename && typeof DataTransfer !== 'undefined'){\n const nextFiles=new DataTransfer();\n [...input.files].forEach(file=>{\n if(file.name!==filename) nextFiles.items.add(file);\n });\n input.files=nextFiles.files;\n previewTorrentFiles();\n return;\n }\n const key=card?.dataset?.torrent || '';\n renderTorrentPreview(addPreviewState.items.filter(item=>(item.info_hash||item.filename)!==key));\n }\n async function clearAddTorrentFormFromModal(){\n // Note: Manual clear action is separate from closing the modal and never leaves Save location empty.\n await resetAddTorrentForm();\n toast('Add torrent form cleared.', 'success');\n }\n async function addTorrentFromModal(){\n const btn=$('addBtn');\n buttonBusy(btn,true);\n setBusy(true);\n try{\n if(hasTooLargeTorrentPreview()) throw new Error(appMessage('toast.addTooLarge'));\n const res=await fetch('/api/torrents/add',{method:'POST',body:addTorrentPayload()});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n if(skipped) toastMessage('toast.addQueuedSkipped','warning',{count:skipped});\n else toastMessage('toast.addQueued','success');\n await resetAddTorrentForm();\n bootstrap.Modal.getInstance($('addModal'))?.hide();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n buttonBusy(btn,false);\n setBusy(false);\n }\n }\n $('addBtn')?.addEventListener('click',addTorrentFromModal);\n $('clearAddTorrentBtn')?.addEventListener('click',clearAddTorrentFormFromModal);\n $('torrentFiles')?.addEventListener('change',previewTorrentFiles);\n $('torrentPreview')?.addEventListener('click',e=>{\n const card=e.target.closest('.torrent-preview-card');\n if(!card) return;\n if(e.target.closest('.preview-select-all')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=true);\n if(e.target.closest('.preview-select-none')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=false);\n if(e.target.closest('.preview-remove-torrent')) removeTorrentPreviewItem(card);\n });\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index e4406ed..9211886 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -3332,7 +3332,7 @@ body.mobile-mode .mobile-filter-bar { .torrent-preview { display: grid; - gap: .75rem; + gap: 0.75rem; } .torrent-preview-title { @@ -3358,7 +3358,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 +3712,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 +3730,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 +3779,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 +3815,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 +5907,112 @@ 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; +} + +@media (max-width: 767.98px) { + .profile-transfer-grid { + grid-template-columns: 1fr; + } +} + diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index de858d2..3d037ef 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -44,7 +44,7 @@ - +
@@ -98,7 +98,7 @@
-
0 selected
+
0 selected
@@ -144,6 +144,7 @@ +
@@ -166,15 +167,54 @@ + + + + + +
Choose columns visible in the torrent list.
Smart Queue
Automatic queue balancing for slow or stalled downloads.
Run Smart Queue during polling. Stopped torrents are managed; Paused torrents stay user-controlled.
When enabled, Smart Queue disables itself after a check finds no active downloads and no waiting stopped candidates. Enable it again manually when you add more work.
Next Smart Queue runnext: readyAutomatic runs use the cooldown below. Manual Check now still runs immediately.
Queue refill during cooldownnext: readyAutomatic keeps the current poller cadence. Custom runs only after the selected number of minutes. Off disables refill completely.
Periodically start a large batch above the active-download target. Normal Smart Queue checks keep replacing stalled items and drain overflow back toward the target.
Next Surge refill runnext: readyAutomatic Surge refill uses the interval below. Manual Smart Queue check still ignores this timer.
Recommended for best efficiency. When enabled, Smart Queue refills empty slots first and does not stop stalled downloads while active downloads are below the cap. Stalled cleanup resumes once the cap is reached or exceeded. Disable only if you prefer aggressive cleanup over keeping the active count near the cap.
Start stopped torrents with existing progress first, so Smart Queue finishes already started downloads before opening fresh ones.
When enabled, Smart Queue does not use seed/peer count as a stalled criterion.
When enabled, low speed is not required. With source and speed ignores enabled, only Stalled after seconds decides.
Choose torrents ignored by Smart Queue. Existing behavior stays unchanged for all non-excluded torrents.
Last operations
-
Automations / rules
Build a rule as: conditions first, then ordered actions. Matching torrents are handled as one batch and the cooldown is applied to the whole rule.
1. Rule
2. Conditions
3. Actions, in order
Rules
History
+
Automations / rules
Build a rule as: conditions first, then ordered actions. Matching torrents are handled as one batch and the cooldown is applied to the whole rule.
1. Rule
2. Conditions
3. Actions, in order
Rules
History
rTorrent config
Grouped rTorrent runtime settings with inline recommendations and compatibility status.
Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.
No changes
Loading config...
Cleanup / retention
One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.
Loading cleanup data...
Backup / restore
Profile backup restores only the active profile context. Application backup restores global application data and is available only to admins.
Creates and restores settings for the currently selected profile. User-scoped preferences are remapped to the current user where needed.
Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.
From fc03b7755b1108ad37685508a1c5cff8f625d78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 20 Jun 2026 17:01:48 +0200 Subject: [PATCH 2/4] move to anther profile --- pytorrent/db.py | 19 +++++++ pytorrent/migrations.py | 25 +++++++++ pytorrent/routes/profiles.py | 20 ++++++- pytorrent/routes/torrents.py | 4 +- pytorrent/services/automation_rules.py | 5 +- pytorrent/services/preferences.py | 74 +++++++++++++++++++++++++ pytorrent/services/workers.py | 11 ++-- pytorrent/static/js/api.js | 2 +- pytorrent/static/js/automationRules.js | 2 +- pytorrent/static/js/profileSelection.js | 2 +- pytorrent/static/styles.css | 43 ++++++++++++++ pytorrent/templates/index.html | 8 ++- 12 files changed, 201 insertions(+), 14 deletions(-) diff --git a/pytorrent/db.py b/pytorrent/db.py index dfb6d96..3b47879 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -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, diff --git a/pytorrent/migrations.py b/pytorrent/migrations.py index d1bb2c9..ad1ad3e 100644 --- a/pytorrent/migrations.py +++ b/pytorrent/migrations.py @@ -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, ) diff --git a/pytorrent/routes/profiles.py b/pytorrent/routes/profiles.py index d1233dd..346517a 100644 --- a/pytorrent/routes/profiles.py +++ b/pytorrent/routes/profiles.py @@ -2,6 +2,7 @@ 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(): @@ -10,6 +11,13 @@ def profiles_list(): 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") @@ -46,7 +54,17 @@ def profiles_delete(profile_id: int): @bp.post("/profiles//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 diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index 6283006..f72447f 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -578,7 +578,9 @@ def _profile_transfer_payload(source_profile: dict, data: dict, *, require_hashe 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 metadata-only profile transfer does not require source-user write access, but it still uses a safe target default. + # 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)) diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index 8579f2a..08ff3bc 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -408,7 +408,8 @@ def _automation_profile_transfer_payload(profile: dict[str, Any], eff: dict[str, if not target_profile: raise ValueError('Automation target profile does not exist') default_path = _safe_remote_path(rtorrent.default_download_path(target_profile)) - target_path = _safe_remote_path(str(eff.get('target_path') or eff.get('path') or default_path)) + 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) @@ -423,6 +424,8 @@ def _automation_profile_transfer_payload(profile: dict[str, Any], eff: dict[str, 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 diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 3328942..18962d8 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -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 diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index b7311b6..371ef69 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -289,6 +289,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) @@ -315,11 +321,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) diff --git a/pytorrent/static/js/api.js b/pytorrent/static/js/api.js index a8ca136..56db230 100644 --- a/pytorrent/static/js/api.js +++ b/pytorrent/static/js/api.js @@ -1 +1 @@ -export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } if(action==='profile_transfer'){ openProfileTransferModal(); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function profileTransferTargetRow(profile){\n const name=profile.name||('rTorrent '+profile.id);\n return ``;\n }\n function selectedTorrentSummaryRows(hashes){\n const rows=hashes.slice(0,6).map(h=>{\n const t=torrents.get(h)||{};\n return `
${esc(t.name||h)}${esc(t.size_h||'')}
`;\n }).join('');\n const more=hashes.length>6?`+${hashes.length-6} more`:'';\n return rows+more;\n }\n function selectedTorrentBytes(hashes){\n return hashes.reduce((sum,h)=>sum+Number((torrents.get(h)||{}).size||0),0);\n }\n function humanBytes(bytes){\n let value=Number(bytes||0); const units=['B','KB','MB','GB','TB','PB']; let idx=0;\n while(value>=1024 && idx=10||idx===0?0:1)} ${units[idx]}`;\n }\n function setProfileTransferPermission(message, tone='muted'){\n const el=$('profileTransferPermissionNote');\n if(!el) return;\n el.className=`profile-transfer-permission form-text mt-2 text-${tone}`;\n el.textContent=message;\n }\n function setProfileTransferDiskInfo(html){\n const el=$('profileTransferDiskInfo');\n if(el) el.innerHTML=html;\n }\n function profileTransferPayloadBase(){\n return {\n target_profile_id:Number($('profileTransferTargetId')?.value||0),\n move_data:!!($('profileTransferMoveData')?.checked),\n label_mode:$('profileTransferLabelMode')?.value||'none',\n label_value:$('profileTransferLabelValue')?.value||'',\n post_action:$('profileTransferPostAction')?.value||'none'\n };\n }\n async function validateProfileTransferSelection(){\n const payload=profileTransferPayloadBase();\n const selectedSize=selectedTorrentBytes(selectedHashes());\n if(!payload.target_profile_id){\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n return null;\n }\n setProfileTransferPermission(payload.move_data?'Checking data-move permissions...':'Only torrent metadata will be moved. Data files stay in the current location.', 'muted');\n try{\n const j=await post('/api/torrents/profile_transfer/validate',payload);\n const disk=j.disk||{};\n const free=Number(disk.free||0);\n const enough=!selectedSize || !free || free>=selectedSize;\n const diskTone=disk.ok?(enough?'success':'warning'):'warning';\n setProfileTransferDiskInfo(`
Destination: ${esc(j.target_path||'-')}
Free: ${esc(disk.free_h||'-')} / ${esc(disk.total_h||'-')} \u00b7 Selected: ${esc(humanBytes(selectedSize))}
${disk.warning?`
${esc(disk.warning)}
`:''}`);\n if(payload.move_data && j.move_data_allowed){\n setProfileTransferPermission(enough?'Data move is allowed for the destination path.':'Data move is allowed, but free space may be lower than selected torrent size.', enough?'success':'warning');\n } else if(payload.move_data){\n setProfileTransferPermission(`Data move is not allowed, so only torrent metadata will be moved. ${j.move_data_downgrade_reason||''}`.trim(), 'warning');\n }\n return j;\n }catch(e){\n setProfileTransferPermission(`Cannot validate data move. Only torrent metadata will be moved. ${e.message}`, 'warning');\n setProfileTransferDiskInfo('Destination disk space unavailable.');\n return {move_data_allowed:false,error:e.message};\n }\n }\n async function openProfileTransferModal(){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const list=$('profileTransferList');\n const count=$('profileTransferCount');\n const torrentList=$('profileTransferTorrentList');\n if(count) count.textContent=String(hashes.length);\n if(torrentList) torrentList.innerHTML=selectedTorrentSummaryRows(hashes);\n if(list) list.innerHTML='Loading profiles...';\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const activeId=Number(j.active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n const targets=(j.profiles||[]).filter(p=>Number(p.id)!==activeId && p.can_write!==false);\n if(list) list.innerHTML=targets.map(profileTransferTargetRow).join('') || '
No other writable profile is available.
';\n if($('profileTransferTargetId')) $('profileTransferTargetId').value='';\n if($('profileTransferMoveData')) $('profileTransferMoveData').checked=false;\n if($('profileTransferLabelMode')) $('profileTransferLabelMode').value='none';\n if($('profileTransferLabelValue')) { $('profileTransferLabelValue').value=''; $('profileTransferLabelValue').classList.add('d-none'); }\n if($('profileTransferPostAction')) $('profileTransferPostAction').value='none';\n new bootstrap.Modal($('profileTransferModal')).show();\n }catch(e){\n if(list) list.innerHTML=`
${esc(e.message)}
`;\n new bootstrap.Modal($('profileTransferModal')).show();\n }\n }\n async function submitProfileTransfer(){\n const hashes=selectedHashes();\n const payload={hashes,...profileTransferPayloadBase()};\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(!payload.target_profile_id) return toast('Choose target profile.', 'warning');\n const btn=$('profileTransferBtn');\n buttonBusy(btn,true);\n try{\n const j=await post('/api/torrents/profile_transfer',payload);\n markQueuedJobs(j, hashes, 'profile_transfer');\n const parts=Number(j.bulk_parts||1);\n const downgraded=j.transfer_move_data_downgraded?' Data move was not permitted; only torrent metadata will be moved.':'';\n toast(`Move to profile queued (${parts} part${parts===1?'':'s'}).${downgraded}`,'success');\n bootstrap.Modal.getInstance($('profileTransferModal'))?.hide();\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n $('profileTransferList')?.addEventListener('click',e=>{\n const btn=e.target.closest('.profile-transfer-card');\n if(!btn) return;\n document.querySelectorAll('.profile-transfer-card').forEach(x=>x.classList.remove('active'));\n btn.classList.add('active');\n if($('profileTransferTargetId')) $('profileTransferTargetId').value=btn.dataset.profileId||'';\n validateProfileTransferSelection();\n });\n $('profileTransferMoveData')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferPostAction')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferLabelMode')?.addEventListener('change',()=>{\n const custom=$('profileTransferLabelMode')?.value==='custom';\n $('profileTransferLabelValue')?.classList.toggle('d-none', !custom);\n });\n $('profileTransferBtn')?.addEventListener('click',submitProfileTransfer);\n\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `
${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST', label='Preparing download...'){\n // Note: Link creation is intentionally light; real file work starts when the browser opens the temporary /download URL.\n setBusy(true, label);\n try{\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span) span.textContent='Starting browser download...';\n // Note: Do not call setBusy(true) again here; this updates the active loader without increasing the busy counter.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n setTimeout(()=>setBusy(false), 1200);\n return json;\n } catch(e) {\n setBusy(false);\n throw e;\n }\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1){\n return openTemporaryDownload(\n `/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`,\n null,\n 'GET',\n 'Preparing .torrent file...'\n ).catch(e=>toast(e.message,'danger'));\n }\n return openTemporaryDownload(\n '/api/torrents/torrent-files.zip/link',\n {hashes:list},\n 'POST',\n `Preparing torrent ZIP (${list.length})...`\n ).catch(e=>toast(e.message,'danger'));\n }\n"; +export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } if(action==='profile_transfer'){ openProfileTransferModal(); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function profileTransferTargetRow(profile){\n const name=profile.name||('rTorrent '+profile.id);\n return ``;\n }\n function selectedTorrentSummaryRows(hashes){\n const rows=hashes.slice(0,6).map(h=>{\n const t=torrents.get(h)||{};\n return `
${esc(t.name||h)}${esc(t.size_h||'')}
`;\n }).join('');\n const more=hashes.length>6?`+${hashes.length-6} more`:'';\n return rows+more;\n }\n function selectedTorrentBytes(hashes){\n return hashes.reduce((sum,h)=>sum+Number((torrents.get(h)||{}).size||0),0);\n }\n function humanBytes(bytes){\n let value=Number(bytes||0); const units=['B','KB','MB','GB','TB','PB']; let idx=0;\n while(value>=1024 && idx=10||idx===0?0:1)} ${units[idx]}`;\n }\n function setProfileTransferPermission(message, tone='muted'){\n const el=$('profileTransferPermissionNote');\n if(!el) return;\n el.className=`profile-transfer-permission form-text mt-2 text-${tone}`;\n el.textContent=message;\n }\n function setProfileTransferDiskInfo(html){\n const el=$('profileTransferDiskInfo');\n if(el) el.innerHTML=html;\n }\n function renderProfileTransferPathHints(paths=[]){\n const box=$('profileTransferPathHints');\n if(!box) return;\n const clean=[...new Set((paths||[]).map(p=>String(p||'').trim()).filter(Boolean))];\n box.innerHTML=clean.length?clean.map(p=>``).join(' '):'No cached target roots.';\n }\n function setProfileTransferTargetPath(path, overwrite=false){\n const input=$('profileTransferTargetPath');\n if(input && (overwrite || !input.value.trim())) input.value=path||'';\n }\n function profileTransferPayloadBase(){\n return {\n target_profile_id:Number($('profileTransferTargetId')?.value||0),\n move_data:!!($('profileTransferMoveData')?.checked),\n label_mode:$('profileTransferLabelMode')?.value||'none',\n label_value:$('profileTransferLabelValue')?.value||'',\n post_action:$('profileTransferPostAction')?.value||'none',\n target_path:($('profileTransferTargetPath')?.value||'').trim()\n };\n }\n async function validateProfileTransferSelection(){\n const payload=profileTransferPayloadBase();\n const selectedSize=selectedTorrentBytes(selectedHashes());\n if(!payload.target_profile_id){\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n renderProfileTransferPathHints([]);\n return null;\n }\n setProfileTransferPermission(payload.move_data?'Checking data-move permissions...':'Only torrent metadata will be moved. Data files stay in the current location.', 'muted');\n try{\n const j=await post('/api/torrents/profile_transfer/validate',payload);\n const disk=j.disk||{};\n const free=Number(disk.free||0);\n const enough=!selectedSize || !free || free>=selectedSize;\n const diskTone=disk.ok?(enough?'success':'warning'):'warning';\n setProfileTransferTargetPath(j.target_path||'', false);\n renderProfileTransferPathHints(j.target_allowed_roots||[]);\n setProfileTransferDiskInfo(`
Destination: ${esc(j.target_path||'-')}
Free: ${esc(disk.free_h||'-')} / ${esc(disk.total_h||'-')} \u00b7 Selected: ${esc(humanBytes(selectedSize))}
${disk.warning?`
${esc(disk.warning)}
`:''}`);\n if(payload.move_data && j.move_data_allowed){\n setProfileTransferPermission(enough?'Data move is allowed for the destination path.':'Data move is allowed, but free space may be lower than selected torrent size.', enough?'success':'warning');\n } else if(payload.move_data){\n setProfileTransferPermission(`Data move is not allowed, so only torrent metadata will be moved. ${j.move_data_downgrade_reason||''}`.trim(), 'warning');\n }\n return j;\n }catch(e){\n setProfileTransferPermission(`Cannot validate data move. Only torrent metadata will be moved. ${e.message}`, 'warning');\n setProfileTransferDiskInfo('Destination disk space unavailable.');\n return {move_data_allowed:false,error:e.message};\n }\n }\n async function openProfileTransferModal(){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const list=$('profileTransferList');\n const count=$('profileTransferCount');\n const torrentList=$('profileTransferTorrentList');\n if(count) count.textContent=String(hashes.length);\n if(torrentList) torrentList.innerHTML=selectedTorrentSummaryRows(hashes);\n if(list) list.innerHTML='Loading profiles...';\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n renderProfileTransferPathHints([]);\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const activeId=Number(j.active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n const targets=(j.profiles||[]).filter(p=>Number(p.id)!==activeId && p.can_write!==false);\n if(list) list.innerHTML=targets.map(profileTransferTargetRow).join('') || '
No other writable profile is available.
';\n if($('profileTransferTargetId')) $('profileTransferTargetId').value='';\n if($('profileTransferTargetPath')) $('profileTransferTargetPath').value='';\n if($('profileTransferMoveData')) $('profileTransferMoveData').checked=false;\n if($('profileTransferLabelMode')) $('profileTransferLabelMode').value='none';\n if($('profileTransferLabelValue')) { $('profileTransferLabelValue').value=''; $('profileTransferLabelValue').classList.add('d-none'); }\n if($('profileTransferPostAction')) $('profileTransferPostAction').value='none';\n new bootstrap.Modal($('profileTransferModal')).show();\n }catch(e){\n if(list) list.innerHTML=`
${esc(e.message)}
`;\n new bootstrap.Modal($('profileTransferModal')).show();\n }\n }\n async function submitProfileTransfer(){\n const hashes=selectedHashes();\n const payload={hashes,...profileTransferPayloadBase()};\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(!payload.target_profile_id) return toast('Choose target profile.', 'warning');\n const btn=$('profileTransferBtn');\n buttonBusy(btn,true);\n try{\n const j=await post('/api/torrents/profile_transfer',payload);\n markQueuedJobs(j, hashes, 'profile_transfer');\n const parts=Number(j.bulk_parts||1);\n const downgraded=j.transfer_move_data_downgraded?' Data move was not permitted; only torrent metadata will be moved.':'';\n toast(`Move to profile queued (${parts} part${parts===1?'':'s'}).${downgraded}`,'success');\n bootstrap.Modal.getInstance($('profileTransferModal'))?.hide();\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n $('profileTransferList')?.addEventListener('click',e=>{\n const btn=e.target.closest('.profile-transfer-card');\n if(!btn) return;\n document.querySelectorAll('.profile-transfer-card').forEach(x=>x.classList.remove('active'));\n btn.classList.add('active');\n if($('profileTransferTargetId')) $('profileTransferTargetId').value=btn.dataset.profileId||'';\n validateProfileTransferSelection();\n });\n $('profileTransferMoveData')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferTargetPath')?.addEventListener('input',()=>{ clearTimeout(window.__profileTransferPathTimer); window.__profileTransferPathTimer=setTimeout(validateProfileTransferSelection, 350); });\n $('profileTransferPathHints')?.addEventListener('click',e=>{ const btn=e.target.closest('.profile-transfer-root'); if(!btn) return; setProfileTransferTargetPath(btn.dataset.path||'', true); validateProfileTransferSelection(); });\n $('profileTransferPostAction')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferLabelMode')?.addEventListener('change',()=>{\n const custom=$('profileTransferLabelMode')?.value==='custom';\n $('profileTransferLabelValue')?.classList.toggle('d-none', !custom);\n });\n $('profileTransferBtn')?.addEventListener('click',submitProfileTransfer);\n\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST', label='Preparing download...'){\n // Note: Link creation is intentionally light; real file work starts when the browser opens the temporary /download URL.\n setBusy(true, label);\n try{\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span) span.textContent='Starting browser download...';\n // Note: Do not call setBusy(true) again here; this updates the active loader without increasing the busy counter.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n setTimeout(()=>setBusy(false), 1200);\n return json;\n } catch(e) {\n setBusy(false);\n throw e;\n }\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1){\n return openTemporaryDownload(\n `/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`,\n null,\n 'GET',\n 'Preparing .torrent file...'\n ).catch(e=>toast(e.message,'danger'));\n }\n return openTemporaryDownload(\n '/api/torrents/torrent-files.zip/link',\n {hashes:list},\n 'POST',\n `Preparing torrent ZIP (${list.length})...`\n ).catch(e=>toast(e.message,'danger'));\n }\n"; diff --git a/pytorrent/static/js/automationRules.js b/pytorrent/static/js/automationRules.js index df2305c..799dfda 100644 --- a/pytorrent/static/js/automationRules.js +++ b/pytorrent/static/js/automationRules.js @@ -1 +1 @@ -export const automationRulesSource = " function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='profile_transfer'){\n eff.target_profile_id=Number($('autoProfileTransferTargetId')?.value||0);\n eff.move_data=!!$('autoProfileTransferMoveData')?.checked;\n eff.post_action=$('autoProfileTransferPostAction')?.value||'none';\n eff.label_mode=$('autoProfileTransferLabelMode')?.value||'none';\n eff.label_value=$('autoProfileTransferLabelValue')?.value||'';\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n if(e.type==='profile_transfer'){\n const flags=[];\n if(e.move_data) flags.push('move data if allowed');\n if(e.post_action && e.post_action!=='none') flags.push(e.post_action);\n if(e.label_mode && e.label_mode!=='none') flags.push(`label ${e.label_mode}`);\n return `move to profile #${e.target_profile_id||'?'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' \u2192 ')||'no actions';\n return `${cs} \u2192 ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' \u00b7 ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n const owner=r.owner_label?` ${esc(r.owner_label)}`:'';\n return `
${esc(r.name)} ${enabled?'on':'off'} ${owner}
${esc(ruleSummary(r))} \u00b7 cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n"; +export const automationRulesSource = " function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='profile_transfer'){\n eff.target_profile_id=Number($('autoProfileTransferTargetId')?.value||0);\n eff.target_path=($('autoProfileTransferTargetPath')?.value||'').trim();\n eff.move_data=!!$('autoProfileTransferMoveData')?.checked;\n eff.post_action=$('autoProfileTransferPostAction')?.value||'none';\n eff.label_mode=$('autoProfileTransferLabelMode')?.value||'none';\n eff.label_value=$('autoProfileTransferLabelValue')?.value||'';\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n if(e.type==='profile_transfer'){\n const flags=[];\n if(e.move_data) flags.push('move data if allowed');\n if(e.post_action && e.post_action!=='none') flags.push(e.post_action);\n if(e.label_mode && e.label_mode!=='none') flags.push(`label ${e.label_mode}`);\n const path=e.target_path?` \u00b7 ${e.target_path}`:'';\n return `move to profile #${e.target_profile_id||'?'}${path}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' \u2192 ')||'no actions';\n return `${cs} \u2192 ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' \u00b7 ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n const owner=r.owner_label?` ${esc(r.owner_label)}`:'';\n return `
${esc(r.name)} ${enabled?'on':'off'} ${owner}
${esc(ruleSummary(r))} \u00b7 cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n"; diff --git a/pytorrent/static/js/profileSelection.js b/pytorrent/static/js/profileSelection.js index 1455796..6ff272f 100644 --- a/pytorrent/static/js/profileSelection.js +++ b/pytorrent/static/js/profileSelection.js @@ -1 +1 @@ -export const profileSelectionSource = " function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n function renderProfilePickerChoices(profiles=[], active=null){\n const list=$('profileChoiceList');\n if(!list) return;\n const activeId=Number(active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n list.innerHTML=(profiles||[]).map(p=>{\n const id=Number(p.id||0);\n const activeClass=id===activeId?' active':'';\n return ``;\n }).join('') || '
No profiles configured.
';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n renderProfilePickerChoices(j.profiles||[], j.active||null);\n }catch(e){ renderProfilePickerChoices([], null); }\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n\n $('profileChoiceList')?.addEventListener('click',async e=>{\n const btn=e.target.closest('.profile-choice-card');\n if(!btn) return;\n const id=btn.dataset.profileId;\n if(!id) return;\n await activateProfileAndRefresh(id, btn.querySelector(\"b\")?.textContent || \"rTorrent\");\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n });\n"; +export const profileSelectionSource = " function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n function profileRuntimeStatsHtml(stats){\n if(!stats) return '';\n const parts=[];\n if(stats.torrent_count!==undefined) parts.push(`${stats.torrent_count} torrents`);\n if(stats.total_size_h) parts.push(`total ${stats.total_size_h}`);\n if(stats.seeding_count!==undefined || stats.downloading_count!==undefined) parts.push(`${stats.seeding_count||0} seeding / ${stats.downloading_count||0} downloading`);\n if(stats.updated_at) parts.push(`cached ${formatDateTime(stats.updated_at)}`);\n return parts.length?`
${parts.map(x=>`${esc(x)}`).join('')}
`:'';\n }\n\n function renderProfilePickerChoices(profiles=[], active=null){\n const list=$('profileChoiceList');\n if(!list) return;\n const activeId=Number(active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n list.innerHTML=(profiles||[]).map(p=>{\n const id=Number(p.id||0);\n const activeClass=id===activeId?' active':'';\n return ``;\n }).join('') || '
No profiles configured.
';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n renderProfilePickerChoices(j.profiles||[], j.active||null);\n }catch(e){ renderProfilePickerChoices([], null); }\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n\n $('profileChoiceList')?.addEventListener('click',async e=>{\n const btn=e.target.closest('.profile-choice-card');\n if(!btn) return;\n const id=btn.dataset.profileId;\n if(!id) return;\n await activateProfileAndRefresh(id, btn.querySelector(\"b\")?.textContent || \"rTorrent\");\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n });\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 9211886..2893202 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -6010,6 +6010,49 @@ body.compact-torrent-list .mobile-progress .torrent-progress { 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; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 3d037ef..c6ed0c1 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -160,7 +160,7 @@