From 98f155b53a673b090020302d19e8c9a9075082df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 6 May 2026 23:07:19 +0200 Subject: [PATCH] labels and automatizations --- pytorrent/config.py | 1 + pytorrent/routes/api.py | 43 +++++- pytorrent/services/automation_rules.py | 107 +++++++------- pytorrent/services/retention.py | 3 +- pytorrent/services/rtorrent.py | 124 +++++++++------- pytorrent/services/smart_queue.py | 88 +++++++---- pytorrent/static/app.js | 152 +++---------------- pytorrent/static/styles.css | 195 ++++++++++++++----------- pytorrent/templates/index.html | 2 +- 9 files changed, 365 insertions(+), 350 deletions(-) diff --git a/pytorrent/config.py b/pytorrent/config.py index 751db11..8458539 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -69,5 +69,6 @@ SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() fo TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1) JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1) +AUTOMATION_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_AUTOMATION_HISTORY_RETENTION_DAYS", 30, 1) LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 30, 1) SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Paused") diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 51add59..59fc6fb 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -326,9 +326,11 @@ def cleanup_summary() -> dict: "jobs_total": _table_count("jobs"), "jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"), "smart_queue_history_total": _table_count("smart_queue_history"), + "automation_history_total": _table_count("automation_history"), "retention_days": { "jobs": JOBS_RETENTION_DAYS, "smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS, + "automation_history": AUTOMATION_HISTORY_RETENTION_DAYS, }, "database": _db_size(), } @@ -731,6 +733,16 @@ def cleanup_smart_queue(): return ok({"deleted": deleted, "cleanup": cleanup_summary()}) +@bp.post("/cleanup/automations") +def cleanup_automations(): + from ..services import automation_rules + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + deleted = automation_rules.clear_history(profile["id"]) + return ok({"deleted": deleted, "cleanup": cleanup_summary()}) + + @bp.post("/cleanup/all") def cleanup_all(): deleted_jobs = clear_jobs() @@ -741,7 +753,10 @@ def cleanup_all(): else: cur = conn.execute("DELETE FROM smart_queue_history") deleted_smart = int(cur.rowcount or 0) - return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart}, "cleanup": cleanup_summary()}) + from ..services import automation_rules + profile = preferences.active_profile() + deleted_automation = automation_rules.clear_history(profile["id"]) if profile else 0 + return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "automation_history": deleted_automation}, "cleanup": cleanup_summary()}) @bp.post("/jobs//cancel") @@ -1052,6 +1067,32 @@ def automations_delete(rule_id: int): return jsonify({'ok': False, 'error': str(exc)}), 400 +@bp.delete('/automations/history/') +def automations_history_delete(history_id: int): + from ..services import automation_rules + profile = preferences.active_profile() + if not profile: + return jsonify({'ok': False, 'error': 'No profile'}), 400 + try: + deleted = automation_rules.delete_history_item(history_id, profile['id']) + return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id'])}) + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 400 + + +@bp.delete('/automations/history') +def automations_history_clear(): + from ..services import automation_rules + profile = preferences.active_profile() + if not profile: + return jsonify({'ok': False, 'error': 'No profile'}), 400 + try: + deleted = automation_rules.clear_history(profile['id']) + return ok({'deleted': deleted, 'history': []}) + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 400 + + @bp.post('/automations/check') def automations_check(): from ..services import automation_rules diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index ce73eaf..fdc45a4 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -88,10 +88,26 @@ def delete_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> No 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]]: +def list_history(profile_id: int, user_id: int | None = None, limit: int = 50) -> list[dict[str, Any]]: user_id = user_id or default_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 * 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 50), 200)))).fetchall() + + +def delete_history_item(history_id: int, profile_id: int, user_id: int | None = None) -> int: + # Note: Allow removing a single automation history card from the UI without touching rules. + user_id = user_id or default_user_id() + with connect() as conn: + cur = conn.execute('DELETE FROM automation_history WHERE id=? AND user_id=? AND profile_id=?', (int(history_id), user_id, profile_id)) + return int(cur.rowcount or 0) + + +def clear_history(profile_id: int, user_id: int | None = None) -> int: + # Note: History cleanup is separate from deleting automation rules. + user_id = user_id or default_user_id() + with connect() as conn: + cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id)) + return int(cur.rowcount or 0) def _condition_true(t: dict[str, Any], cond: dict[str, Any]) -> bool: @@ -113,8 +129,8 @@ 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 + # Note: Conditions can now be negated in automation rules. Timed no-seeds keeps its old delayed behavior only for the positive condition, so old rules do not change. 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: @@ -128,7 +144,8 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, 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, torrent_hash: str = '*') -> bool: + # Note: Automation cooldown is rule-wide for batch execution; '*' stores the last run for the whole rule. 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() @@ -136,12 +153,12 @@ def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60 -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 _touch_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None: + 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, '*', now, now, now)) -def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: +def _apply_effects_batch(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: + # Note: Rules now execute actions once for all matching torrents instead of calling move/check/start one item at a time. hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')] labels_by_hash = {str(t.get('hash') or ''): _label_names(t.get('label')) for t in torrents} applied: list[dict[str, Any]] = [] @@ -150,37 +167,36 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str typ = str(eff.get('type') or '') if typ == 'move': path = str(eff.get('path') or '').strip() or rtorrent.default_download_path(profile) - payload = { - 'path': path, - 'move_data': bool(eff.get('move_data')), - 'recheck': bool(eff.get('recheck', eff.get('move_data'))), - 'keep_seeding': bool(eff.get('keep_seeding')), - } - result = rtorrent.action(profile, hashes, 'move', payload) - applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'result': result}) + move_payload = {'path': path, 'move_data': bool(eff.get('move_data')), 'recheck': bool(eff.get('recheck', eff.get('move_data'))), 'keep_seeding': bool(eff.get('keep_seeding'))} + result = rtorrent.move_torrents(profile, hashes, move_payload) if path else None + if path: applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'move_data': bool(eff.get('move_data')), 'recheck': bool(move_payload['recheck']), 'keep_seeding': bool(eff.get('keep_seeding')), 'result': result}) elif typ == 'add_label': label = str(eff.get('label') or '').strip() + changed = 0 if label: for h in hashes: - labels = labels_by_hash.setdefault(h, []) + labels = labels_by_hash.get(h, []) if label not in labels: - labels.append(label); c.call('d.custom1.set', h, _label_value(labels)) - applied.append({'type': 'add_label', 'label': label, 'count': len(hashes)}) + labels.append(label); labels_by_hash[h] = labels; c.call('d.custom1.set', h, _label_value(labels)); changed += 1 + applied.append({'type': 'add_label', 'label': label, 'count': changed}) elif typ == 'remove_label': - label = str(eff.get('label') or '').strip() + label = str(eff.get('label') or '').strip(); changed = 0 if label: for h in hashes: - labels = [x for x in labels_by_hash.get(h, []) if x != label] - labels_by_hash[h] = labels; c.call('d.custom1.set', h, _label_value(labels)) - applied.append({'type': 'remove_label', 'label': label, 'count': len(hashes)}) + labels = labels_by_hash.get(h, []) + new_labels = [x for x in labels if x != label] + if new_labels != labels: + labels_by_hash[h] = new_labels; c.call('d.custom1.set', h, _label_value(new_labels)); changed += 1 + applied.append({'type': 'remove_label', 'label': label, 'count': changed}) elif typ == 'set_labels': - value = _label_value(_label_names(eff.get('labels'))) + value = _label_value(_label_names(eff.get('labels'))); new_labels = _label_names(value) for h in hashes: - labels_by_hash[h] = _label_names(value); c.call('d.custom1.set', h, value) + labels_by_hash[h] = list(new_labels); c.call('d.custom1.set', h, value) applied.append({'type': 'set_labels', 'labels': value, 'count': len(hashes)}) elif typ in {'pause', 'stop', 'start', 'resume', 'recheck'}: - result = rtorrent.action(profile, hashes, typ, {}) - applied.append({'type': typ, 'count': len(hashes), 'result': result}) + method = {'pause':'d.pause','stop':'d.stop','start':'d.start','resume':'d.resume','recheck':'d.check_hash'}[typ] + for h in hashes: c.call(method, h) + applied.append({'type': typ, 'count': len(hashes)}) return applied @@ -189,30 +205,23 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = if not profile: return {'ok': False, 'error': 'No active rTorrent profile'} user_id = user_id or default_user_id(); profile_id = int(profile['id']) rules = [r for r in list_rules(profile_id, user_id) if 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); c = rtorrent.client_for(profile); applied = []; batches = []; now = utcnow() + if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'rules': 0} + torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; now = utcnow() with connect() as conn: for rule in rules: - # Note: Automations now execute as one batch per rule, not as one independent action per torrent. - 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)] - if not matched: - continue + if not matched: continue + if not force and not _cooldown_ok(conn, rule, profile_id, '*'): continue hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')] - if not hashes: - continue - try: - actions = _apply_effects_bulk(c, profile, matched, rule.get('effects') or []) - except Exception as exc: - actions = [{'error': str(exc), 'count': len(hashes)}] - for t in matched: - h = str(t.get('hash') or '') + names = [str(t.get('name') or '') for t in matched] + try: actions = _apply_effects_batch(c, profile, matched, rule.get('effects') or []) + except Exception as exc: actions = [{'error': str(exc), 'count': len(hashes)}] + _touch_rule_cooldown(conn, rule, profile_id, now) + for h in hashes: 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(hashes))} for a in actions]}) - _mark_rule_cooldown(conn, rule, profile_id, now) - torrent_name = str(matched[0].get('name') or '') if len(matched) == 1 else f'{len(matched)} torrents' - torrent_hash = hashes[0] if len(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(actions), now)) - batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(hashes), 'actions': actions}) - return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches} + history_payload = {'mode': 'batch', 'count': len(hashes), 'hashes': hashes, 'names': names[:50], 'actions': actions} + torrent_name = names[0] if len(names) == 1 else f'{len(hashes)} torrents' + torrent_hash = hashes[0] if len(hashes) == 1 else ','.join(hashes[:20]) + 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_payload), now)) + applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(hashes), 'hashes': hashes, 'names': names[:20], 'actions': actions}) + return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied} diff --git a/pytorrent/services/retention.py b/pytorrent/services/retention.py index d7a03a7..4c2bdbf 100644 --- a/pytorrent/services/retention.py +++ b/pytorrent/services/retention.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS +from ..config import AUTOMATION_HISTORY_RETENTION_DAYS, JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS from ..db import connect _LAST_CLEANUP = 0.0 @@ -30,6 +30,7 @@ def cleanup(force: bool = False) -> dict[str, int]: targets = { "traffic_history": ("created_at", TRAFFIC_HISTORY_RETENTION_DAYS), "smart_queue_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS), + "automation_history": ("created_at", AUTOMATION_HISTORY_RETENTION_DAYS), "jobs": ("updated_at", JOBS_RETENTION_DAYS), "logs": ("created_at", LOG_RETENTION_DAYS), } diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index b853464..68632f8 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -1274,6 +1274,73 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: result['ok'] = result.get('ok', True) return result + +def move_torrents(profile: dict, torrent_hashes: list[str], payload: dict | None = None) -> dict: + # Note: Shared move implementation keeps API move and automation move-to-path identical. + payload = payload or {} + c = client_for(profile) + path = _remote_clean_path(payload.get("path") or "") + move_data = bool(payload.get("move_data")) + recheck = bool(payload.get("recheck", move_data)) + keep_seeding = bool(payload.get("keep_seeding")) + # Note: keep_seeding lets automation move completed data to another path and force the torrent back into seeding. + if not path: + raise ValueError("Missing path") + results = [] + if move_data: + _rt_execute_allow_timeout(c, "execute.throw", "mkdir", "-p", path) + for h in torrent_hashes: + item = {"hash": h, "path": path, "move_data": move_data, "keep_seeding": keep_seeding} + try: + was_state = int(c.call("d.state", h) or 0) + except Exception: + was_state = 0 + try: + was_active = int(c.call("d.is_active", h) or 0) + except Exception: + was_active = was_state + if move_data: + src = _remote_clean_path(_torrent_data_path(c, h)) + if not src: + raise ValueError(f"Cannot determine source path for {h}") + dst = _remote_join(path, posixpath.basename(src.rstrip("/"))) + if src != dst: + try: + c.call("d.stop", h) + except Exception: + pass + try: + c.call("d.close", h) + except Exception: + pass + _run_remote_move(c, src, dst) + item["moved_from"] = src + item["moved_to"] = dst + else: + item["skipped"] = "source and destination are the same" + c.call("d.directory.set", h, path) + if recheck: + try: + c.call("d.check_hash", h) + except Exception as exc: + item["recheck_error"] = str(exc) + if keep_seeding or was_state or was_active: + try: + c.call("d.start", h) + item["started_after_move"] = True + except Exception as exc: + item["start_error"] = str(exc) + else: + c.call("d.directory.set", h, path) + if keep_seeding: + try: + c.call("d.start", h) + item["started_after_path_change"] = True + except Exception as exc: + item["start_error"] = str(exc) + results.append(item) + return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "results": results} + def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None) -> dict: payload = payload or {} c = client_for(profile) @@ -1294,61 +1361,8 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | c.call("d.custom.set", h, "py_ratio_group", group) return {"ok": True, "count": len(torrent_hashes), "ratio_group": group} if name == "move": - path = _remote_clean_path(payload.get("path") or "") - move_data = bool(payload.get("move_data")) - recheck = bool(payload.get("recheck", move_data)) - keep_seeding = bool(payload.get("keep_seeding")) - # Note: Automations can force seeding after a physical move even if the torrent was not active before. - if not path: - raise ValueError("Missing path") - results = [] - if move_data: - _rt_execute_allow_timeout(c, "execute.throw", "mkdir", "-p", path) - for h in torrent_hashes: - item = {"hash": h, "path": path, "move_data": move_data, "keep_seeding": keep_seeding} - try: - was_state = int(c.call("d.state", h) or 0) - except Exception: - was_state = 0 - try: - was_active = int(c.call("d.is_active", h) or 0) - except Exception: - was_active = was_state - if move_data: - src = _remote_clean_path(_torrent_data_path(c, h)) - if not src: - raise ValueError(f"Cannot determine source path for {h}") - dst = _remote_join(path, posixpath.basename(src.rstrip("/"))) - if src != dst: - try: - c.call("d.stop", h) - except Exception: - pass - try: - c.call("d.close", h) - except Exception: - pass - _run_remote_move(c, src, dst) - item["moved_from"] = src - item["moved_to"] = dst - else: - item["skipped"] = "source and destination are the same" - c.call("d.directory.set", h, path) - if recheck: - try: - c.call("d.check_hash", h) - except Exception as exc: - item["recheck_error"] = str(exc) - if keep_seeding or was_state or was_active: - try: - c.call("d.start", h) - item["started_after_move"] = True - except Exception as exc: - item["start_after_move_error"] = str(exc) - else: - c.call("d.directory.set", h, path) - results.append(item) - return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "results": results} + # Note: Main move delegates to the shared helper used by automations. + return move_torrents(profile, torrent_hashes, payload) if name == "pause": # Note: The app pause action is now a pure d.pause so later resume works without stop/start. results = [pause_hash(c, h) for h in torrent_hashes] diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index a8d64e8..1779ee3 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -132,6 +132,39 @@ def _excluded_hashes(profile_id: int, user_id: int) -> set[str]: return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)} +def _label_names(value: str | None) -> list[str]: + names: list[str] = [] + for part in str(value or '').replace(';', ',').replace('|', ',').split(','): + label = part.strip() + if label and label not in names: + names.append(label) + return names + + +def _label_value(labels: list[str]) -> str: + out: list[str] = [] + for label in labels: + label = str(label or '').strip() + if label and label not in out: + out.append(label) + return ', '.join(out) + + +def _has_smart_queue_label(value: str | None) -> bool: + return SMART_QUEUE_LABEL in _label_names(value) + + +def _without_smart_queue_label(value: str | None) -> str: + return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_LABEL]) + + +def _with_smart_queue_label(value: str | None) -> str: + labels = _label_names(value) + if SMART_QUEUE_LABEL not in labels: + labels.append(SMART_QUEUE_LABEL) + return _label_value(labels) + + def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: now = utcnow() with connect() as conn: @@ -165,21 +198,18 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current (profile_id, torrent_hash), ).fetchone() live_label = _read_label(client, torrent_hash, current_label or '') - if not row: - if live_label != SMART_QUEUE_LABEL: - return False - try: - # Note: Clear the Smart Queue label even when the torrent was marked earlier but no previous-label entry remains. - client.call('d.custom1.set', torrent_hash, '') - return True - except Exception: - return False - previous = row.get('previous_label') or '' + if not row and not _has_smart_queue_label(live_label): + return False + restored = _without_smart_queue_label(live_label) + previous = _without_smart_queue_label((row or {}).get('previous_label') or '') + if not restored and previous: + restored = previous try: - # Note: On resume, Smart Queue restores the previous label only while it still sees its own technical label. - if live_label == SMART_QUEUE_LABEL or current_label is None: - client.call('d.custom1.set', torrent_hash, previous) - conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) + # Note: Smart Queue now removes only its technical label, preserving labels added manually while the torrent was waiting in the queue. + if _has_smart_queue_label(live_label) or current_label is None: + client.call('d.custom1.set', torrent_hash, restored) + if row: + conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) return True except Exception: return False @@ -282,10 +312,12 @@ def _read_live_start_state(client: Any, torrent_hash: str) -> dict[str, Any]: result['started'] = bool(int(result.get('active') or 0)) return result -def _set_smart_queue_label(client: Any, torrent_hash: str, attempts: int = 3) -> bool: +def _set_smart_queue_label(client: Any, torrent_hash: str, current_label: str | None = None, attempts: int = 3) -> bool: + # Note: The queue label is appended as a technical label instead of replacing the user's labels. + target = _with_smart_queue_label(current_label if current_label is not None else _read_label(client, torrent_hash, '')) for attempt in range(max(1, attempts)): try: - client.call('d.custom1.set', torrent_hash, SMART_QUEUE_LABEL) + client.call('d.custom1.set', torrent_hash, target) return True except Exception: if attempt < attempts - 1: @@ -298,15 +330,15 @@ def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) -> if not torrent_hash: return False previous = str(torrent.get('label') or '') - if previous != SMART_QUEUE_LABEL: - _remember_auto_label(profile_id, torrent_hash, previous) - return _set_smart_queue_label(client, torrent_hash) + if not _has_smart_queue_label(previous): + _remember_auto_label(profile_id, torrent_hash, _without_smart_queue_label(previous)) + return _set_smart_queue_label(client, torrent_hash, previous) def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = True) -> bool: if not torrent or int(torrent.get('complete') or 0): return False - if str(torrent.get('label') or '') == SMART_QUEUE_LABEL: + if _has_smart_queue_label(torrent.get('label')): return True # Note: Paused in rTorrent usually has state=1 and active=0, so state=0 must not be required. # This lets Smart Queue treat paused torrents as pending and fill the queue target later. @@ -319,11 +351,11 @@ def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = def _clear_untracked_smart_queue_label(client: Any, torrent_hash: str, current_label: str) -> bool: - if current_label != SMART_QUEUE_LABEL: + if not _has_smart_queue_label(current_label): return False try: - # Note: Clear an orphaned Smart Queue label when no previous-label entry exists in the database. - client.call('d.custom1.set', torrent_hash, '') + # Note: Orphan cleanup removes only the Smart Queue technical label and keeps manual labels intact. + client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(current_label)) return True except Exception: return False @@ -346,7 +378,7 @@ def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str, if _restore_auto_label(client, profile_id, h, None if t is None else current_label): restored.append(h) continue - if current_label != SMART_QUEUE_LABEL: + if not _has_smart_queue_label(current_label): _set_smart_queue_label(client, h) for h, t in by_hash.items(): @@ -363,7 +395,7 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool: # Paused can have state=1/open=1, so a slot is counted only after d.is_active=1. if int(t.get('complete') or 0): return False - if str(t.get('label') or '') == SMART_QUEUE_LABEL: + if _has_smart_queue_label(t.get('label')): return False status = str(t.get('status') or '').lower() if status == 'checking' or status == 'paused' or bool(t.get('paused')): @@ -375,7 +407,7 @@ def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> b """Return True for paused/held torrents Smart Queue may resume later.""" if int(t.get('complete') or 0): return False - if str(t.get('label') or '') == SMART_QUEUE_LABEL: + if _has_smart_queue_label(t.get('label')): return True # Note: Paused items are the primary source for filling the queue, regardless of manage_stopped. if bool(t.get('paused')) or str(t.get('status') or '').lower() == 'paused': @@ -406,7 +438,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = excluded = _excluded_hashes(profile_id, user_id) manage_stopped = bool(settings.get('manage_stopped')) def is_managed_hold(t: dict[str, Any]) -> bool: - return str(t.get('label') or '') == SMART_QUEUE_LABEL + return _has_smart_queue_label(t.get('label')) # Note: Count Smart Queue slots by d.is_active because Paused can have state=1/open=1 and must not occupy the limit. downloading = [ @@ -538,7 +570,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = keep_labels = ( set(paused) | {str(t.get('hash') or '') for t in to_label_waiting} - | {str(t.get('hash') or '') for t in stopped if str(t.get('label') or '') == SMART_QUEUE_LABEL and str(t.get('hash') or '') not in set(resumed)} + | {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(t.get('label')) and str(t.get('hash') or '') not in set(resumed)} ) restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, manage_stopped) details = {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'resume_requested': resume_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': manage_stopped, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_pause + len(resumed), 'paused_planned': len(to_pause), 'resumed_planned': len(to_resume), 'rtorrent_cap': rtorrent_cap} diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index e0ff916..bf3f443 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -525,133 +525,24 @@ function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia("(max-width: 900px)").matches; document.body.classList.toggle("mobile-mode", auto || document.body.classList.contains("mobile-mode-manual")); scheduleRender(true); } - let automationRulesCache=[]; - let automationConditions=[]; - let automationEffects=[]; - - function automationCondition(){ - const type=$('autoConditionType')?.value||'completed'; - const cond={type, negate:!!$('autoCondNegate')?.checked}; - if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); } - if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1); - if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||''; - if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding'; - if(type==='path_contains') cond.text=$('autoCondText')?.value||''; - return cond; - } - - function automationEffect(){ - const type=$('autoEffectType')?.value||'add_label'; - const eff={type}; - if(type==='move'){ - eff.path=$('autoEffectPath')?.value||''; - eff.move_data=!!$('autoMoveData')?.checked; - eff.recheck=!!$('autoMoveRecheck')?.checked; - eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked; - } - if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||''; - if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||''; - return eff; - } - - function updateAutomationForm(){ - const ct=$('autoConditionType')?.value||''; - document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct))); - const et=$('autoEffectType')?.value||''; - document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et))); - } - - function conditionText(c={}){ - const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`: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'; - return c.negate?`NOT (${base})`:base; - } - function effectText(e={}){ - if(e.type==='move'){ - const flags=[]; - if(e.move_data) flags.push('move data'); - if(e.recheck) flags.push('recheck'); - if(e.keep_seeding) flags.push('keep seeding'); - return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`; - } - 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; - } - function ruleSummary(r){ - const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions'; - const es=(r.effects||[]).map(effectText).join(' → ')||'no actions'; - return `${cs} → ${es}`; - } - - function renderAutomationBuilder(){ - const cBox=$('automationConditionList'); - if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.'; - const eBox=$('automationEffectList'); - if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.'; - } - function resetAutomationForm(){ - if($('autoEditId')) $('autoEditId').value=''; - if($('autoName')) $('autoName').value=''; - if($('autoEnabled')) $('autoEnabled').checked=true; - if($('autoCooldown')) $('autoCooldown').value='60'; - automationConditions=[]; automationEffects=[]; - $('automationCancelEditBtn')?.classList.add('d-none'); - if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule'; - renderAutomationBuilder(); updateAutomationForm(); - } - function editAutomationRule(rule){ - if(!rule) return; - if($('autoEditId')) $('autoEditId').value=rule.id||''; - if($('autoName')) $('autoName').value=rule.name||''; - if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled; - if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60; - automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[]; - automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[]; - $('automationCancelEditBtn')?.classList.remove('d-none'); - if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule'; - renderAutomationBuilder(); - } - - function summarizeActionObject(a={}){ - if(a.error) return `${esc(a.error)}`; - const count=a.count || a.result?.count || a.result?.results?.length || ''; - const parts=[]; - if(a.type) parts.push(a.type); - if(count) parts.push(`${count} torrent(s)`); - if(a.path) parts.push(a.path); - if(a.label) parts.push(`label ${a.label}`); - if(a.labels) parts.push(`labels ${a.labels}`); - if(a.move_data) parts.push('move data'); - if(a.recheck) parts.push('recheck'); - if(a.keep_seeding) parts.push('keep seeding'); - return `${esc(parts.join(' · ')||'action')}`; - } - function automationHistoryActions(raw){ - let actions=[]; - try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; } - if(!Array.isArray(actions)) actions=[actions]; - const summary=actions.map(summarizeActionObject).join(' '); - const details=esc(JSON.stringify(actions,null,2)); - return `
${summary||'No actions'}
${details}
`; - } - - async function loadAutomations(){ - const j=await (await fetch('/api/automations')).json(); - const rules=j.rules||[], hist=j.history||[]; - automationRulesCache=rules; - if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>`
${esc(r.name)} ${r.enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`).join(''):'
No automation rules.
'; - if($('automationHistory')) $('automationHistory').innerHTML=hist.length?table(['Time','Rule','Torrent / batch','Actions'],hist.map(h=>[esc(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')])):'
No automation history yet.
'; - } - - async function saveAutomation(){ - const currentCond=automationCondition(); - const currentEff=automationEffect(); - const conditions=automationConditions.length?automationConditions:[currentCond]; - const effects=automationEffects.length?automationEffects:[currentEff]; - const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects}; - setBusy(true); - try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); } - catch(e){toast(e.message,'danger');} - finally{setBusy(false);} - } + // Note: Builder queues allow many conditions and many ordered actions in one automation without changing old single-step saves. + let automationConditionQueue=[]; + let automationEffectQueue=[]; + function automationCondition(){ const type=$('autoConditionType')?.value||'completed'; const cond={type}; if($('autoCondNegate')?.checked) cond.negate=true; if(type==='no_seeds'){cond.seeds=Number($('autoCondSeeds')?.value||0);cond.minutes=Number($('autoCondMinutes')?.value||0);} if(type==='ratio_gte')cond.ratio=Number($('autoCondRatio')?.value||1); if(type==='label_missing'||type==='label_has')cond.label=$('autoCondLabel')?.value||''; if(type==='status')cond.status=$('autoCondStatus')?.value||'Seeding'; if(type==='path_contains')cond.text=$('autoCondText')?.value||''; return cond; } + function automationEffect(){ const type=$('autoEffectType')?.value||'add_label'; const eff={type}; if(type==='move'){eff.path=$('autoEffectPath')?.value||''; eff.move_data=!!($('autoMoveDataPhysical')?.checked); eff.recheck=!!($('autoMoveRecheck')?.checked); eff.keep_seeding=!!($('autoMoveKeepSeeding')?.checked);} if(type==='add_label'||type==='remove_label')eff.label=$('autoEffectLabel')?.value||''; if(type==='set_labels')eff.labels=$('autoEffectLabels')?.value||''; return eff; } + function updateAutomationForm(){ const ct=$('autoConditionType')?.value||''; document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct))); const et=$('autoEffectType')?.value||''; document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et))); } + function conditionSummary(c){ const base=c.type==='no_seeds'?`no seeds <=${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`: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'; return c.negate?`NOT (${base})`:base; } + function effectSummary(e){ return e.type==='move'?`move to ${e.path||'default path'}${e.move_data?' + data move':''}${e.keep_seeding?' + keep seeding':''}${e.recheck?' + recheck':''}`: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; } + function renderAutomationBuilder(){ const cbox=$('autoConditionsList'), ebox=$('autoEffectsList'); if(cbox) cbox.innerHTML=automationConditionQueue.length?automationConditionQueue.map((c,i)=>`
${esc(i+1)}. ${esc(conditionSummary(c))}
`).join(''):'
No extra conditions added; current condition will be used on save.
'; if(ebox) ebox.innerHTML=automationEffectQueue.length?automationEffectQueue.map((e,i)=>`
${esc(i+1)}. ${esc(effectSummary(e))}
`).join(''):'
No action sequence added; current action will be used on save.
'; } + function addAutomationCondition(){ automationConditionQueue.push(automationCondition()); renderAutomationBuilder(); } + function addAutomationEffect(){ automationEffectQueue.push(automationEffect()); renderAutomationBuilder(); } + function ruleSummary(r){ const cs=(r.conditions||[]).map(conditionSummary).join(' + '); const es=(r.effects||[]).map(effectSummary).join(' → '); return `${cs} → ${es}`; } + function parseAutomationActions(raw){ try{ const value=JSON.parse(raw||'{}'); if(Array.isArray(value)) return {actions:value,count:1,names:[]}; if(value && typeof value==='object') return {actions:value.actions||[],count:value.count||1,names:value.names||[],hashes:value.hashes||[]}; }catch(e){} return {actions:[],count:1,names:[],raw:raw||''}; } + function automationActionBadge(a){ if(a.error) return `error`; const count=a.count||a.result?.count||''; const tail=count?` · ${count}`:''; if(a.type==='move') return `move${tail}`; if(a.type==='remove_label') return `remove label${tail}`; if(a.type==='add_label') return `add label${tail}`; if(a.type==='recheck') return `recheck${tail}`; if(a.type==='start'||a.type==='resume') return `${esc(a.type)}${tail}`; return `${esc(a.type||'action')}${tail}`; } + function automationActionDetail(a){ if(a.error) return `
${esc(a.error)}
`; if(a.type==='move'){ const result=a.result||{}; return `
Path: ${esc(a.path||'')}
Files moved: ${a.move_data?'yes':'no'} · recheck: ${a.recheck?'yes':'no'} · seed: ${a.keep_seeding?'yes':'no'}
${result.ok!==undefined?`
Result: ${result.ok?'ok':'failed'} · ${esc(result.count||a.count||0)} item(s)
`:''}`; } if(a.label) return `
Label: ${esc(a.label)}
Changed: ${esc(a.count||1)}
`; if(a.labels) return `
Labels: ${esc(a.labels)}
`; return `
Count: ${esc(a.count||1)}
`; } + function renderAutomationHistory(hist){ const box=$('automationHistory'); if(!box) return; if(!hist.length){ box.innerHTML='
No automation history yet.
'; return; } const items=hist.map((h,i)=>{ const parsed=parseAutomationActions(h.actions_json); const title=parsed.count>1?`${parsed.count} torrents`:h.torrent_name||h.torrent_hash||'torrent'; const names=(parsed.names||[]).slice(0,20); const actions=parsed.actions||[]; return `
${esc(title)}
${esc(h.created_at)} · ${esc(h.rule_name||'')}
${actions.length?actions.map(automationActionBadge).join(''):'details'}
${names.length?``:''}
${actions.map(a=>`
${automationActionBadge(a)}${automationActionDetail(a)}
`).join('')||esc(parsed.raw||'')}
`; }).join(''); box.innerHTML=`
`; } + async function loadAutomations(){ const j=await (await fetch('/api/automations')).json(); const rules=j.rules||[], hist=j.history||[]; if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>`
${esc(r.name)} ${r.enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`).join(''):'
No automation rules.
'; renderAutomationHistory(hist); renderAutomationBuilder(); } + async function saveAutomation(){ const conditions=automationConditionQueue.length?automationConditionQueue:[automationCondition()]; const effects=automationEffectQueue.length?automationEffectQueue:[automationEffect()]; const payload={name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects}; setBusy(true); try{ await post('/api/automations',payload); automationConditionQueue=[]; automationEffectQueue=[]; renderAutomationBuilder(); toast('Automation rule saved','success'); await loadAutomations(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } @@ -666,9 +557,10 @@ cleanupCountCard('Job logs total', data.jobs_total, `retention ${retention.jobs||'-'} days`), cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'), cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, `retention ${retention.smart_queue_history||'-'} days`), + cleanupCountCard('Automation logs', data.automation_history_total, `retention ${retention.automation_history||'-'} days`), cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'') ]; - box.innerHTML=`
${cards.join('')}
Job cleanup preserves pending and running jobs. Use Jobs modal for emergency clear when unfinished jobs must be removed.
`; + box.innerHTML=`
${cards.join('')}
Job cleanup preserves pending and running jobs. Automation cleanup removes history only, not rules.
`; } async function loadCleanup(){ const box=$('cleanupManager'); if(!box) return; @@ -803,8 +695,8 @@ }catch(e){ box.innerHTML=`
${esc(e.message)}
`; } } - $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();loadAuthUsers();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences(); if(tool==='users') loadAuthUsers();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(del && confirm('Delete user?')){ await fetch(`/api/auth/users/${del.dataset.id}`,{method:'DELETE'}); loadAuthUsers(); } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); const noEffect=r.start_no_effect?.length||0; const requested=r.resume_requested?.length||0; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}${tail}${waitTail}${cap}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');}); - $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toast(`Automations applied ${torrents} torrent(s) in ${batches} batch(es)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); + $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();loadAuthUsers();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences(); if(tool==='users') loadAuthUsers();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(del && confirm('Delete user?')){ await fetch(`/api/auth/users/${del.dataset.id}`,{method:'DELETE'}); loadAuthUsers(); } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); const noEffect=r.start_no_effect?.length||0; const requested=r.resume_requested?.length||0; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}${tail}${waitTail}${cap}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear all cleanup logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');}); + $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('autoAddConditionBtn')?.addEventListener('click',addAutomationCondition); $('autoAddEffectBtn')?.addEventListener('click',addAutomationEffect); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',async e=>{ const toggle=e.target.closest('.automation-history-toggle'); if(toggle){ const detail=document.querySelector(`[data-detail-index="${CSS.escape(String(toggle.dataset.index||''))}"]`); detail?.classList.toggle('d-none'); return; } const del=e.target.closest('.automation-history-delete'); if(del){ if(!confirm('Delete this automation log?')) return; const r=await fetch('/api/automations/history/'+del.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok)toast(j.error||'Delete failed','danger'); await loadAutomations(); return; } if(e.target.closest('#automationHistoryClearBtn')){ if(!confirm('Clear automation history?')) return; const r=await fetch('/api/automations/history',{method:'DELETE'}); const j=await r.json(); if(!j.ok)toast(j.error||'Clear failed','danger'); await loadAutomations(); } }); $('autoConditionsList')?.addEventListener('click',e=>{const btn=e.target.closest('.auto-remove-condition'); if(!btn)return; automationConditionQueue.splice(Number(btn.dataset.index||0),1); renderAutomationBuilder();}); $('autoEffectsList')?.addEventListener('click',e=>{const btn=e.target.closest('.auto-remove-effect'); if(!btn)return; automationEffectQueue.splice(Number(btn.dataset.index||0),1); renderAutomationBuilder();}); document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} }); $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);}); $('smartExcludeSelectedBtn')?.addEventListener('click',()=>setSmartException(selectedHashes(),true,'manual')); diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index b26ea9c..6676a59 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1238,60 +1238,37 @@ body.mobile-mode .mobile-card { color: var(--bs-primary-text-emphasis); } -.automation-shell { - display: grid; - gap: 0.75rem; -} -.automation-main-card { - padding: 0.75rem; - border: 1px solid var(--bs-border-color); - border-radius: 0.75rem; - background: var(--bs-body-bg); -} -.automation-card-title { - margin-bottom: 0.5rem; - font-weight: 700; -} -.automation-rule-grid, -.automation-builder-grid { +.automation-form-grid { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 0.5rem; align-items: center; } -.automation-enabled, -.automation-negate { - margin: 0; - padding: 0.45rem 0.6rem 0.45rem 2.5rem; - border: 1px solid var(--bs-border-color); - border-radius: 0.5rem; -} -.automation-path-input { - grid-column: span 2; -} -.automation-chip-list { - display: flex; - flex-wrap: wrap; + +.auto-move-option, +.auto-condition-option { gap: 0.45rem; + margin: 0; } -.automation-chip { - display: inline-flex; - align-items: center; - gap: 0.35rem; - max-width: 100%; - padding: 0.25rem 0.5rem; - border: 1px solid var(--bs-border-color); - border-radius: 999px; - background: var(--bs-tertiary-bg); - font-size: 0.82rem; -} -.automation-actions, -.automation-row-actions { - display: flex; - flex-wrap: wrap; + + +.automation-builder-list { + display: grid; + grid-column: 1 / -1; gap: 0.4rem; - align-items: center; } + +.automation-chip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.55rem; + background: var(--bs-secondary-bg); +} + .automation-row { display: flex; justify-content: space-between; @@ -1303,49 +1280,10 @@ body.mobile-mode .mobile-card { margin-bottom: 0.45rem; background: var(--bs-body-bg); } -.automation-row-main { - min-width: 0; -} -.automation-action-pill { - display: inline-flex; - max-width: 100%; - margin: 0.1rem; - padding: 0.15rem 0.4rem; - border-radius: 999px; - background: var(--bs-secondary-bg); - font-size: 0.78rem; - white-space: normal; -} -.automation-history-details { - max-width: min(620px, 60vw); -} -.automation-history-details summary { - cursor: pointer; - list-style-position: inside; -} -.automation-history-details pre, -.automation-history-raw { - max-width: 100%; - max-height: 220px; - margin: 0.35rem 0 0; - padding: 0.5rem; - overflow: auto; - border: 1px solid var(--bs-border-color); - border-radius: 0.5rem; - background: var(--bs-tertiary-bg); - white-space: pre-wrap; - word-break: break-word; -} @media (max-width: 900px) { - .automation-rule-grid, - .automation-builder-grid { + .automation-form-grid { grid-template-columns: 1fr; } - .automation-path-input, - .automation-history-details { - grid-column: auto; - max-width: 100%; - } } .disk-status { display: inline-flex; @@ -2233,3 +2171,90 @@ body.mobile-mode .mobile-filter-bar { width: 100%; } } + +/* Note: Automation history uses compact cards with horizontal scrolling so long torrent names never expand the modal. */ +.automation-history-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5rem; +} + +.automation-history-carousel { + display: grid; + gap: 0.6rem; + max-height: 430px; + overflow-y: auto; + padding-right: 0.25rem; +} + +.automation-history-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.6rem; + padding: 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: var(--bs-body-bg); +} + +.automation-history-main { + min-width: 0; +} + +.automation-history-title { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.automation-history-buttons, +.automation-action-strip { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.automation-history-buttons { + align-content: flex-start; + justify-content: flex-end; +} + +.automation-history-detail { + grid-column: 1 / -1; + min-width: 0; +} + +.automation-name-carousel { + display: flex; + gap: 0.4rem; + overflow-x: auto; + padding-bottom: 0.35rem; +} + +.automation-name-carousel span { + flex: 0 0 auto; + max-width: 260px; + overflow: hidden; + padding: 0.25rem 0.45rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: var(--bs-secondary-bg); + text-overflow: ellipsis; + white-space: nowrap; +} + +.automation-action-detail-list { + display: grid; + gap: 0.45rem; +} + +.automation-action-detail { + display: grid; + gap: 0.25rem; + min-width: 0; + padding: 0.5rem; + border-radius: 0.55rem; + background: var(--bs-tertiary-bg); + overflow-wrap: anywhere; +} diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index eb02036..b87f368 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -148,7 +148,7 @@ - +