From 51e00a4e373b3a68fcc2cb36d8202920b0985a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 7 Jun 2026 22:52:17 +0200 Subject: [PATCH] fix automation visibility and execution by profile ownership --- pytorrent/routes/automations.py | 49 +++-- pytorrent/routes/system.py | 9 +- pytorrent/services/automation_rules.py | 285 +++++++++++++++++-------- pytorrent/services/websocket.py | 5 +- pytorrent/static/js/automationRules.js | 2 +- 5 files changed, 232 insertions(+), 118 deletions(-) diff --git a/pytorrent/routes/automations.py b/pytorrent/routes/automations.py index 76aea82..d4f5447 100644 --- a/pytorrent/routes/automations.py +++ b/pytorrent/routes/automations.py @@ -2,6 +2,11 @@ from __future__ import annotations from ._shared import * + +def _automation_user_id() -> int: + return int(default_user_id() or 0) + + @bp.get('/automations') def automations_get(): from ..services import automation_rules @@ -9,13 +14,15 @@ def automations_get(): if not profile: return ok({'rules': [], 'history': [], 'error': 'No profile'}) try: - user_id = default_user_id() - return ok({'rules': automation_rules.list_rules(profile['id'], user_id=user_id), 'history': automation_rules.list_history(profile['id'], user_id=user_id)}) + user_id = _automation_user_id() + return ok({ + 'rules': automation_rules.list_rules(profile['id'], user_id=user_id), + 'history': automation_rules.list_history(profile['id'], user_id=user_id), + }) except Exception as exc: return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500 - @bp.get('/automations/export') def automations_export(): from ..services import automation_rules @@ -23,14 +30,12 @@ def automations_export(): if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: - # Note: JSON export is profile-scoped and excludes execution history/cooldown state. - data = automation_rules.export_rules(profile['id'], user_id=default_user_id()) + data = automation_rules.export_rules(profile['id'], user_id=_automation_user_id()) return ok({'export': data, 'count': len(data.get('rules') or [])}) except Exception as exc: return jsonify({'ok': False, 'error': str(exc)}), 400 - @bp.post('/automations/import') def automations_import(): from ..services import automation_rules @@ -40,15 +45,13 @@ def automations_import(): try: payload = request.get_json(silent=True) or {} replace = str(request.args.get('replace') or '').lower() in {'1', 'true', 'yes'} or bool(payload.get('replace')) if isinstance(payload, dict) else False - # Note: Import appends rules by default, so existing automations remain untouched. - user_id = default_user_id() + user_id = _automation_user_id() imported = automation_rules.import_rules(profile['id'], payload, user_id=user_id, replace=replace) return ok({'imported': len(imported), 'rules': automation_rules.list_rules(profile['id'], user_id=user_id)}) except Exception as exc: return jsonify({'ok': False, 'error': str(exc)}), 400 - @bp.post('/automations') def automations_save(): from ..services import automation_rules @@ -56,14 +59,13 @@ def automations_save(): if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: - user_id = default_user_id() + user_id = _automation_user_id() rule = automation_rules.save_rule(profile['id'], request.get_json(silent=True) or {}, user_id=user_id) return ok({'rule': rule, 'rules': automation_rules.list_rules(profile['id'], user_id=user_id)}) except Exception as exc: return jsonify({'ok': False, 'error': str(exc)}), 400 - @bp.delete('/automations/') def automations_delete(rule_id: int): from ..services import automation_rules @@ -71,14 +73,13 @@ def automations_delete(rule_id: int): if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: - user_id = default_user_id() + user_id = _automation_user_id() automation_rules.delete_rule(rule_id, profile['id'], user_id=user_id) return ok({'rules': automation_rules.list_rules(profile['id'], user_id=user_id)}) except Exception as exc: return jsonify({'ok': False, 'error': str(exc)}), 400 - @bp.post('/automations//run') def automations_run_rule(rule_id: int): from ..services import automation_rules @@ -86,9 +87,12 @@ def automations_run_rule(rule_id: int): if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: - # Note: Single-rule run ignores disabled state and cooldown for manual troubleshooting. - user_id = default_user_id() - return ok({'result': automation_rules.check(profile, user_id=user_id, force=True, rule_id=rule_id), 'rules': automation_rules.list_rules(profile['id'], user_id=user_id), 'history': automation_rules.list_history(profile['id'], user_id=user_id)}) + user_id = _automation_user_id() + return ok({ + 'result': automation_rules.check(profile, user_id=user_id, force=True, rule_id=rule_id), + 'rules': automation_rules.list_rules(profile['id'], user_id=user_id), + 'history': automation_rules.list_history(profile['id'], user_id=user_id), + }) except Exception as exc: return jsonify({'ok': False, 'error': str(exc)}), 500 @@ -100,14 +104,16 @@ def automations_check(): if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: - # Note: Force check ignores disabled state and cooldown, allowing a one-off manual automation pass. - user_id = default_user_id() - return ok({'result': automation_rules.check(profile, user_id=user_id, force=True), 'rules': automation_rules.list_rules(profile['id'], user_id=user_id), 'history': automation_rules.list_history(profile['id'], user_id=user_id)}) + user_id = _automation_user_id() + return ok({ + 'result': automation_rules.check(profile, user_id=user_id, force=True), + 'rules': automation_rules.list_rules(profile['id'], user_id=user_id), + 'history': automation_rules.list_history(profile['id'], user_id=user_id), + }) except Exception as exc: return jsonify({'ok': False, 'error': str(exc)}), 500 - @bp.delete('/automations/history') def automations_history_clear(): from ..services import automation_rules @@ -115,8 +121,7 @@ def automations_history_clear(): if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: - # Note: Clear only automation execution logs; rules and cooldown state stay unchanged. - user_id = default_user_id() + user_id = _automation_user_id() deleted = automation_rules.clear_history(profile['id'], user_id=user_id) return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id'], user_id=user_id), 'cleanup': cleanup_summary()}) except Exception as exc: diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index a654a7c..35ebd51 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -260,14 +260,13 @@ def cleanup_automations(): if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 profile_id = int(profile["id"]) - user_id = default_user_id() with connect() as conn: exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() if not exists: deleted = 0 else: - # Note: Cleanup panel removes only current-user automation logs for the active profile; saved rules stay intact. - cur = conn.execute("DELETE FROM automation_history WHERE user_id=? AND profile_id=?", (user_id, profile_id)) + # Note: Automation history is profile-scoped and can include rules owned by multiple users. + cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (profile_id,)) deleted = int(cur.rowcount or 0) return ok({"deleted": deleted, "cleanup": cleanup_summary()}) @@ -303,8 +302,8 @@ def cleanup_all(): if not exists_auto: deleted_auto = 0 else: - # Note: Full cleanup clears only the current user's automation history for the active profile. - cur = conn.execute("DELETE FROM automation_history WHERE user_id=? AND profile_id=?", (default_user_id(), active_profile_id)) + # Note: Full cleanup clears automation history for the active profile, regardless of rule owner. + cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (active_profile_id,)) deleted_auto = int(cur.rowcount or 0) return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()}) diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index 71923b7..2a629ae 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -12,11 +12,7 @@ AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'} def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None = None) -> int: - """Return the user id that should own automation reads, jobs and history. - - Note: Request-bound calls must keep the authenticated/bypass user, while - background poller calls can safely fall back to the profile owner. - """ + """Return a safe user id for rule ownership or background execution.""" if user_id: return int(user_id) request_user_id = auth.current_user_id() @@ -28,14 +24,19 @@ def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None def _loads(value: str | None, default: Any) -> Any: - try: return json.loads(value or '') - except Exception: return default + try: + return json.loads(value or '') + except Exception: + return default def _ts(value: str | None) -> float: - if not value: return 0.0 - try: return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp() - except Exception: return 0.0 + if not value: + return 0.0 + try: + return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp() + except Exception: + return 0.0 def _now_ts() -> float: @@ -46,7 +47,8 @@ def _label_names(value: str | None) -> list[str]: seen = [] for part in str(value or '').replace(';', ',').replace('|', ',').split(','): item = part.strip() - if item and item not in seen: seen.append(item) + if item and item not in seen: + seen.append(item) return seen @@ -54,7 +56,8 @@ def _label_value(labels: list[str]) -> str: out = [] for label in labels: label = str(label or '').strip() - if label and label not in out: out.append(label) + if label and label not in out: + out.append(label) return ', '.join(out) @@ -62,35 +65,98 @@ def _rule_row(row: dict[str, Any]) -> dict[str, Any]: item = dict(row) item['conditions'] = _loads(item.pop('conditions_json', '[]'), []) item['effects'] = _loads(item.pop('effects_json', '[]'), []) + item['owner_user_id'] = int(item.get('user_id') or 0) + item['owner_username'] = str(item.get('owner_username') or '').strip() + item['owner_display_name'] = str(item.get('owner_display_name') or '').strip() + item['owner_label'] = item['owner_display_name'] or item['owner_username'] or f"user #{item['owner_user_id']}" return item -def list_rules(profile_id: int | None = None, user_id: int | None = None) -> list[dict[str, Any]]: - user_id = _resolve_user_id(user_id=user_id) +def _require_profile_read(profile_id: int, user_id: int | None = None) -> int: + viewer_id = _resolve_user_id(user_id=user_id) + if not auth.can_access_profile(profile_id, viewer_id): + raise ValueError('No access to profile') + return viewer_id + + +def _require_profile_write(profile_id: int, user_id: int | None = None) -> int: + viewer_id = _resolve_user_id(user_id=user_id) + if not auth.can_write_profile(profile_id, viewer_id): + raise ValueError('No write access to profile') + return viewer_id + + +def _can_manage_rule(profile_id: int, rule: dict[str, Any], user_id: int) -> bool: + return int(rule.get('user_id') or 0) == int(user_id) or auth.can_write_profile(profile_id, user_id) + + +def _select_rules_sql(where_sql: str) -> str: + return f''' + SELECT + r.*, + u.username AS owner_username, + COALESCE(u.display_name, '') AS owner_display_name + FROM automation_rules r + LEFT JOIN users u ON u.id = r.user_id + WHERE {where_sql} + ORDER BY r.enabled DESC, r.name COLLATE NOCASE + ''' + + +def _decorate_rule_state(rules: list[dict[str, Any]], profile_id: int | None) -> None: if profile_id is None: - profile = active_profile(); profile_id = int(profile['id']) if profile else None + return with connect() as conn: - rows = conn.execute('SELECT * FROM automation_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY enabled DESC, name COLLATE NOCASE', (user_id, profile_id)).fetchall() + for rule in rules: + row = conn.execute( + 'SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', + (rule['id'], profile_id, '__rule__'), + ).fetchone() + last = row.get('last_applied_at') if row else None + cooldown = int(rule.get('cooldown_minutes') or 0) + remaining = max(0, int((_ts(last) + cooldown * 60) - _now_ts())) if last and cooldown > 0 else 0 + rule['last_applied_at'] = last + rule['cooldown_remaining_seconds'] = remaining + + +def list_rules(profile_id: int | None = None, user_id: int | None = None) -> list[dict[str, Any]]: + if profile_id is None: + profile = active_profile(user_id=user_id) + profile_id = int(profile['id']) if profile else None + if profile_id is None: + return [] + _require_profile_read(profile_id, user_id) + with connect() as conn: + rows = conn.execute(_select_rules_sql('r.profile_id=?'), (profile_id,)).fetchall() rules = [_rule_row(r) for r in rows] - if profile_id is not None: - with connect() as conn: - for rule in rules: - row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, '__rule__')).fetchone() - last = row.get('last_applied_at') if row else None - cooldown = int(rule.get('cooldown_minutes') or 0) - remaining = max(0, int((_ts(last) + cooldown * 60) - _now_ts())) if last and cooldown > 0 else 0 - # Note: Exposes live cooldown timers for the Automations tab without changing rule behavior. - rule['last_applied_at'] = last - rule['cooldown_remaining_seconds'] = remaining + _decorate_rule_state(rules, profile_id) + return rules + + +def _list_enabled_rules_for_profile(profile_id: int, rule_id: int | None = None, force: bool = False) -> list[dict[str, Any]]: + params: list[Any] = [profile_id] + clauses = ['r.profile_id=?'] + if rule_id is not None: + clauses.append('r.id=?') + params.append(int(rule_id)) + if not force: + clauses.append('r.enabled=1') + with connect() as conn: + rows = conn.execute(_select_rules_sql(' AND '.join(clauses)), tuple(params)).fetchall() + rules = [_rule_row(r) for r in rows] + _decorate_rule_state(rules, profile_id) return rules def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[str, Any]: - user_id = _resolve_user_id(user_id=user_id) + _require_profile_read(profile_id, user_id) with connect() as conn: - row = conn.execute('SELECT * FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id)).fetchone() - if not row: raise ValueError('Rule not found') - return _rule_row(row) + row = conn.execute(_select_rules_sql('r.id=? AND r.profile_id=?'), (rule_id, profile_id)).fetchone() + if not row: + raise ValueError('Rule not found') + rule = _rule_row(row) + _decorate_rule_state([rule], profile_id) + return rule def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]: @@ -104,70 +170,96 @@ def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]: def export_rules(profile_id: int, user_id: int | None = None) -> dict[str, Any]: - # Note: Export contains only portable rule definitions, never DB ids or execution history. rules = [_portable_rule(rule) for rule in list_rules(profile_id, user_id)] - return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'rules': rules} + return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'scope': 'profile', 'rules': rules} def import_rules(profile_id: int, payload: dict[str, Any] | list[Any], user_id: int | None = None, replace: bool = False) -> list[dict[str, Any]]: - user_id = _resolve_user_id(user_id=user_id) + owner_id = _require_profile_write(profile_id, user_id) raw_rules = payload if isinstance(payload, list) else payload.get('rules', []) if isinstance(payload, dict) else [] if not isinstance(raw_rules, list) or not raw_rules: raise ValueError('Import file does not contain automation rules') if replace: with connect() as conn: - # Note: Optional replace is profile-scoped; it does not touch other profiles or history tables. - conn.execute('DELETE FROM automation_rules WHERE user_id=? AND profile_id=?', (user_id, profile_id)) + conn.execute('DELETE FROM automation_rules WHERE profile_id=?', (profile_id,)) conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,)) imported = [] for raw in raw_rules: if not isinstance(raw, dict): continue rule = _portable_rule(raw) - rule.pop('id', None) - imported.append(save_rule(profile_id, rule, user_id)) + imported.append(save_rule(profile_id, rule, owner_id)) if not imported: raise ValueError('No valid automation rules found') return imported def save_rule(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: - user_id = _resolve_user_id(user_id=user_id) + actor_id = _resolve_user_id(user_id=user_id) name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule' conditions = data.get('conditions') or [] effects = data.get('effects') or [] - if not isinstance(conditions, list) or not conditions: raise ValueError('Rule needs at least one condition') - if not isinstance(effects, list) or not effects: raise ValueError('Rule needs at least one effect') + if not isinstance(conditions, list) or not conditions: + raise ValueError('Rule needs at least one condition') + if not isinstance(effects, list) or not effects: + raise ValueError('Rule needs at least one effect') cooldown = max(0, int(data.get('cooldown_minutes') or 0)) enabled = 1 if data.get('enabled', True) else 0 - now = utcnow(); rule_id = int(data.get('id') or 0) - with connect() as conn: - if rule_id: - conn.execute('UPDATE automation_rules SET name=?, enabled=?, conditions_json=?, effects_json=?, cooldown_minutes=?, updated_at=? WHERE id=? AND user_id=? AND profile_id=?', (name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, rule_id, user_id, profile_id)) - else: - cur = conn.execute('INSERT INTO automation_rules(user_id,profile_id,name,enabled,conditions_json,effects_json,cooldown_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)', (user_id, profile_id, name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, now)) + now = utcnow() + rule_id = int(data.get('id') or 0) + if rule_id: + existing = get_rule(rule_id, profile_id, actor_id) + if not _can_manage_rule(profile_id, existing, actor_id): + raise ValueError('No permission to edit this automation rule') + owner_id = int(existing.get('user_id') or existing.get('owner_user_id') or actor_id) + with connect() as conn: + cur = conn.execute( + 'UPDATE automation_rules SET name=?, enabled=?, conditions_json=?, effects_json=?, cooldown_minutes=?, updated_at=? WHERE id=? AND profile_id=?', + (name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, rule_id, profile_id), + ) + if not cur.rowcount: + raise ValueError('Rule not found') + else: + owner_id = _require_profile_write(profile_id, actor_id) + with connect() as conn: + cur = conn.execute( + 'INSERT INTO automation_rules(user_id,profile_id,name,enabled,conditions_json,effects_json,cooldown_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)', + (owner_id, profile_id, name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, now), + ) rule_id = int(cur.lastrowid) - return get_rule(rule_id, profile_id, user_id) + return get_rule(rule_id, profile_id, actor_id) def delete_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> None: - user_id = _resolve_user_id(user_id=user_id) + actor_id = _resolve_user_id(user_id=user_id) + rule = get_rule(rule_id, profile_id, actor_id) + if not _can_manage_rule(profile_id, rule, actor_id): + raise ValueError('No permission to delete this automation rule') with connect() as conn: - conn.execute('DELETE FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id)) + conn.execute('DELETE FROM automation_rules WHERE id=? AND profile_id=?', (rule_id, profile_id)) conn.execute('DELETE FROM automation_rule_state WHERE rule_id=? AND profile_id=?', (rule_id, profile_id)) def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: - user_id = _resolve_user_id(user_id=user_id) + _require_profile_read(profile_id, user_id) with connect() as conn: - return conn.execute('SELECT * FROM automation_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', (user_id, profile_id, max(1, min(int(limit or 30), 100)))).fetchall() + return conn.execute(''' + SELECT + h.*, + u.username AS owner_username, + COALESCE(u.display_name, '') AS owner_display_name + FROM automation_history h + LEFT JOIN users u ON u.id = h.user_id + WHERE h.profile_id=? + ORDER BY h.created_at DESC + LIMIT ? + ''', (profile_id, max(1, min(int(limit or 30), 100)))).fetchall() def clear_history(profile_id: int, user_id: int | None = None) -> int: - user_id = _resolve_user_id(user_id=user_id) + _require_profile_write(profile_id, user_id) with connect() as conn: - # Note: Manual automation log cleanup is scoped to the active profile and current user. - cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id)) + cur = conn.execute('DELETE FROM automation_history WHERE profile_id=?', (profile_id,)) return int(cur.rowcount or 0) @@ -192,46 +284,47 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, for cond in rule.get('conditions') or []: raw_ok = _condition_true(t, cond) negated = bool(cond.get('negate')) - # Note: Negation is applied in the backend, so UI and API only store the condition flag. ok = (not raw_ok) if negated else raw_ok if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated: row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone() - if ok: - since = row['condition_since_at'] if row and row.get('condition_since_at') else now - conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,last_matched_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=COALESCE(automation_rule_state.condition_since_at, excluded.condition_since_at), last_matched_at=excluded.last_matched_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, since, now, now)) - delayed_ok = delayed_ok and (now_ts - _ts(since) >= int(cond.get('minutes') or 0) * 60) + since = row.get('condition_since_at') if row else None + if raw_ok: + if not since: + conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=excluded.condition_since_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now)) + since = now + delayed_ok = delayed_ok and (_ts(since) + int(cond.get('minutes') or 0) * 60 <= now_ts) else: - conn.execute('UPDATE automation_rule_state SET condition_since_at=NULL, updated_at=? WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (now, rule['id'], profile_id, h)); delayed_ok = False + conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=NULL, updated_at=excluded.updated_at', (rule['id'], profile_id, h, None, now)) + delayed_ok = False else: immediate_ok = immediate_ok and ok return immediate_ok and delayed_ok -def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '__rule__') -> bool: +def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int) -> bool: cooldown = int(rule.get('cooldown_minutes') or 0) if cooldown <= 0: return True - row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, torrent_hash)).fetchone() - if not row or not row.get('last_applied_at'): return True - return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60 + row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, '__rule__')).fetchone() + last = row.get('last_applied_at') if row else None + return not last or (_ts(last) + cooldown * 60 <= _now_ts()) def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None: - # Note: Cooldown is rule-level, so one batch execution blocks the whole automation until the cooldown expires. conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now)) def _chunk_hashes(hashes: list[str], size: int = AUTOMATION_JOB_CHUNK_SIZE) -> list[list[str]]: - # Note: Automation jobs use the same small-batch idea as manual bulk jobs, so long move/remove/actions remain visible and recoverable. safe_size = max(1, int(size or AUTOMATION_JOB_CHUNK_SIZE)) return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)] def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrents_by_hash: dict[str, dict[str, Any]], extra: dict[str, Any] | None = None) -> dict[str, Any]: - # Note: Job context marks jobs created by automations, making the Jobs log explain what rule queued the work. ctx = { 'source': 'automation', 'rule_id': rule.get('id'), 'rule_name': str(rule.get('name') or ''), + 'rule_owner_user_id': int(rule.get('user_id') or rule.get('owner_user_id') or 0), + 'rule_owner': str(rule.get('owner_label') or ''), 'effect': eff_type, 'bulk': len(hashes) > 1, 'hash_count': len(hashes), @@ -251,7 +344,6 @@ def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrent def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], action_name: str, hashes: list[str], payload: dict[str, Any], torrents_by_hash: dict[str, dict[str, Any]], user_id: int | None = None, context_extra: dict[str, Any] | None = None) -> list[str]: - # Note: Light automation actions stay in one job; heavy actions are chunked for recoverability. job_ids: list[str] = [] chunks = [hashes] if action_name in AUTOMATION_LIGHT_ACTIONS else _chunk_hashes(hashes) for index, chunk in enumerate(chunks, start=1): @@ -267,7 +359,8 @@ def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], actio extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))}) if action_name == 'remove': extra.update({'remove_data': bool(part_payload.get('remove_data'))}) - part_payload['job_context'] = _job_context(rule, str(context_extra.get('effect_type') if context_extra else action_name), chunk, torrents_by_hash, extra) + effect_type = str(context_extra.get('effect_type') if context_extra else action_name) + part_payload['job_context'] = _job_context(rule, effect_type, chunk, torrents_by_hash, extra) job_ids.append(enqueue(action_name, int(profile['id']), part_payload, user_id=user_id)) return job_ids @@ -293,7 +386,6 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str elif typ == 'add_label': label = str(eff.get('label') or '').strip() if label: - # Note: Add-label automations are idempotent and queue only torrents that need a changed label value. grouped: dict[str, list[str]] = {} for h in hashes: labels = labels_by_hash.get(h, []) @@ -312,7 +404,6 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str elif typ == 'remove_label': label = str(eff.get('label') or '').strip() if label: - # Note: Remove-label automations are queued only for torrents where the requested label exists. grouped: dict[str, list[str]] = {} for h in hashes: labels = labels_by_hash.get(h, []) @@ -330,7 +421,6 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str elif typ == 'set_labels': value = _label_value(_label_names(eff.get('labels'))) target_labels = _label_names(value) - # Note: Set-labels queues a job only if the current labels differ from the requested exact list. target_hashes = [h for h in hashes if labels_by_hash.get(h, []) != target_labels] for h in target_hashes: labels_by_hash[h] = list(target_labels) @@ -338,28 +428,45 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str job_ids = _enqueue_automation_job(profile, rule, 'set_label', target_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'set_labels', 'labels': value}) applied.append({'type': 'set_labels', 'labels': value, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids}) elif typ in {'pause', 'stop', 'start', 'resume', 'recheck', 'reannounce'}: - # Note: Runtime actions are queued as jobs too, so automation activity is visible in the Jobs panel. job_ids = _enqueue_automation_job(profile, rule, typ, hashes, {}, torrents_by_hash, user_id, {'effect_type': typ}) applied.append({'type': typ, 'count': len(hashes), 'target_hashes': hashes, 'job_ids': job_ids}) elif typ == 'remove': - # Note: Remove is supported for automation payloads and still goes through ordered worker jobs. payload = {'remove_data': bool(eff.get('remove_data'))} job_ids = _enqueue_automation_job(profile, rule, 'remove', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'remove'}) applied.append({'type': 'remove', 'count': len(hashes), 'target_hashes': hashes, 'remove_data': payload['remove_data'], 'job_ids': job_ids}) return applied +def _record_skipped_rule(profile_id: int, rule: dict[str, Any], hashes: list[str], reason: str, now: str) -> dict[str, Any]: + action = {'type': 'skipped', 'error': reason, 'count': len(hashes)} + owner_id = int(rule.get('user_id') or rule.get('owner_user_id') or default_user_id()) + torrent_hash = hashes[0] if len(hashes) == 1 else f'batch:{rule["id"]}:{now}:skipped' + torrent_name = '1 torrent' if len(hashes) == 1 else f'{len(hashes)} torrents' + with connect() as conn: + conn.execute( + 'INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', + (owner_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps([action]), now), + ) + return {'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(hashes), 'actions': [action], 'skipped': True} + + def check(profile: dict | None = None, user_id: int | None = None, force: bool = False, rule_id: int | None = None) -> dict[str, Any]: - profile = profile or active_profile() - if not profile: return {'ok': False, 'error': 'No active rTorrent profile'} - user_id = _resolve_user_id(profile, user_id); profile_id = int(profile['id']) - rules = [r for r in list_rules(profile_id, user_id) if (rule_id is None or int(r.get('id') or 0) == int(rule_id)) and (force or int(r.get('enabled') or 0))] - if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0} - torrents = rtorrent.list_torrents(profile); applied = []; batches = []; now = utcnow() + profile = profile or active_profile(user_id=user_id) + if not profile: + return {'ok': False, 'error': 'No active rTorrent profile'} + profile_id = int(profile['id']) + if rule_id is not None: + _require_profile_read(profile_id, user_id) + rules = _list_enabled_rules_for_profile(profile_id, rule_id=rule_id, force=force) + if not rules: + return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0} + torrents = rtorrent.list_torrents(profile) + applied = [] + batches = [] + now = utcnow() planned: list[dict[str, Any]] = [] with connect() as conn: for rule in rules: - # Note: This pass only matches rules and updates condition timers; job creation is intentionally delayed until after this DB transaction commits. if not force and not _cooldown_ok(conn, rule, profile_id): continue matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)] @@ -372,26 +479,28 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = rule = item['rule'] matched = item['matched'] hashes = item['hashes'] - # Note: Automation jobs are enqueued outside the rule-state transaction, preventing SQLite self-locks when enqueue() writes to jobs. + owner_id = int(rule.get('user_id') or rule.get('owner_user_id') or default_user_id()) + if not auth.can_write_profile(profile_id, owner_id): + batch = _record_skipped_rule(profile_id, rule, hashes, 'Rule owner no longer has write access to profile', now) + batches.append(batch) + continue try: - actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, user_id) + actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, owner_id) except Exception as exc: actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}] changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])}) if not actions or not changed_hashes: - # Note: Matching torrents with no real action are not logged and do not restart the cooldown. continue history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions] matched_by_hash = {str(t.get('hash') or ''): t for t in matched} with connect() as conn: - # Note: State/history writes happen after enqueue succeeds, so failed job creation does not create misleading automation history. for h in changed_hashes: t = matched_by_hash.get(h, {}) conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_matched_at,last_applied_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_matched_at=excluded.last_matched_at, last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now, now)) - applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]}) + applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'owner_user_id': owner_id, 'owner_label': rule.get('owner_label'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]}) _mark_rule_cooldown(conn, rule, profile_id, now) torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents' torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}' - conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (user_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now)) - batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(changed_hashes), 'actions': history_actions}) + conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (owner_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now)) + batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'owner_user_id': owner_id, 'owner_label': rule.get('owner_label'), 'count': len(changed_hashes), 'actions': history_actions}) return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches} diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index 7ccee02..e68f461 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -58,8 +58,9 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: except Exception as exc: _emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id) try: - auto_result = automation_rules.check(profile, user_id=profile_user_id, force=False) - if auto_result.get("applied"): + # Note: Automations are profile-scoped; each queued job still runs as the rule owner. + auto_result = automation_rules.check(profile, force=False) + if auto_result.get("applied") or auto_result.get("batches"): _emit_profile(socketio, "automation_update", auto_result, profile_id) except Exception as exc: _emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id) diff --git a/pytorrent/static/js/automationRules.js b/pytorrent/static/js/automationRules.js index 498e8f7..0f50eaa 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 return `
${esc(r.name)} ${enabled?'on':'off'}
${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==='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";