diff --git a/pytorrent/db.py b/pytorrent/db.py index 690754c..30619b7 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -181,8 +181,7 @@ CREATE TABLE IF NOT EXISTS ratio_groups ( CREATE TABLE IF NOT EXISTS rss_feeds ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - profile_id INTEGER, + profile_id INTEGER NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER DEFAULT 1, @@ -196,8 +195,7 @@ CREATE TABLE IF NOT EXISTS rss_feeds ( CREATE TABLE IF NOT EXISTS rss_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - profile_id INTEGER, + profile_id INTEGER NOT NULL, name TEXT NOT NULL, pattern TEXT NOT NULL, exclude_pattern TEXT, @@ -214,13 +212,12 @@ CREATE TABLE IF NOT EXISTS rss_rules ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at); -CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled); +CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at); +CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled); CREATE TABLE IF NOT EXISTS rss_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - profile_id INTEGER, + profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, @@ -230,8 +227,7 @@ CREATE TABLE IF NOT EXISTS rss_history ( created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at); -CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at); -CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status); +CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status); CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added'); CREATE TABLE IF NOT EXISTS ratio_assignments ( @@ -275,7 +271,6 @@ CREATE TABLE IF NOT EXISTS app_backups ( ); CREATE TABLE IF NOT EXISTS smart_queue_settings ( - user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, @@ -296,7 +291,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings ( protect_active_below_cap INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, - PRIMARY KEY(user_id, profile_id) + PRIMARY KEY(profile_id) ); CREATE TABLE IF NOT EXISTS smart_queue_stalled ( @@ -317,19 +312,17 @@ CREATE TABLE IF NOT EXISTS smart_queue_start_grace ( ); CREATE TABLE IF NOT EXISTS smart_queue_exclusions ( - user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, - PRIMARY KEY(user_id, profile_id, torrent_hash) + PRIMARY KEY(profile_id, torrent_hash) ); -CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at); +CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at); CREATE TABLE IF NOT EXISTS smart_queue_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, event TEXT NOT NULL, paused_count INTEGER DEFAULT 0, @@ -340,7 +333,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_history ( ); CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at); -CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at); + CREATE TABLE IF NOT EXISTS smart_queue_auto_labels ( profile_id INTEGER NOT NULL, @@ -418,14 +411,13 @@ CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_ CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at); CREATE TABLE IF NOT EXISTS rtorrent_config_overrides ( - user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, baseline_value TEXT, apply_on_start INTEGER DEFAULT 0, updated_at TEXT NOT NULL, - PRIMARY KEY(user_id, profile_id, key) + PRIMARY KEY(profile_id, key) ); CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start); @@ -598,7 +590,7 @@ MIGRATIONS = [ "ALTER TABLE automation_history ADD COLUMN rule_name TEXT", "ALTER TABLE automation_history ADD COLUMN actions_json TEXT", "ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT", - "CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')", "CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))", @@ -611,15 +603,15 @@ MIGRATIONS = [ "CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at)", - "CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)", - "CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)", - "CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)", - "CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)", + "CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)", + "CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)", + "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", + "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status)", "CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id)", - "CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)", - "CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)", + "CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)", + "CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)", @@ -647,6 +639,81 @@ POST_MIGRATION_INDEXES = [ "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", ] + +PROFILE_ONLY_TABLES = { + "rss_feeds": { + "columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER DEFAULT 1, interval_minutes INTEGER DEFAULT 30, last_error TEXT, last_checked_at TEXT, next_check_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL", + "copy": ["id", "profile_id", "name", "url", "enabled", "interval_minutes", "last_error", "last_checked_at", "next_check_at", "created_at", "updated_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)"], + }, + "rss_rules": { + "columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, pattern TEXT NOT NULL, exclude_pattern TEXT, min_size_mb INTEGER DEFAULT 0, max_size_mb INTEGER DEFAULT 0, category TEXT, quality TEXT, season INTEGER, episode INTEGER, save_path TEXT, label TEXT, start INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL", + "copy": ["id", "profile_id", "name", "pattern", "exclude_pattern", "min_size_mb", "max_size_mb", "category", "quality", "season", "episode", "save_path", "label", "start", "enabled", "created_at", "updated_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)"], + }, + "rss_history": { + "columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL", + "copy": ["id", "profile_id", "feed_id", "rule_id", "title", "link", "status", "message", "created_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')"], + }, + "smart_queue_settings": { + "columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)", + "copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "auto_stop_idle", "updated_at"], + "indexes": [], + }, + "smart_queue_exclusions": { + "columns": "profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash)", + "copy": ["profile_id", "torrent_hash", "reason", "created_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)"], + }, + "smart_queue_history": { + "columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, event TEXT NOT NULL, paused_count INTEGER DEFAULT 0, resumed_count INTEGER DEFAULT 0, checked_count INTEGER DEFAULT 0, details_json TEXT, created_at TEXT NOT NULL", + "copy": ["id", "profile_id", "event", "paused_count", "resumed_count", "checked_count", "details_json", "created_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)"], + }, + "rtorrent_config_overrides": { + "columns": "profile_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, baseline_value TEXT, apply_on_start INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, key)", + "copy": ["profile_id", "key", "value", "baseline_value", "apply_on_start", "updated_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start)"], + }, +} + + +def _table_columns(conn, table: str) -> set[str]: + try: + return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + except sqlite3.OperationalError: + return set() + + +def _normalize_profile_only_tables(conn) -> None: + """Move operational settings from user scope to profile scope on existing databases.""" + for table, spec in PROFILE_ONLY_TABLES.items(): + columns = _table_columns(conn, table) + if not columns or "user_id" not in columns: + for index_sql in spec["indexes"]: + try: + conn.execute(index_sql) + except sqlite3.OperationalError: + pass + continue + tmp = f"{table}_profile_scope_tmp" + conn.execute("PRAGMA foreign_keys = OFF") + conn.execute(f"DROP TABLE IF EXISTS {tmp}") + conn.execute(f"CREATE TABLE {tmp} ({spec['columns']})") + copy_cols = [col for col in spec["copy"] if col in columns] + if copy_cols: + col_sql = ",".join(copy_cols) + if table in {"smart_queue_settings", "smart_queue_exclusions", "rtorrent_config_overrides"}: + conn.execute(f"INSERT OR REPLACE INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL") + else: + conn.execute(f"INSERT INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL") + conn.execute(f"DROP TABLE {table}") + conn.execute(f"ALTER TABLE {tmp} RENAME TO {table}") + for index_sql in spec["indexes"]: + conn.execute(index_sql) + conn.execute("PRAGMA foreign_keys = ON") + def utcnow() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") @@ -687,6 +754,7 @@ def init_db(): conn.execute(sql) except sqlite3.OperationalError: pass + _normalize_profile_only_tables(conn) now = utcnow() conn.execute( "INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)", diff --git a/pytorrent/routes/backup.py b/pytorrent/routes/backup.py index 92b7732..37113c4 100644 --- a/pytorrent/routes/backup.py +++ b/pytorrent/routes/backup.py @@ -13,11 +13,14 @@ def _active_profile_id() -> int | None: def backup_list(): uid = default_user_id() pid = _active_profile_id() + can_app = auth.is_admin() return ok({ "profile_backups": backup_service.list_backups(uid, "profile", pid) if pid else [], - "app_backups": backup_service.list_backups(uid, "app") if auth.is_admin() else [], - "auto": backup_service.get_auto_backup_settings(uid) if auth.is_admin() else None, - "can_app_backup": auth.is_admin(), + "app_backups": backup_service.list_backups(uid, "app") if can_app else [], + "profile_auto": backup_service.get_auto_backup_settings(uid, "profile", pid) if pid else None, + "app_auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None, + "auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None, + "can_app_backup": can_app, }) @@ -58,14 +61,34 @@ def backup_create(): def backup_settings_get(): if not auth.is_admin(): return jsonify({"ok": False, "error": "Application backup settings are admin-only"}), 403 - return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())}) + return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "app")}) @bp.post("/backup/settings") def backup_settings_save(): data = request.get_json(silent=True) or {} try: - return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())}) + return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "app")}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 + + +@bp.get("/backup/profile/settings") +def profile_backup_settings_get(): + pid = _active_profile_id() + if not pid: + return jsonify({"ok": False, "error": "No profile"}), 400 + return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "profile", pid)}) + + +@bp.post("/backup/profile/settings") +def profile_backup_settings_save(): + data = request.get_json(silent=True) or {} + pid = _active_profile_id() + if not pid: + return jsonify({"ok": False, "error": "No profile"}), 400 + try: + return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "profile", pid)}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 diff --git a/pytorrent/routes/rss.py b/pytorrent/routes/rss.py index 494077c..7bf984c 100644 --- a/pytorrent/routes/rss.py +++ b/pytorrent/routes/rss.py @@ -2,65 +2,109 @@ from __future__ import annotations from ._shared import * + +def _active_profile_or_400(): + profile = preferences.active_profile() + if not profile: + return None + return profile + + @bp.get("/rss") def rss_list(): - profile = preferences.active_profile() - pid = profile["id"] if profile else None + profile = _active_profile_or_400() + if not profile: + return ok({"feeds": [], "rules": [], "history": []}) + pid = int(profile["id"]) with connect() as conn: - feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() - rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() - history = conn.execute("SELECT * FROM rss_history WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY id DESC LIMIT 80", (default_user_id(), pid)).fetchall() + feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? ORDER BY name", (pid,)).fetchall() + rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? ORDER BY name", (pid,)).fetchall() + history = conn.execute("SELECT * FROM rss_history WHERE profile_id=? ORDER BY id DESC LIMIT 80", (pid,)).fetchall() return ok({"feeds": feeds, "rules": rules, "history": history}) - @bp.post("/rss/feeds") def rss_feed_save(): - profile = preferences.active_profile() + profile = _active_profile_or_400() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} now = utcnow() feed_id = data.get("id") + pid = int(profile["id"]) with connect() as conn: if feed_id: - conn.execute("UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND user_id=?", (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, default_user_id())) + conn.execute( + "UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND profile_id=?", + (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, pid), + ) else: - conn.execute("INSERT INTO rss_feeds(user_id,profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now)) + conn.execute( + "INSERT INTO rss_feeds(profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?)", + (pid, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now), + ) return rss_list() - @bp.delete("/rss/feeds/") def rss_feed_delete(feed_id: int): + profile = _active_profile_or_400() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 with connect() as conn: - conn.execute("DELETE FROM rss_feeds WHERE id=? AND user_id=?", (feed_id, default_user_id())) + conn.execute("DELETE FROM rss_feeds WHERE id=? AND profile_id=?", (feed_id, int(profile["id"]))) return rss_list() - @bp.post("/rss/rules") def rss_rule_save(): - profile = preferences.active_profile() + profile = _active_profile_or_400() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} now = utcnow() rule_id = data.get("id") - values = (data.get("name") or "Rule", data.get("pattern") or ".*", data.get("exclude_pattern") or "", int(data.get("min_size_mb") or 0), int(data.get("max_size_mb") or 0), data.get("category") or "", data.get("quality") or "", data.get("season") or None, data.get("episode") or None, data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1 if data.get("enabled", True) else 0, now) + pid = int(profile["id"]) + values = ( + data.get("name") or "Rule", + data.get("pattern") or ".*", + data.get("exclude_pattern") or "", + int(data.get("min_size_mb") or 0), + int(data.get("max_size_mb") or 0), + data.get("category") or "", + data.get("quality") or "", + data.get("season") or None, + data.get("episode") or None, + data.get("save_path") or active_default_download_path(profile), + data.get("label") or "", + 1 if data.get("start", True) else 0, + 1 if data.get("enabled", True) else 0, + now, + ) with connect() as conn: if rule_id: - conn.execute("UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND user_id=?", (*values, rule_id, default_user_id())) + conn.execute( + "UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND profile_id=?", + (*values, rule_id, pid), + ) else: - conn.execute("INSERT INTO rss_rules(user_id,profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, *values, now)) + conn.execute( + "INSERT INTO rss_rules(profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (pid, *values, now), + ) return rss_list() - @bp.delete("/rss/rules/") def rss_rule_delete(rule_id: int): + profile = _active_profile_or_400() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 with connect() as conn: - conn.execute("DELETE FROM rss_rules WHERE id=? AND user_id=?", (rule_id, default_user_id())) + conn.execute("DELETE FROM rss_rules WHERE id=? AND profile_id=?", (rule_id, int(profile["id"]))) return rss_list() - @bp.post("/rss/rules/test") def rss_rule_test(): data = request.get_json(silent=True) or {} @@ -71,12 +115,9 @@ def rss_rule_test(): return jsonify({"ok": False, "error": str(exc)}), 400 - @bp.post("/rss/check") def rss_check(): profile = preferences.active_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 - return ok(rss_service.check(profile, default_user_id(), only_due=False)) - - + return ok(rss_service.check(profile, only_due=False)) diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index 385c799..0bffc50 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -15,7 +15,7 @@ APP_BACKUP_TABLES = [ "rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings", ] -# Note: Profile backups contain only the active profile context and current user's profile-scoped preferences. +# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user. PROFILE_BACKUP_TABLES = [ "rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions", @@ -28,12 +28,12 @@ PROFILE_TABLE_FILTERS = { "disk_monitor_preferences": "user_id=? AND profile_id=?", "labels": "user_id=? AND profile_id=?", "ratio_groups": "user_id=? AND profile_id=?", - "rss_feeds": "user_id=? AND profile_id=?", - "rss_rules": "user_id=? AND profile_id=?", - "smart_queue_settings": "user_id=? AND profile_id=?", - "smart_queue_exclusions": "user_id=? AND profile_id=?", + "rss_feeds": "profile_id=?", + "rss_rules": "profile_id=?", + "smart_queue_settings": "profile_id=?", + "smart_queue_exclusions": "profile_id=?", "automation_rules": "user_id=? AND profile_id=?", - "rtorrent_config_overrides": "user_id=? AND profile_id=?", + "rtorrent_config_overrides": "profile_id=?", "poller_settings": "profile_id=?", "download_plan_settings": "user_id=? AND profile_id=?", } @@ -76,6 +76,13 @@ def _loads(value: str) -> dict: return {} +def _table_columns(conn, table: str) -> set[str]: + try: + return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + except Exception: + return set() + + def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]: try: sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "") @@ -191,7 +198,10 @@ def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict: rows = tables.get(table) or [] if not rows: continue - columns = list(rows[0].keys()) + available = _table_columns(conn, table) + columns = [col for col in rows[0].keys() if col in available] + if not columns: + continue placeholders = ",".join("?" for _ in columns) conn.execute(f"DELETE FROM {table}") for row in rows: @@ -245,7 +255,10 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int count = 0 for row in rows: clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id)) - columns = list(clean.keys()) + available = _table_columns(conn, table) + columns = [col for col in clean.keys() if col in available] + if not columns: + continue placeholders = ",".join("?" for _ in columns) conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns]) count += 1 @@ -274,15 +287,24 @@ def delete_backup(backup_id: int, user_id: int | None = None) -> dict: return {"deleted": backup_id} -def _settings_row_key(user_id: int | None = None) -> str: - return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or auth.current_user_id() or default_user_id()}" +def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str: + uid = user_id or auth.current_user_id() or default_user_id() + scope = "profile" if backup_type == "profile" else "app" + if scope == "profile": + return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{uid}:{int(profile_id or 0)}" + return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}" -def _latest_backup_created_at(user_id: int) -> str | None: +def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None: + clauses = ["user_id=?", "COALESCE(backup_type,'app')=?"] + params: list[object] = [user_id, backup_type] + if backup_type == "profile": + clauses.append("profile_id=?") + params.append(int(profile_id or 0)) with connect() as conn: row = conn.execute( - "SELECT created_at FROM app_backups WHERE user_id=? AND COALESCE(backup_type,'app')='app' ORDER BY created_at DESC, id DESC LIMIT 1", - (user_id,), + f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1", + tuple(params), ).fetchone() return str(row["created_at"] or "") if row and row.get("created_at") else None @@ -302,20 +324,29 @@ def _preview_row(row: dict) -> dict: return output -def get_auto_backup_settings(user_id: int | None = None) -> dict: - key = _settings_row_key(user_id) +def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict: + key = _settings_row_key(user_id, backup_type, profile_id) with connect() as conn: row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone() settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")} settings["enabled"] = bool(settings.get("enabled")) settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24)) settings["retention_days"] = max(1, int(settings.get("retention_days") or 30)) + settings["backup_type"] = "profile" if backup_type == "profile" else "app" + if backup_type == "profile": + settings["profile_id"] = int(profile_id or 0) return settings -def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict: - _require_admin(user_id) - current = get_auto_backup_settings(user_id) +def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict: + user_id = user_id or auth.current_user_id() or default_user_id() + backup_type = "profile" if backup_type == "profile" else "app" + if backup_type == "app": + _require_admin(user_id) + else: + if not profile_id or not auth.can_access_profile(int(profile_id), user_id): + raise PermissionError("No access to profile") + current = get_auto_backup_settings(user_id, backup_type, profile_id) settings = { **current, "enabled": bool(data.get("enabled")), @@ -323,7 +354,7 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict: "retention_days": max(1, int(data.get("retention_days") or current["retention_days"])), "last_run_at": data.get("last_run_at", current.get("last_run_at")), } - key = _settings_row_key(user_id) + key = _settings_row_key(user_id, backup_type, profile_id) with connect() as conn: conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings))) return settings @@ -350,39 +381,69 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict: } -def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int: +def prune_old_backups(user_id: int | None = None, retention_days: int = 30, backup_type: str = "app", profile_id: int | None = None) -> int: user_id = user_id or auth.current_user_id() or default_user_id() cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds") + clauses = ["user_id=?", "COALESCE(backup_type,'app')=?", "created_at dict | None: - user_id = user_id or default_user_id() - if not _is_admin_user(user_id): - return None - settings = get_auto_backup_settings(user_id) - if not settings.get("enabled"): - return None +def _should_run(settings: dict, last_value: str | None) -> bool: now = datetime.now(timezone.utc) - last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id) try: last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None except Exception: last = None - if last and now - last < timedelta(hours=settings["interval_hours"]): + return not last or now - last >= timedelta(hours=settings["interval_hours"]) + + +def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict | None: + user_id = user_id or default_user_id() + backup_type = "profile" if backup_type == "profile" else "app" + if backup_type == "app" and not _is_admin_user(user_id): + return None + if backup_type == "profile" and (not profile_id or not auth.can_access_profile(int(profile_id), user_id)): + return None + settings = get_auto_backup_settings(user_id, backup_type, profile_id) + if not settings.get("enabled"): + return None + last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id, backup_type, profile_id) + if not _should_run(settings, last_value): if settings.get("last_run_at") != last_value: settings["last_run_at"] = last_value - save_auto_backup_settings(settings, user_id) + save_auto_backup_settings(settings, user_id, backup_type, profile_id) return None - backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) + now = datetime.now(timezone.utc) + if backup_type == "profile": + backup = create_profile_backup(f"Automatic profile backup {now.isoformat(timespec='seconds')}", int(profile_id or 0), user_id, automatic=True) + else: + backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds") - save_auto_backup_settings(settings, user_id) - prune_old_backups(user_id, settings["retention_days"]) + save_auto_backup_settings(settings, user_id, backup_type, profile_id) + prune_old_backups(user_id, settings["retention_days"], backup_type, profile_id) return backup +def _profile_schedule_keys() -> list[tuple[int, int]]: + prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:" + keys: list[tuple[int, int]] = [] + with connect() as conn: + rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall() + for row in rows: + parts = str(row.get("key") or "").split(":") + try: + keys.append((int(parts[-2]), int(parts[-1]))) + except Exception: + continue + return keys + + def start_scheduler() -> None: global _scheduler_started with _scheduler_lock: @@ -397,7 +458,9 @@ def start_scheduler() -> None: rows = conn.execute("SELECT id FROM users WHERE is_active=1 AND role='admin'").fetchall() user_ids = [int(row["id"]) for row in rows] or [default_user_id()] for uid in user_ids: - maybe_create_automatic_backup(uid) + maybe_create_automatic_backup(uid, "app") + for uid, pid in _profile_schedule_keys(): + maybe_create_automatic_backup(uid, "profile", pid) except Exception: pass time.sleep(300) diff --git a/pytorrent/services/rss.py b/pytorrent/services/rss.py index 5e861db..97e8450 100644 --- a/pytorrent/services/rss.py +++ b/pytorrent/services/rss.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone, timedelta from email.utils import parsedate_to_datetime from typing import Iterable -from ..db import connect, utcnow, default_user_id +from ..db import connect, utcnow from . import rtorrent from .workers import enqueue @@ -122,12 +122,12 @@ def matches_rule(rule: dict, item: dict) -> tuple[bool, str]: return True, "matched" -def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None: +def _log(profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None: with connect() as conn: try: conn.execute( - "INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)", - (user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()), + "INSERT INTO rss_history(profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?)", + (profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()), ) except Exception: # Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads. @@ -135,15 +135,14 @@ def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict: - user_id = user_id or default_user_id() profile_id = int(profile["id"]) now = utcnow() with connect() as conn: if only_due: - feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall() + feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (profile_id, now)).fetchall() else: - feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() - rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() + feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall() + rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall() queued = 0 tested = 0 errors: list[dict] = [] @@ -160,11 +159,11 @@ def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> continue link = item.get("link") or "" if not link: - _log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link") + _log(profile_id, feed["id"], rule["id"], item, "skipped", "missing link") continue enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id) queued += 1 - _log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason) + _log(profile_id, feed["id"], rule["id"], item, "queued", reason) with connect() as conn: conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"])) except Exception as exc: @@ -200,11 +199,11 @@ def start_scheduler(socketio=None) -> None: try: from .preferences import get_profile with connect() as conn: - profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall() + profiles = conn.execute("SELECT DISTINCT profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall() for row in profiles: - profile = get_profile(int(row["profile_id"]), int(row["user_id"])) + profile = get_profile(int(row["profile_id"])) if profile: - result = check(profile, int(row["user_id"]), only_due=True) + result = check(profile, only_due=True) if socketio and result.get("queued"): socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}") except Exception: diff --git a/pytorrent/services/rtorrent/config.py b/pytorrent/services/rtorrent/config.py index 4494bd3..ab086a1 100644 --- a/pytorrent/services/rtorrent/config.py +++ b/pytorrent/services/rtorrent/config.py @@ -54,11 +54,10 @@ def _normalize_config_value(meta: dict, value): def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]: - user_id = user_id or default_user_id() with connect() as conn: rows = conn.execute( - "SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", - (user_id, int(profile_id)), + "SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE profile_id=?", + (int(profile_id),), ).fetchall() return {r["key"]: r for r in rows} @@ -129,7 +128,6 @@ def _read_rtorrent_config_value(client, key: str, meta: dict) -> str: def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]: known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS} - user_id = default_user_id() now = utcnow() profile_id = int(profile["id"]) baseline_values = baseline_values or {} @@ -139,8 +137,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba for key in clear_set: if key in known: conn.execute( - "DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", - (user_id, profile_id, key), + "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?", + (profile_id, key), ) for key, value in (values or {}).items(): if key in clear_set: @@ -150,8 +148,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba continue normalized = _normalize_config_value(meta, value) existing = conn.execute( - "SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", - (user_id, profile_id, key), + "SELECT baseline_value FROM rtorrent_config_overrides WHERE profile_id=? AND key=?", + (profile_id, key), ).fetchone() existing_baseline = existing.get("baseline_value") if existing else None @@ -165,18 +163,18 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba if baseline not in (None, "") and normalized == baseline: conn.execute( - "DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", - (user_id, profile_id, key), + "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?", + (profile_id, key), ) continue conn.execute( - "INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)", - (user_id, profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now), + "INSERT OR REPLACE INTO rtorrent_config_overrides(profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?)", + (profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now), ) stored.append(key) conn.execute( - "UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?", - (1 if apply_on_start else 0, now, user_id, profile_id), + "UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE profile_id=?", + (1 if apply_on_start else 0, now, profile_id), ) return stored @@ -220,17 +218,16 @@ def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_sta def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict: """Remove saved UI overrides and return the freshly read rTorrent config.""" # Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent. - user_id = user_id or default_user_id() profile_id = int(profile["id"]) with connect() as conn: row = conn.execute( - "SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", - (user_id, profile_id), + "SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE profile_id=?", + (profile_id,), ).fetchone() removed = int((row or {}).get("count") or 0) conn.execute( - "DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", - (user_id, profile_id), + "DELETE FROM rtorrent_config_overrides WHERE profile_id=?", + (profile_id,), ) config = get_config(profile) config["reset_removed"] = removed diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index 833bbc3..4ff7afd 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -135,9 +135,8 @@ def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, defaul return max(minimum, int(default)) -def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: +def _default_settings(profile_id: int) -> dict[str, Any]: return { - 'user_id': user_id, 'profile_id': profile_id, 'enabled': 0, 'max_active_downloads': 5, @@ -162,18 +161,16 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]: - user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( - 'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?', - (user_id, profile_id), + 'SELECT * FROM smart_queue_settings WHERE profile_id=?', + (profile_id,), ).fetchone() - settings = dict(row or _default_settings(user_id, profile_id)) + settings = dict(row or _default_settings(profile_id)) return settings def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: - user_id = user_id or default_user_id() current = get_settings(profile_id, user_id) settings = { 'enabled': 1 if data.get('enabled', current.get('enabled')) else 0, @@ -214,9 +211,9 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N now = utcnow() with connect() as conn: conn.execute( - '''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(user_id, profile_id) DO UPDATE SET + '''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(profile_id) DO UPDATE SET enabled=excluded.enabled, max_active_downloads=excluded.max_active_downloads, stalled_seconds=excluded.stalled_seconds, @@ -234,80 +231,74 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N refill_enabled=excluded.refill_enabled, refill_interval_minutes=excluded.refill_interval_minutes, updated_at=excluded.updated_at''', - (user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), + (profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), ) return get_settings(profile_id, user_id) def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]: - user_id = user_id or default_user_id() with connect() as conn: return conn.execute( - 'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC', - (user_id, profile_id), + 'SELECT * FROM smart_queue_exclusions WHERE profile_id=? ORDER BY created_at DESC', + (profile_id,), ).fetchall() def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None: - user_id = user_id or default_user_id() now = utcnow() with connect() as conn: if excluded: conn.execute( - 'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)', - (user_id, profile_id, torrent_hash, reason, now), + 'INSERT OR REPLACE INTO smart_queue_exclusions(profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?)', + (profile_id, torrent_hash, reason, now), ) else: conn.execute( - 'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?', - (user_id, profile_id, torrent_hash), + 'DELETE FROM smart_queue_exclusions WHERE profile_id=? AND torrent_hash=?', + (profile_id, torrent_hash), ) def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None: - user_id = user_id or default_user_id() paused = paused or [] resumed = resumed or [] details = details or {} with connect() as conn: conn.execute( - 'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)', - (user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), + 'INSERT INTO smart_queue_history(profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?)', + (profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), ) def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: - user_id = user_id or default_user_id() with connect() as conn: return conn.execute( - 'SELECT * FROM smart_queue_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))), + 'SELECT * FROM smart_queue_history WHERE profile_id=? ORDER BY 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: """Delete Smart Queue history rows for the current profile and return the removed count.""" # Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched. - user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( - 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', - (user_id, profile_id), + 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?', + (profile_id,), ).fetchone() count = int((row or {}).get('count') or 0) conn.execute( - 'DELETE FROM smart_queue_history WHERE user_id=? AND profile_id=?', - (user_id, profile_id), + 'DELETE FROM smart_queue_history WHERE profile_id=?', + (profile_id,), ) return count def count_history(profile_id: int, user_id: int | None = None) -> int: - user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( - 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', - (user_id, profile_id), + 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?', + (profile_id,), ).fetchone() return int((row or {}).get('count') or 0) @@ -315,11 +306,10 @@ def count_history(profile_id: int, user_id: int | None = None) -> int: def _latest_history_event(profile_id: int, user_id: int | None = None) -> str: """Return the newest Smart Queue history event for duplicate suppression.""" # Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream. - user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( - 'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1', - (user_id, profile_id), + 'SELECT event FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT 1', + (profile_id,), ).fetchone() return str((row or {}).get('event') or '') @@ -338,8 +328,8 @@ def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[s return True -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 _excluded_hashes(profile_id: int, user_id: int | None = None) -> set[str]: + return {r['torrent_hash'] for r in list_exclusions(profile_id)} @@ -891,7 +881,7 @@ def _refill_mode(settings: dict[str, Any]) -> str: def _mark_refill_run(profile_id: int, user_id: int) -> None: # Note: Custom refill interval is measured from the last lightweight refill attempt. with connect() as conn: - conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) + conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id)) def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]: @@ -1090,13 +1080,13 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i def mark_run(profile_id: int, user_id: int | None = None) -> None: user_id = user_id or default_user_id() with connect() as conn: - conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) + conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id)) def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]: # Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact. now = utcnow() with connect() as conn: - conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (now, now, user_id, profile_id)) + conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE profile_id=?', (now, now, profile_id)) add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id) settings = get_settings(profile_id, user_id) return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'} diff --git a/pytorrent/static/js/poller.js b/pytorrent/static/js/poller.js index 8e3a577..5fa70f1 100644 --- a/pytorrent/static/js/poller.js +++ b/pytorrent/static/js/poller.js @@ -1 +1 @@ -export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms · gap ${esc(rt.last_tick_gap_ms||0)} ms · effective ${esc(rt.effective_interval_seconds||0)}s · min ${esc(rt.configured_min_interval_seconds||0)}s · payload ${esc(fmtBytes(rt.emitted_payload_size||0))} · rTorrent calls ${esc(rt.rtorrent_call_count||0)} · skipped ${esc(rt.skipped_emissions||0)} · mode ${esc(mode)} · adaptive ${adaptive?'on':'off'} · ok ${rt.last_ok?'yes':'no'} · ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; 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==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('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 token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'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();}); $('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); 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('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner 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 job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('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); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('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');});\n $('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); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('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; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } 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();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n 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);} });\n $('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);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ toggleMobileVisibleSelection(); scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileDetails=e.target.closest('.mobile-details-btn'); if(mobileDetails){ const card0=mobileDetails.closest('.mobile-card'); if(card0?.dataset.hash) openMobileDetails(card0.dataset.hash); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download-link`).catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('compactTorrentListEnabled')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n"; +export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms · gap ${esc(rt.last_tick_gap_ms||0)} ms · effective ${esc(rt.effective_interval_seconds||0)}s · min ${esc(rt.configured_min_interval_seconds||0)}s · payload ${esc(fmtBytes(rt.emitted_payload_size||0))} · rTorrent calls ${esc(rt.rtorrent_call_count||0)} · skipped ${esc(rt.skipped_emissions||0)} · mode ${esc(mode)} · adaptive ${adaptive?'on':'off'} · ok ${rt.last_ok?'yes':'no'} · ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; 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==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('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 token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'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();}); $('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); 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('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner 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 job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('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); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('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');});\n $('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); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('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; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } 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();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n 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);} });\n $('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);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ toggleMobileVisibleSelection(); scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileDetails=e.target.closest('.mobile-details-btn'); if(mobileDetails){ const card0=mobileDetails.closest('.mobile-card'); if(card0?.dataset.hash) openMobileDetails(card0.dataset.hash); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download-link`).catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('compactTorrentListEnabled')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n"; diff --git a/pytorrent/static/js/rss.js b/pytorrent/static/js/rss.js index 1d4d974..e2671e8 100644 --- a/pytorrent/static/js/rss.js +++ b/pytorrent/static/js/rss.js @@ -1 +1 @@ -export const rssSource = " async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[]; if($('rssManager')) $('rssManager').innerHTML=`
Feeds
${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),` `]))}
Rules
${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),` `]))}
RSS log
${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`; }\n \n\n function fillBackupSettings(settings={}){\n if($('backupAutoEnabled')) $('backupAutoEnabled').checked=!!settings.enabled;\n if($('backupAutoInterval')) $('backupAutoInterval').value=settings.interval_hours||24;\n if($('backupRetentionDays')) $('backupRetentionDays').value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '
No saved rows in this table.
';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`
${esc(t.name)}${esc(t.rows)} row(s) · ${(t.columns||[]).length} column(s)${backupPreviewDetails(t)}
`).join('');\n const type=preview.backup_type==='app'?'application':'profile';\n return `
Backup preview
${esc(type)} backup · Created: ${esc(preview.created_at||'-')} · ${preview.automatic?'automatic':'manual'} · sensitive values hidden
${rows || '
Backup has no previewable settings.
'}
`;\n }\n function backupRows(rows=[]){\n return responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`
Download
`]),'backup-table');\n }\n function switchBackupPane(pane){\n document.querySelectorAll('[data-backup-pane]').forEach(x=>x.classList.toggle('active',x.dataset.backupPane===pane));\n document.querySelectorAll('[data-backup-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.backupPanel!==pane));\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n fillBackupSettings(j.auto||{});\n if($('profileBackupManager')) $('profileBackupManager').innerHTML=backupRows(j.profile_backups||[]);\n if($('appBackupManager')) $('appBackupManager').innerHTML=j.can_app_backup ? backupRows(j.app_backups||[]) : '
Application backups are admin-only.
';\n if(!j.can_app_backup) document.querySelector('[data-backup-pane=\"app\"]')?.classList.add('disabled');\n }\n\n\n\n"; +export const rssSource = "\n async function loadRss(){\n const j=await (await fetch('/api/rss')).json();\n const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[];\n if($('rssManager')) $('rssManager').innerHTML=`
Feeds
${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),` `]))}
Rules
${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),` `]))}
RSS log
${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`;\n }\n\n function fillBackupSettings(settings={}, prefix='app'){\n const cap=prefix==='profile'?'Profile':'App';\n const enabled=$(prefix==='profile'?'profileBackupAutoEnabled':'backupAutoEnabled');\n const interval=$(prefix==='profile'?'profileBackupAutoInterval':'backupAutoInterval');\n const retention=$(prefix==='profile'?'profileBackupRetentionDays':'backupRetentionDays');\n if(enabled) enabled.checked=!!settings.enabled;\n if(interval) interval.value=settings.interval_hours||24;\n if(retention) retention.value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '
No saved rows in this table.
';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`
${esc(t.name)}${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)${backupPreviewDetails(t)}
`).join('');\n const type=preview.backup_type==='app'?'application':'profile';\n return `
Backup preview
${esc(type)} backup \u00b7 Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 sensitive values hidden
${rows || '
Backup has no previewable settings.
'}
`;\n }\n function backupRows(rows=[]){\n return responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`
Download
`]),'backup-table');\n }\n function switchBackupPane(pane){\n document.querySelectorAll('[data-backup-pane]').forEach(x=>x.classList.toggle('active',x.dataset.backupPane===pane));\n document.querySelectorAll('[data-backup-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.backupPanel!==pane));\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n fillBackupSettings(j.profile_auto||{}, 'profile');\n fillBackupSettings(j.app_auto||j.auto||{}, 'app');\n if($('profileBackupManager')) $('profileBackupManager').innerHTML=backupRows(j.profile_backups||[]);\n if($('appBackupManager')) $('appBackupManager').innerHTML=j.can_app_backup ? backupRows(j.app_backups||[]) : '
Application backups are admin-only.
';\n if(!j.can_app_backup) document.querySelector('[data-backup-pane=\"app\"]')?.classList.add('disabled');\n }\n"; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 4e7194a..1903147 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -309,7 +309,7 @@
Automations / rules
Build a rule as: conditions first, then ordered actions. Matching torrents are handled as one batch and the cooldown is applied to the whole rule.
1. Rule
2. Conditions
3. Actions, in order
Rules
History
rTorrent config
Typical rTorrent options, like in ruTorrent. Unsupported methods are shown as unavailable.
Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.
No changes
Loading config...
Cleanup / retention
One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.
Loading cleanup data...
-
Backup / restore
Profile backup restores only the active profile context for the current user. Application backup restores global application data and is available only to admins.
Creates and restores settings for the currently selected profile only. User IDs and profile IDs are remapped to the current user and active profile on restore.
Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.
+
Backup / restore
Profile backup restores only the active profile context. Application backup restores global application data and is available only to admins.
Creates and restores settings for the currently selected profile. User-scoped preferences are remapped to the current user where needed.
Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.
pyTorrent status
Diagnostics for pyTorrent process and active SCGI/XMLRPC connection.
Open this tab to load diagnostics.