From 8990f2b404d360bdf4684911e5805c0eefc6a15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 7 Jun 2026 23:12:00 +0200 Subject: [PATCH 1/2] fix profile-scoped backups and shared profile rules --- pytorrent/db.py | 6 + pytorrent/routes/operation_logs.py | 9 +- pytorrent/routes/profiles.py | 60 +++++++-- pytorrent/services/backup.py | 178 +++++++++++++++++++------ pytorrent/services/download_planner.py | 48 +++++-- pytorrent/services/operation_logs.py | 15 +-- pytorrent/services/ratio_rules.py | 28 ++-- pytorrent/static/js/backupTools.js | 2 +- pytorrent/static/js/labelTools.js | 2 +- pytorrent/static/js/ratioTools.js | 2 +- 10 files changed, 264 insertions(+), 86 deletions(-) diff --git a/pytorrent/db.py b/pytorrent/db.py index a32de2f..fe3dd95 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -263,6 +263,8 @@ CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(pr 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_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_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_groups_profile_enabled ON ratio_groups(profile_id, enabled, name); +CREATE INDEX IF NOT EXISTS idx_labels_profile_name ON labels(profile_id, name); CREATE TABLE IF NOT EXISTS app_backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -273,6 +275,8 @@ CREATE TABLE IF NOT EXISTS app_backups ( payload_json TEXT NOT NULL, created_at TEXT NOT NULL ); +CREATE INDEX IF NOT EXISTS idx_app_backups_profile_type_created ON app_backups(profile_id, backup_type, created_at); +CREATE INDEX IF NOT EXISTS idx_app_backups_user_type_created ON app_backups(user_id, backup_type, created_at); CREATE TABLE IF NOT EXISTS smart_queue_settings ( profile_id INTEGER NOT NULL, @@ -450,6 +454,7 @@ CREATE TABLE IF NOT EXISTS download_plan_settings ( updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id) ); +CREATE INDEX IF NOT EXISTS idx_download_plan_settings_profile ON download_plan_settings(profile_id, updated_at); CREATE TABLE IF NOT EXISTS download_plan_paused ( profile_id INTEGER NOT NULL, @@ -508,6 +513,7 @@ CREATE TABLE IF NOT EXISTS operation_log_settings ( updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id) ); +CREATE INDEX IF NOT EXISTS idx_operation_log_settings_profile ON operation_log_settings(profile_id, updated_at); CREATE TABLE IF NOT EXISTS tracker_favicon_cache ( domain TEXT PRIMARY KEY, source_url TEXT, diff --git a/pytorrent/routes/operation_logs.py b/pytorrent/routes/operation_logs.py index 9c217c6..6a6b620 100644 --- a/pytorrent/routes/operation_logs.py +++ b/pytorrent/routes/operation_logs.py @@ -35,9 +35,12 @@ def operation_logs_settings_save(): profile = _active_profile_or_400() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 - settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {}) - result = operation_logs.apply_retention(int(profile["id"])) - return ok({"settings": settings, "retention": result}) + try: + settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {}) + result = operation_logs.apply_retention(int(profile["id"])) + return ok({"settings": settings, "retention": result}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 @bp.post("/operation-logs/clear") diff --git a/pytorrent/routes/profiles.py b/pytorrent/routes/profiles.py index 41a7571..029e4fb 100644 --- a/pytorrent/routes/profiles.py +++ b/pytorrent/routes/profiles.py @@ -2,6 +2,7 @@ from __future__ import annotations from ._shared import * from ..services.rtorrent.diagnostics import profile_diagnostics +from ..services import auth @bp.get("/profiles") def profiles_list(): @@ -108,8 +109,19 @@ def prefs_table_columns_recommended(): def labels_list(): profile = preferences.active_profile() pid = profile["id"] if profile else None + if not pid: + return ok({"labels": []}) with connect() as conn: - rows = conn.execute("SELECT * FROM labels WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name COLLATE NOCASE", (default_user_id(), pid)).fetchall() + rows = conn.execute( + """ + SELECT l.*, COALESCE(u.display_name,u.username,u.email,'user ' || l.user_id) AS owner_name + FROM labels l + LEFT JOIN users u ON u.id=l.user_id + WHERE l.profile_id=? + ORDER BY l.name COLLATE NOCASE, l.id + """, + (pid,), + ).fetchall() return ok({"labels": rows}) @@ -123,9 +135,15 @@ def labels_save(): name = str(data.get("name") or "").strip() if not name: return jsonify({"ok": False, "error": "Missing label name"}), 400 + if not auth.can_write_profile(int(profile["id"]), default_user_id()): + return jsonify({"ok": False, "error": "No write access to profile"}), 403 now = utcnow() with connect() as conn: - conn.execute("INSERT OR IGNORE INTO labels(user_id,profile_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)", (default_user_id(), profile["id"], name, data.get("color") or "#64748b", now, now)) + existing = conn.execute("SELECT id FROM labels WHERE profile_id=? AND lower(name)=lower(?) ORDER BY id LIMIT 1", (profile["id"], name)).fetchone() + if existing: + conn.execute("UPDATE labels SET color=?, updated_at=? WHERE id=?", (data.get("color") or "#64748b", now, existing["id"])) + else: + conn.execute("INSERT INTO labels(user_id,profile_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)", (default_user_id(), profile["id"], name, data.get("color") or "#64748b", now, now)) return labels_list() @@ -134,8 +152,10 @@ def labels_save(): def labels_delete(label_id: int): profile = preferences.active_profile() pid = profile["id"] if profile else None + if not pid or not auth.can_write_profile(int(pid), default_user_id()): + return jsonify({"ok": False, "error": "No write access to profile"}), 403 with connect() as conn: - conn.execute("DELETE FROM labels WHERE id=? AND user_id=? AND (profile_id=? OR profile_id IS NULL)", (label_id, default_user_id(), pid)) + conn.execute("DELETE FROM labels WHERE id=? AND profile_id=?", (label_id, pid)) return labels_list() @@ -145,8 +165,17 @@ def ratio_groups_list(): profile = preferences.active_profile() pid = profile["id"] if profile else None with connect() as conn: - rows = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name COLLATE NOCASE", (default_user_id(), pid)).fetchall() - history = conn.execute("SELECT * FROM ratio_history WHERE user_id=? AND profile_id=? ORDER BY id DESC LIMIT 50", (default_user_id(), pid or 0)).fetchall() if pid else [] + rows = conn.execute( + """ + SELECT g.*, COALESCE(u.display_name,u.username,u.email,'user ' || g.user_id) AS owner_name + FROM ratio_groups g + LEFT JOIN users u ON u.id=g.user_id + WHERE g.profile_id=? + ORDER BY g.name COLLATE NOCASE, g.id + """, + (pid or 0,), + ).fetchall() if pid else [] + history = conn.execute("SELECT * FROM ratio_history WHERE profile_id=? ORDER BY id DESC LIMIT 50", (pid or 0,)).fetchall() if pid else [] return ok({"groups": rows, "history": history}) @@ -160,14 +189,23 @@ def ratio_groups_save(): name = str(data.get("name") or "").strip() if not name: return jsonify({"ok": False, "error": "Missing group name"}), 400 + if not auth.can_write_profile(int(profile["id"]), default_user_id()): + return jsonify({"ok": False, "error": "No write access to profile"}), 403 now = utcnow() with connect() as conn: - conn.execute( - """INSERT INTO ratio_groups(user_id,profile_id,name,min_ratio,max_ratio,seed_time_minutes,min_seed_time_minutes,ignore_private,ignore_active_upload,active_upload_min_bytes,move_path,set_label,action,enabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(user_id,profile_id,name) DO UPDATE SET min_ratio=excluded.min_ratio,max_ratio=excluded.max_ratio,seed_time_minutes=excluded.seed_time_minutes,min_seed_time_minutes=excluded.min_seed_time_minutes,ignore_private=excluded.ignore_private,ignore_active_upload=excluded.ignore_active_upload,active_upload_min_bytes=excluded.active_upload_min_bytes,move_path=excluded.move_path,set_label=excluded.set_label,action=excluded.action,enabled=excluded.enabled,updated_at=excluded.updated_at""", - (default_user_id(), profile["id"], name, float(data.get("min_ratio") or 1), float(data.get("max_ratio") or 2), int(data.get("seed_time_minutes") or 0), int(data.get("min_seed_time_minutes") or 0), 1 if data.get("ignore_private", True) else 0, 1 if data.get("ignore_active_upload", True) else 0, int(data.get("active_upload_min_bytes") or 1024), data.get("move_path") or "", data.get("set_label") or "", data.get("action") or "stop", 1 if data.get("enabled", True) else 0, now, now), - ) + existing = conn.execute("SELECT id,user_id FROM ratio_groups WHERE profile_id=? AND lower(name)=lower(?) ORDER BY id LIMIT 1", (profile["id"], name)).fetchone() + values = (float(data.get("min_ratio") or 1), float(data.get("max_ratio") or 2), int(data.get("seed_time_minutes") or 0), int(data.get("min_seed_time_minutes") or 0), 1 if data.get("ignore_private", True) else 0, 1 if data.get("ignore_active_upload", True) else 0, int(data.get("active_upload_min_bytes") or 1024), data.get("move_path") or "", data.get("set_label") or "", data.get("action") or "stop", 1 if data.get("enabled", True) else 0, now) + if existing: + conn.execute( + """UPDATE ratio_groups SET min_ratio=?,max_ratio=?,seed_time_minutes=?,min_seed_time_minutes=?,ignore_private=?,ignore_active_upload=?,active_upload_min_bytes=?,move_path=?,set_label=?,action=?,enabled=?,updated_at=? WHERE id=? AND profile_id=?""", + (*values, existing["id"], profile["id"]), + ) + else: + conn.execute( + """INSERT INTO ratio_groups(user_id,profile_id,name,min_ratio,max_ratio,seed_time_minutes,min_seed_time_minutes,ignore_private,ignore_active_upload,active_upload_min_bytes,move_path,set_label,action,enabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (default_user_id(), profile["id"], name, *values[:-1], now, now), + ) return ratio_groups_list() diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index ad4739d..4f7a6ea 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -15,27 +15,46 @@ APP_BACKUP_TABLES = [ "rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings", ] -# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user. +# Note: Profile backups contain profile behavior plus user-specific view preferences for the user creating the backup. PROFILE_BACKUP_TABLES = [ "rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions", "automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings", ] +# Scope values: +# - profile: shared profile behavior, visible/restored by profile access. +# - user_profile: personal preferences for the backup creator/restorer. +PROFILE_TABLE_SCOPES = { + "rtorrent_profiles": "profile_id", + "profile_preferences": "user_profile", + "disk_monitor_preferences": "user_profile", + "labels": "profile", + "ratio_groups": "profile", + "rss_feeds": "profile", + "rss_rules": "profile", + "smart_queue_settings": "profile", + "smart_queue_exclusions": "profile", + "automation_rules": "profile", + "rtorrent_config_overrides": "profile", + "poller_settings": "profile", + "download_plan_settings": "profile_singleton", +} + PROFILE_TABLE_FILTERS = { "rtorrent_profiles": "id=?", "profile_preferences": "user_id=? AND profile_id=?", "disk_monitor_preferences": "user_id=? AND profile_id=?", - "labels": "user_id=? AND profile_id=?", - "ratio_groups": "user_id=? AND profile_id=?", + "labels": "profile_id=?", + "ratio_groups": "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=?", + "automation_rules": "profile_id=?", "rtorrent_config_overrides": "profile_id=?", "poller_settings": "profile_id=?", - "download_plan_settings": "user_id=? AND profile_id=?", + "download_plan_settings": "profile_id=?", } DEFAULT_AUTO_BACKUP_SETTINGS = { @@ -91,6 +110,41 @@ def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) return [] +def _profile_filter_params(table: str, user_id: int, profile_id: int) -> tuple[object, ...]: + scope = PROFILE_TABLE_SCOPES.get(table) + if scope in {"profile", "profile_id", "profile_singleton"}: + return (int(profile_id),) + return (int(user_id), int(profile_id)) + + +def _user_label(conn, user_id: int | None) -> str: + if not user_id: + return "system" + try: + row = conn.execute("SELECT display_name, username, email FROM users WHERE id=?", (int(user_id),)).fetchone() + if row: + return str(row.get("display_name") or row.get("username") or row.get("email") or f"user {user_id}") + except Exception: + pass + return f"user {user_id}" + + +def _backup_row_visible(row: dict, user_id: int) -> bool: + backup_type = str(row.get("backup_type") or "app") + if backup_type == "app": + return _is_admin_user(user_id) + profile_id = int(row.get("profile_id") or 0) + return bool(profile_id and auth.can_access_profile(profile_id, user_id)) + + +def _backup_row_writable(row: dict, user_id: int) -> bool: + backup_type = str(row.get("backup_type") or "app") + if backup_type == "app": + return _is_admin_user(user_id) + profile_id = int(row.get("profile_id") or 0) + return bool(profile_id and auth.can_write_profile(profile_id, user_id)) + + def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict: with connect() as conn: cur = conn.execute( @@ -127,11 +181,7 @@ def create_profile_backup(name: str, profile_id: int, user_id: int | None = None with connect() as conn: for table in PROFILE_BACKUP_TABLES: where = PROFILE_TABLE_FILTERS.get(table) - if where == "id=?" or where == "profile_id=?": - params = (int(profile_id),) - else: - params = (user_id, int(profile_id)) - payload["tables"][table] = _table_rows(conn, table, where, params) + payload["tables"][table] = _table_rows(conn, table, where, _profile_filter_params(table, user_id, int(profile_id))) return _store_backup(user_id, name, "profile", int(profile_id), payload) @@ -141,26 +191,39 @@ def create_backup(name: str, user_id: int | None = None, automatic: bool = False def list_backups(user_id: int | None = None, backup_type: str | None = None, profile_id: int | None = None) -> list[dict]: user_id = user_id or auth.current_user_id() or default_user_id() - clauses = ["user_id=?"] - params: list[object] = [user_id] + clauses: list[str] = [] + params: list[object] = [] if backup_type: clauses.append("COALESCE(backup_type,'app')=?") params.append(backup_type) if profile_id is not None: clauses.append("profile_id=?") params.append(int(profile_id)) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" with connect() as conn: rows = conn.execute( - f"SELECT id,name,created_at,payload_json,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY id DESC", + f""" + SELECT b.id,b.name,b.user_id,b.created_at,b.payload_json,COALESCE(b.backup_type,'app') AS backup_type,b.profile_id, + u.display_name AS owner_display_name,u.username AS owner_username,u.email AS owner_email + FROM app_backups b + LEFT JOIN users u ON u.id=b.user_id + {where} + ORDER BY b.id DESC + """, tuple(params), ).fetchall() result = [] for row in rows: + if not _backup_row_visible(row, user_id): + continue payload = _loads(row.get("payload_json") or "{}") tables = payload.get("tables") or {} + owner_name = str(row.get("owner_display_name") or row.get("owner_username") or row.get("owner_email") or f"user {row.get('user_id')}") result.append({ "id": row.get("id"), "name": row.get("name"), + "owner_user_id": row.get("user_id"), + "owner_name": owner_name, "created_at": row.get("created_at"), "backup_type": row.get("backup_type") or payload.get("backup_type") or "app", "profile_id": row.get("profile_id") or payload.get("source_profile_id"), @@ -169,16 +232,14 @@ def list_backups(user_id: int | None = None, backup_type: str | None = None, pro }) return result - -def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict: +def payload_for_backup(backup_id: int, user_id: int | None = None, require_write: bool = False) -> dict: user_id = user_id or auth.current_user_id() or default_user_id() with connect() as conn: - row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone() - if not row: + row = conn.execute("SELECT id,user_id,COALESCE(backup_type,'app') AS backup_type,profile_id,payload_json FROM app_backups WHERE id=?", (backup_id,)).fetchone() + if not row or not (_backup_row_writable(row, user_id) if require_write else _backup_row_visible(row, user_id)): raise ValueError("Backup not found") return json.loads(row["payload_json"] or "{}") - def _backup_type(payload: dict) -> str: return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app")) @@ -186,7 +247,7 @@ def _backup_type(payload: dict) -> str: def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict: user_id = user_id or auth.current_user_id() or default_user_id() _require_admin(user_id) - payload = payload_for_backup(backup_id, user_id) + payload = payload_for_backup(backup_id, user_id, require_write=True) if _backup_type(payload) != "app": raise ValueError("This is not an application backup") tables = payload.get("tables") or {} @@ -234,7 +295,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int user_id = user_id or auth.current_user_id() or default_user_id() if not auth.can_write_profile(target_profile_id, user_id): raise PermissionError("No write access to profile") - payload = payload_for_backup(backup_id, user_id) + payload = payload_for_backup(backup_id, user_id, require_write=True) if _backup_type(payload) != "profile": raise ValueError("This is not a profile backup") tables = payload.get("tables") or {} @@ -245,10 +306,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int for table in PROFILE_BACKUP_TABLES: rows = tables.get(table) or [] where = PROFILE_TABLE_FILTERS.get(table) - if where == "id=?" or where == "profile_id=?": - params = (int(target_profile_id),) - else: - params = (user_id, int(target_profile_id)) + params = _profile_filter_params(table, user_id, int(target_profile_id)) conn.execute(f"DELETE FROM {table} WHERE {where}", params) if not rows: continue @@ -269,7 +327,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | None = None) -> dict: - payload = payload_for_backup(backup_id, user_id) + payload = payload_for_backup(backup_id, user_id, require_write=True) if _backup_type(payload) == "profile": target = profile_id or payload.get("source_profile_id") if not target: @@ -281,26 +339,30 @@ def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | def delete_backup(backup_id: int, user_id: int | None = None) -> dict: user_id = user_id or auth.current_user_id() or default_user_id() with connect() as conn: - cur = conn.execute("DELETE FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)) + row = conn.execute("SELECT id,user_id,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE id=?", (backup_id,)).fetchone() + if not row or not _backup_row_writable(row, user_id): + raise ValueError("Backup not found") + cur = conn.execute("DELETE FROM app_backups WHERE id=?", (backup_id,)) if not cur.rowcount: raise ValueError("Backup not found") return {"deleted": backup_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}:profile:{int(profile_id or 0)}" return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}" - 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] + clauses = ["COALESCE(backup_type,'app')=?"] + params: list[object] = [backup_type] if backup_type == "profile": clauses.append("profile_id=?") params.append(int(profile_id or 0)) + else: + clauses.append("user_id=?") + params.append(user_id) with connect() as conn: row = conn.execute( f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1", @@ -308,7 +370,6 @@ def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id ).fetchone() return str(row["created_at"] or "") if row and row.get("created_at") else None - def _preview_value(value: object) -> object: if value is None or isinstance(value, (int, float, bool)): return value @@ -325,9 +386,13 @@ def _preview_row(row: dict) -> dict: def get_auto_backup_settings(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() 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() + if not row and backup_type == "profile": + legacy_key = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{int(user_id)}:{int(profile_id or 0)}" + row = conn.execute("SELECT value FROM app_settings WHERE key=?", (legacy_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)) @@ -335,6 +400,9 @@ def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app settings["backup_type"] = "profile" if backup_type == "profile" else "app" if backup_type == "profile": settings["profile_id"] = int(profile_id or 0) + settings["owner_user_id"] = user_id or auth.current_user_id() or default_user_id() + with connect() as conn: + settings["owner_name"] = _user_label(conn, settings["owner_user_id"]) return settings @@ -361,11 +429,28 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_typ return settings + +def _backup_owner_info(backup_id: int) -> dict: + with connect() as conn: + row = conn.execute( + """ + SELECT b.user_id,COALESCE(u.display_name,u.username,u.email,'user ' || b.user_id) AS owner_name + FROM app_backups b + LEFT JOIN users u ON u.id=b.user_id + WHERE b.id=? + """, + (int(backup_id),), + ).fetchone() + return {"owner_user_id": row.get("user_id") if row else None, "owner_name": row.get("owner_name") if row else ""} + def preview_backup(backup_id: int, user_id: int | None = None) -> dict: payload = payload_for_backup(backup_id, user_id) tables = payload.get("tables") or {} + owner = _backup_owner_info(backup_id) return { "version": payload.get("version"), + "owner_user_id": owner.get("owner_user_id"), + "owner_name": owner.get("owner_name"), "created_at": payload.get("created_at"), "backup_type": _backup_type(payload), "source_profile_id": payload.get("source_profile_id"), @@ -385,16 +470,18 @@ 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, 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 bool: now = datetime.now(timezone.utc) try: @@ -433,18 +520,33 @@ def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str = def _profile_schedule_keys() -> list[tuple[int, int]]: prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:" - keys: list[tuple[int, int]] = [] + keys: set[tuple[int, int]] = set() 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]))) + if len(parts) >= 5: + # Legacy key: backup:auto:profile:{uid}:{profile_id} + keys.add((int(parts[-2]), int(parts[-1]))) + elif len(parts) >= 4: + profile_id = int(parts[-1]) + keys.add((_profile_owner_for_backup(profile_id), profile_id)) except Exception: continue - return keys + return sorted(keys) +def _profile_owner_for_backup(profile_id: int) -> int: + with connect() as conn: + row = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (int(profile_id),)).fetchone() + if row and row.get("user_id"): + return int(row["user_id"]) + row = conn.execute("SELECT user_id FROM user_profile_permissions WHERE profile_id=? AND access_level='full' ORDER BY user_id LIMIT 1", (int(profile_id),)).fetchone() + if row and row.get("user_id"): + return int(row["user_id"]) + return default_user_id() + def start_scheduler() -> None: global _scheduler_started with _scheduler_lock: diff --git a/pytorrent/services/download_planner.py b/pytorrent/services/download_planner.py index 13ce3b5..b4adb50 100644 --- a/pytorrent/services/download_planner.py +++ b/pytorrent/services/download_planner.py @@ -8,7 +8,7 @@ from typing import Any import psutil from ..db import connect, default_user_id, utcnow -from . import rtorrent +from . import auth, rtorrent DEFAULTS = { "enabled": False, @@ -140,14 +140,31 @@ def normalize(data: dict | None) -> dict: } -def _row(user_id: int, profile_id: int) -> dict | None: +def _row(user_id: int | None, profile_id: int) -> dict | None: with connect() as conn: - return conn.execute( - "SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?", - (user_id, profile_id), + row = conn.execute( + "SELECT * FROM download_plan_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1", + (profile_id,), ).fetchone() + if row: + return row + if user_id: + return conn.execute( + "SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?", + (user_id, profile_id), + ).fetchone() + return None +def _user_label(user_id: int | None) -> str: + if not user_id: + return "system" + with connect() as conn: + row = conn.execute("SELECT display_name, username, email FROM users WHERE id=?", (int(user_id),)).fetchone() + if row: + return str(row.get("display_name") or row.get("username") or row.get("email") or f"user {user_id}") + return f"user {user_id}" + def _preference_row_for_disk_source(profile_id: int, user_id: int | None = None) -> dict | None: @@ -269,12 +286,13 @@ def get_settings(profile_id: int, user_id: int | None = None) -> dict: row = _row(user_id, profile_id) if not row: migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)}) - return {**migrated, "profile_id": int(profile_id), "user_id": int(user_id)} + return {**migrated, "profile_id": int(profile_id), "owner_user_id": int(user_id), "owner_name": _user_label(user_id)} try: data = json.loads(row.get("settings_json") or "{}") except Exception: data = {} - settings = {**normalize(data), "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": row.get("updated_at")} + owner_user_id = int(row.get("user_id") or user_id) + settings = {**normalize(data), "profile_id": int(profile_id), "owner_user_id": owner_user_id, "owner_name": _user_label(owner_user_id), "updated_at": row.get("updated_at")} runtime_override = _override_until(int(profile_id)) if runtime_override: settings["manual_override_until"] = runtime_override @@ -283,18 +301,20 @@ def get_settings(profile_id: int, user_id: int | None = None) -> dict: def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict: user_id = user_id or default_user_id() + if not auth.can_write_profile(int(profile_id), user_id): + raise PermissionError("No write access to profile") settings = normalize(data) now = utcnow() with connect() as conn: + conn.execute("DELETE FROM download_plan_settings WHERE profile_id=?", (int(profile_id),)) conn.execute( """ INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at) VALUES(?,?,?,?) - ON CONFLICT(user_id, profile_id) DO UPDATE SET settings_json=excluded.settings_json, updated_at=excluded.updated_at """, (user_id, profile_id, json.dumps(settings), now), ) - return {**settings, "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": now} + return {**settings, "profile_id": int(profile_id), "owner_user_id": int(user_id), "owner_name": _user_label(user_id), "updated_at": now} def _active_downloading_hashes(profile: dict) -> list[str]: @@ -445,9 +465,10 @@ def evaluate(profile: dict, settings: dict | None = None, now: datetime | None = def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict: profile_id = int(profile.get("id") or 0) - user_id = user_id or int(profile.get("user_id") or default_user_id()) - # Note: Background planner runs without Flask session state, so settings are resolved with the profile owner. - settings = get_settings(profile_id, user_id) + settings = get_settings(profile_id, user_id or int(profile.get("user_id") or default_user_id())) + user_id = int(settings.get("owner_user_id") or user_id or profile.get("user_id") or default_user_id()) + if not auth.can_write_profile(profile_id, user_id): + return {"ok": True, "enabled": False, "profile_id": profile_id, "skipped": True, "reason": "planner owner has no write access", "history": history(profile_id, 20), "history_total": history_count(profile_id)} if not settings.get("enabled"): return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile, user_id=user_id)} now = time.monotonic() @@ -505,8 +526,7 @@ def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> d def preview(profile: dict, user_id: int | None = None) -> dict: profile_id = int(profile.get("id") or 0) - user_id = user_id or int(profile.get("user_id") or default_user_id()) - settings = get_settings(profile_id, user_id) + settings = get_settings(profile_id, user_id or int(profile.get("user_id") or default_user_id())) decision = evaluate(profile, settings) return { "profile_id": profile_id, diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py index 01002e5..d60eee3 100644 --- a/pytorrent/services/operation_logs.py +++ b/pytorrent/services/operation_logs.py @@ -104,12 +104,13 @@ def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict: profile_id = int(profile_id or 0) with connect() as conn: row = conn.execute( - "SELECT * FROM operation_log_settings WHERE user_id=? AND profile_id=?", - (user_id, profile_id), + "SELECT * FROM operation_log_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1", + (profile_id,), ).fetchone() if not row: - return {"user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS} + return {"owner_user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS} data = {**DEFAULT_SETTINGS, **dict(row)} + data["owner_user_id"] = int(data.pop("user_id", user_id) or user_id) data["retention_mode"] = data.get("retention_mode") if data.get("retention_mode") in VALID_RETENTION_MODES else "days" data["retention_days"] = max(1, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"])) data["retention_lines"] = max(100, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"])) @@ -125,16 +126,14 @@ def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> di days = max(1, min(3650, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"]))) lines = max(100, min(1_000_000, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"]))) now = utcnow() + if not auth.can_write_profile(profile_id, user_id): + raise PermissionError("No write access to profile") with connect() as conn: + conn.execute("DELETE FROM operation_log_settings WHERE profile_id=?", (profile_id,)) conn.execute( """ INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at) VALUES(?,?,?,?,?,?,?) - ON CONFLICT(user_id, profile_id) DO UPDATE SET - retention_mode=excluded.retention_mode, - retention_days=excluded.retention_days, - retention_lines=excluded.retention_lines, - updated_at=excluded.updated_at """, (user_id, profile_id, mode, days, lines, now, now), ) diff --git a/pytorrent/services/ratio_rules.py b/pytorrent/services/ratio_rules.py index e3daae7..321b371 100644 --- a/pytorrent/services/ratio_rules.py +++ b/pytorrent/services/ratio_rules.py @@ -5,7 +5,7 @@ import time from datetime import datetime, timezone from ..db import connect, utcnow, default_user_id -from . import rtorrent +from . import auth, rtorrent from .workers import enqueue @@ -67,12 +67,14 @@ def _should_apply(profile: dict, group: dict, torrent: dict) -> tuple[bool, str] def check(profile: dict, user_id: int | None = None) -> dict: - user_id = user_id or default_user_id() + viewer_user_id = user_id or default_user_id() profile_id = int(profile["id"]) with connect() as conn: - groups = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() + groups = conn.execute("SELECT * FROM ratio_groups WHERE profile_id=? AND enabled=1 ORDER BY lower(name), id", (profile_id,)).fetchall() already = {row["torrent_hash"] for row in conn.execute("SELECT torrent_hash FROM ratio_assignments WHERE profile_id=? AND last_status='applied'", (profile_id,)).fetchall()} - groups_by_name = {str(g.get("name") or ""): g for g in groups} + groups_by_name: dict[str, dict] = {} + for group in groups: + groups_by_name.setdefault(str(group.get("name") or ""), group) applied = 0 skipped = 0 queued_jobs = [] @@ -93,6 +95,11 @@ def check(profile: dict, user_id: int | None = None) -> dict: ) continue action = str(group.get("action") or "stop") + owner_user_id = int(group.get("user_id") or viewer_user_id) + if not auth.can_write_profile(profile_id, owner_user_id): + skipped += 1 + _record(owner_user_id, profile_id, group, torrent, action, "skipped", "owner has no write access to profile") + continue payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}} if action == "remove_data": api_action = "remove" @@ -105,10 +112,10 @@ def check(profile: dict, user_id: int | None = None) -> dict: payload["label"] = group.get("set_label") or group.get("name") or "" else: api_action = action if action in {"stop", "remove", "pause"} else "stop" - job_id = enqueue(api_action, profile_id, payload, user_id=user_id) + job_id = enqueue(api_action, profile_id, payload, user_id=owner_user_id) queued_jobs.append(job_id) applied += 1 - _record(user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action}) + _record(owner_user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action}) return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs} @@ -127,12 +134,15 @@ 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 ratio_groups WHERE enabled=1 AND profile_id IS NOT NULL").fetchall() + profiles = conn.execute("SELECT DISTINCT profile_id FROM ratio_groups 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_id = int(row["profile_id"]) + with connect() as conn: + owner = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone() + profile = get_profile(profile_id, int(owner["user_id"] if owner and owner.get("user_id") else default_user_id())) if not profile: continue - result = check(profile, int(row["user_id"])) + result = check(profile) if socketio and result.get("applied"): socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}") except Exception: diff --git a/pytorrent/static/js/backupTools.js b/pytorrent/static/js/backupTools.js index 50e5b96..44fbbdd 100644 --- a/pytorrent/static/js/backupTools.js +++ b/pytorrent/static/js/backupTools.js @@ -1 +1 @@ -export const backupToolsSource = " 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) · ${(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.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"; +export const backupToolsSource = " 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 Owner: ${esc(preview.owner_name||'-')} \u00b7 sensitive values hidden
${rows || '
Backup has no previewable settings.
'}
`;\n }\n function backupRows(rows=[]){\n return responsiveTable(['Name','Created','Owner','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),esc(b.owner_name||'-'),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/static/js/labelTools.js b/pytorrent/static/js/labelTools.js index 07c8787..21b18a1 100644 --- a/pytorrent/static/js/labelTools.js +++ b/pytorrent/static/js/labelTools.js @@ -1 +1 @@ -export const labelToolsSource = " async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}
`).join(''):'
No labels.Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n"; +export const labelToolsSource = " async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}Owner: ${esc(l.owner_name||'-')}
`).join(''):'
No labels.Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n"; diff --git a/pytorrent/static/js/ratioTools.js b/pytorrent/static/js/ratioTools.js index a4832ab..870b63e 100644 --- a/pytorrent/static/js/ratioTools.js +++ b/pytorrent/static/js/ratioTools.js @@ -1 +1 @@ -export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`
Groups
${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; +export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`
Groups
${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.owner_name||'-'),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; From c21a3ad944921ca55332042bd322b72bba4b0ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 7 Jun 2026 23:29:07 +0200 Subject: [PATCH 2/2] fix disk monitor preferences profile ownership --- pytorrent/db.py | 54 ++++++++++++++++++++++++- pytorrent/services/backup.py | 12 +++++- pytorrent/services/preferences.py | 40 +++++++++++++++--- pytorrent/services/workers.py | 2 +- pytorrent/static/js/diskMonitor.js | 2 +- pytorrent/static/js/preferencesTools.js | 2 +- pytorrent/static/js/runtimeState.js | 2 +- pytorrent/templates/index.html | 4 +- 8 files changed, 102 insertions(+), 16 deletions(-) diff --git a/pytorrent/db.py b/pytorrent/db.py index fe3dd95..f031c00 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -137,8 +137,8 @@ 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 TABLE IF NOT EXISTS disk_monitor_preferences ( + profile_id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, - profile_id INTEGER NOT NULL, paths_json TEXT, mode TEXT DEFAULT 'default', selected_path TEXT, @@ -146,10 +146,10 @@ CREATE TABLE IF NOT EXISTS disk_monitor_preferences ( stop_threshold INTEGER DEFAULT 98, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ); +CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id); CREATE TABLE IF NOT EXISTS labels ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -531,6 +531,55 @@ def create_schema(conn: sqlite3.Connection) -> None: conn.executescript(SCHEMA) +def ensure_profile_scoped_disk_monitor_preferences(conn: sqlite3.Connection) -> None: + """Migrate disk monitor settings from user+profile rows to one shared row per profile.""" + columns = conn.execute("PRAGMA table_info(disk_monitor_preferences)").fetchall() + pk_columns = [str(row["name"]) for row in columns if int(row.get("pk") or 0)] + if pk_columns == ["profile_id"]: + conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)") + return + + now = utcnow() + conn.execute("DROP INDEX IF EXISTS idx_disk_monitor_preferences_owner") + conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_new") + conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_old_user_profile") + conn.execute(""" + CREATE TABLE disk_monitor_preferences_new ( + profile_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + paths_json TEXT, + mode TEXT DEFAULT 'default', + selected_path TEXT, + stop_enabled INTEGER DEFAULT 0, + stop_threshold INTEGER DEFAULT 98, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id), + FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) + ) + """) + conn.execute(""" + INSERT INTO disk_monitor_preferences_new( + profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at + ) + SELECT profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold, + COALESCE(created_at, ?), COALESCE(updated_at, ?) + FROM ( + SELECT d.*, + ROW_NUMBER() OVER ( + PARTITION BY profile_id + ORDER BY COALESCE(updated_at, created_at, '') DESC, user_id ASC + ) AS rn + FROM disk_monitor_preferences d + WHERE profile_id IS NOT NULL + ) + WHERE rn=1 + """, (now, now)) + conn.execute("ALTER TABLE disk_monitor_preferences RENAME TO disk_monitor_preferences_old_user_profile") + conn.execute("ALTER TABLE disk_monitor_preferences_new RENAME TO disk_monitor_preferences") + conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)") + + def seed_default_user(conn: sqlite3.Connection) -> None: """Ensure the built-in admin user and default preferences exist.""" now = utcnow() @@ -584,6 +633,7 @@ def init_db(): except sqlite3.OperationalError: pass create_schema(conn) + ensure_profile_scoped_disk_monitor_preferences(conn) seed_default_user(conn) try: from .services.auth import ensure_admin_user diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index 4f7a6ea..5afef88 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -28,7 +28,7 @@ PROFILE_BACKUP_TABLES = [ PROFILE_TABLE_SCOPES = { "rtorrent_profiles": "profile_id", "profile_preferences": "user_profile", - "disk_monitor_preferences": "user_profile", + "disk_monitor_preferences": "profile", "labels": "profile", "ratio_groups": "profile", "rss_feeds": "profile", @@ -44,7 +44,7 @@ PROFILE_TABLE_SCOPES = { PROFILE_TABLE_FILTERS = { "rtorrent_profiles": "id=?", "profile_preferences": "user_id=? AND profile_id=?", - "disk_monitor_preferences": "user_id=? AND profile_id=?", + "disk_monitor_preferences": "profile_id=?", "labels": "profile_id=?", "ratio_groups": "profile_id=?", "rss_feeds": "profile_id=?", @@ -273,6 +273,12 @@ def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict: return {"restored": restored, "backup_type": "app"} +def _single_profile_row(rows: list[dict]) -> list[dict]: + if not rows: + return [] + return [sorted(rows, key=lambda row: str(row.get("updated_at") or row.get("created_at") or ""), reverse=True)[0]] + + def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict: clean = dict(row) if table == "rtorrent_profiles": @@ -305,6 +311,8 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int try: for table in PROFILE_BACKUP_TABLES: rows = tables.get(table) or [] + if table == "disk_monitor_preferences": + rows = _single_profile_row([dict(row) for row in rows]) where = PROFILE_TABLE_FILTERS.get(table) params = _profile_filter_params(table, user_id, int(target_profile_id)) conn.execute(f"DELETE FROM {table} WHERE {where}", params) diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 9474a72..151acce 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -296,17 +296,39 @@ def legacy_disk_monitor_preferences(user_id: int | None = None) -> dict: return _normalize_disk_monitor(row) +def _disk_monitor_owner_label(row: dict | None) -> str: + if not row: + return "" + return str(row.get("owner_display_name") or row.get("owner_username") or row.get("owner_email") or (f"user #{row.get('user_id')}" if row.get("user_id") else "")).strip() + + def get_disk_monitor_preferences(profile_id: int | None = None, user_id: int | None = None) -> dict: user_id = user_id or auth.current_user_id() or default_user_id() profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0) if not profile_id: return legacy_disk_monitor_preferences(user_id) + if not auth.can_access_profile(profile_id, user_id): + return legacy_disk_monitor_preferences(user_id) with connect() as conn: - row = conn.execute("SELECT * FROM disk_monitor_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() + row = conn.execute( + """ + SELECT d.*, u.username AS owner_username, u.display_name AS owner_display_name, u.email AS owner_email + FROM disk_monitor_preferences d + LEFT JOIN users u ON u.id=d.user_id + WHERE d.profile_id=? + """, + (profile_id,), + ).fetchone() if row: - return _normalize_disk_monitor(row) + clean = _normalize_disk_monitor(row) + clean["disk_monitor_owner_user_id"] = int(row.get("user_id") or 0) + clean["disk_monitor_owner_label"] = _disk_monitor_owner_label(row) + return clean # Backward-compatible seed: existing global disk monitor values become defaults for first use of a profile. - return legacy_disk_monitor_preferences(user_id) + clean = legacy_disk_monitor_preferences(user_id) + clean["disk_monitor_owner_user_id"] = 0 + clean["disk_monitor_owner_label"] = "" + return clean def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict: @@ -314,6 +336,8 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0) if not profile_id: return legacy_disk_monitor_preferences(user_id) + if not auth.can_write_profile(profile_id, user_id): + raise PermissionError("No write access to profile") current = get_disk_monitor_preferences(profile_id, user_id) merged = dict(current) for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"): @@ -323,10 +347,14 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i now = utcnow() with connect() as conn: conn.execute( - "INSERT INTO disk_monitor_preferences(user_id,profile_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) " - "ON CONFLICT(user_id,profile_id) DO UPDATE SET paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at", - (user_id, profile_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now), + "INSERT INTO disk_monitor_preferences(profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(profile_id) DO UPDATE SET user_id=excluded.user_id, paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at", + (profile_id, user_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now), ) + clean["disk_monitor_owner_user_id"] = int(user_id) + with connect() as conn: + row = conn.execute("SELECT display_name AS owner_display_name, username AS owner_username, email AS owner_email, id AS user_id FROM users WHERE id=?", (user_id,)).fetchone() + clean["disk_monitor_owner_label"] = _disk_monitor_owner_label(row) return clean diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index 5c262d3..5104e4a 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -247,7 +247,7 @@ def _clear_disk_refresh_cache(profile_id: int) -> None: def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None: _clear_disk_refresh_cache(profile_id) - # Note: The browser performs the fresh /api/system/disk read so user-specific disk monitor preferences stay respected. + # Note: The browser performs the fresh /api/system/disk read so profile-scoped disk monitor preferences stay respected. _emit("disk_refresh_requested", { "profile_id": int(profile_id), "hash_count": int(hash_count or 0), diff --git a/pytorrent/static/js/diskMonitor.js b/pytorrent/static/js/diskMonitor.js index 2bc43d3..288898c 100644 --- a/pytorrent/static/js/diskMonitor.js +++ b/pytorrent/static/js/diskMonitor.js @@ -1 +1 @@ -export const diskMonitorSource = " function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveEasterEggPrefs(){ easterEggEnabled=!!$('easterEggEnabled')?.checked; easterEggLoadingImageUrl=String($('easterEggLoadingImageUrl')?.value||'').trim(); easterEggClickImageUrl=String($('easterEggClickImageUrl')?.value||'').trim(); try{ const res=await post('/api/preferences',{easter_egg_enabled:easterEggEnabled,easter_egg_loading_image_url:easterEggLoadingImageUrl,easter_egg_click_image_url:easterEggClickImageUrl}); const prefs=res.preferences||{}; easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0; easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url||'').trim(); easterEggClickImageUrl=String(prefs.easter_egg_click_image_url||'').trim(); if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; applyInitialLoaderEasterEgg(); scheduleRender(true); toast('Easter egg preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }"; +export const diskMonitorSource = " function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n if($('diskMonitorOwner')) $('diskMonitorOwner').textContent=diskMonitorOwnerLabel ? `Owner: ${diskMonitorOwnerLabel}` : '';\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveEasterEggPrefs(){ easterEggEnabled=!!$('easterEggEnabled')?.checked; easterEggLoadingImageUrl=String($('easterEggLoadingImageUrl')?.value||'').trim(); easterEggClickImageUrl=String($('easterEggClickImageUrl')?.value||'').trim(); try{ const res=await post('/api/preferences',{easter_egg_enabled:easterEggEnabled,easter_egg_loading_image_url:easterEggLoadingImageUrl,easter_egg_click_image_url:easterEggClickImageUrl}); const prefs=res.preferences||{}; easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0; easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url||'').trim(); easterEggClickImageUrl=String(prefs.easter_egg_click_image_url||'').trim(); if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; applyInitialLoaderEasterEgg(); scheduleRender(true); toast('Easter egg preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||diskMonitorOwnerLabel||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }"; diff --git a/pytorrent/static/js/preferencesTools.js b/pytorrent/static/js/preferencesTools.js index 538f011..58c4b00 100644 --- a/pytorrent/static/js/preferencesTools.js +++ b/pytorrent/static/js/preferencesTools.js @@ -1 +1 @@ -export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }"; +export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }"; diff --git a/pytorrent/static/js/runtimeState.js b/pytorrent/static/js/runtimeState.js index 2c0a9ec..c6715f2 100644 --- a/pytorrent/static/js/runtimeState.js +++ b/pytorrent/static/js/runtimeState.js @@ -1 +1 @@ -export const runtimeStateSource = " let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Reverse DNS follow-up refreshes are independent from the user-selected peers auto-refresh interval.\n const REVERSE_DNS_REFRESH_SECONDS = 2;\n const REVERSE_DNS_REFRESH_MAX_ATTEMPTS = 8;\n let reverseDnsRefreshTimer = null;\n let reverseDnsRefreshInFlight = false;\n let reverseDnsRefreshAttempts = 0;\n let reverseDnsRefreshHash = null;\n let mobileReverseDnsRefreshTimer = null;\n let mobileReverseDnsRefreshAttempts = 0;\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let easterEggEnabled = Number(window.PYTORRENT?.easterEggEnabled || 0) !== 0;\n let easterEggLoadingImageUrl = String(window.PYTORRENT?.easterEggLoadingImageUrl || \"\").trim();\n let easterEggClickImageUrl = String(window.PYTORRENT?.easterEggClickImageUrl || \"\").trim();\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_PREFIX = \"pytorrent.footerStatus.v2\";\n function isActiveProfilePayload(payload={}){\n const payloadProfile = Number(payload?.profile_id || 0);\n const currentProfile = Number(activeProfileId || window.PYTORRENT?.activeProfile || 0);\n return !payloadProfile || !currentProfile || payloadProfile === currentProfile;\n }\n function currentProfileStorageId(){\n return String(activeProfileId || window.PYTORRENT?.activeProfile || \"none\");\n }\n function footerStatusStorageKey(profileId=currentProfileStorageId()){\n return `${FOOTER_STATUS_STORAGE_PREFIX}.${profileId || \"none\"}`;\n }\n function clearProfileScopedFooterState(){\n // Note: Profile changes clear footer-only values immediately so old rTorrent data is never mixed with the new profile.\n [\"statSockets\", \"statRtDownloads\", \"statRtUploads\", \"statRtHttp\", \"statRtFiles\", \"statRtPort\", \"statDl\", \"statUl\", \"mobileSpeedDl\", \"mobileSpeedUl\", \"statPeakSession\", \"statPeakAllTime\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n [\"statCpu\", \"statRam\", \"statVersion\", \"statTotalDl\", \"statTotalUl\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n lastBrowserSpeed.down = \"0 B/s\";\n lastBrowserSpeed.up = \"0 B/s\";\n updateBrowserSpeedTitle(\"0 B/s\", \"0 B/s\", 0, 0);\n }\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n"; +export const runtimeStateSource = " let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Reverse DNS follow-up refreshes are independent from the user-selected peers auto-refresh interval.\n const REVERSE_DNS_REFRESH_SECONDS = 2;\n const REVERSE_DNS_REFRESH_MAX_ATTEMPTS = 8;\n let reverseDnsRefreshTimer = null;\n let reverseDnsRefreshInFlight = false;\n let reverseDnsRefreshAttempts = 0;\n let reverseDnsRefreshHash = null;\n let mobileReverseDnsRefreshTimer = null;\n let mobileReverseDnsRefreshAttempts = 0;\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let easterEggEnabled = Number(window.PYTORRENT?.easterEggEnabled || 0) !== 0;\n let easterEggLoadingImageUrl = String(window.PYTORRENT?.easterEggLoadingImageUrl || \"\").trim();\n let easterEggClickImageUrl = String(window.PYTORRENT?.easterEggClickImageUrl || \"\").trim();\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let diskMonitorOwnerLabel = String(window.PYTORRENT?.diskMonitorOwnerLabel || \"\").trim();\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_PREFIX = \"pytorrent.footerStatus.v2\";\n function isActiveProfilePayload(payload={}){\n const payloadProfile = Number(payload?.profile_id || 0);\n const currentProfile = Number(activeProfileId || window.PYTORRENT?.activeProfile || 0);\n return !payloadProfile || !currentProfile || payloadProfile === currentProfile;\n }\n function currentProfileStorageId(){\n return String(activeProfileId || window.PYTORRENT?.activeProfile || \"none\");\n }\n function footerStatusStorageKey(profileId=currentProfileStorageId()){\n return `${FOOTER_STATUS_STORAGE_PREFIX}.${profileId || \"none\"}`;\n }\n function clearProfileScopedFooterState(){\n // Note: Profile changes clear footer-only values immediately so old rTorrent data is never mixed with the new profile.\n [\"statSockets\", \"statRtDownloads\", \"statRtUploads\", \"statRtHttp\", \"statRtFiles\", \"statRtPort\", \"statDl\", \"statUl\", \"mobileSpeedDl\", \"mobileSpeedUl\", \"statPeakSession\", \"statPeakAllTime\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n [\"statCpu\", \"statRam\", \"statVersion\", \"statTotalDl\", \"statTotalUl\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n lastBrowserSpeed.down = \"0 B/s\";\n lastBrowserSpeed.up = \"0 B/s\";\n updateBrowserSpeedTitle(\"0 B/s\", \"0 B/s\", 0, 0);\n }\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n"; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 197d7e3..6d14604 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -295,7 +295,7 @@
rTorrent profiles
Loading profiles...
Diagnostics
Run a profile test to show diagnostics.
Add profile
Create one rTorrent profile at a time. Move/remove queues keep their order for each profile.
Torrent statistics
Cached metadata summary. File metadata is refreshed every 15 minutes, a few minutes after startup, or manually.
Not loaded.
Open this tab to load statistics.
-
Appearance
Theme, typography and interface scale. Torrent view preferences also remember the selected filter, sorting and the height of the General / Files / Trackers panel.
View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.
Browser title
Controls what is shown in the browser tab.
Tracker icons
Visual helper for tracker filters in the sidebar.
Notifications
Toast notifications from automatic systems.
Disk monitor
Choose what the footer disk bar should represent and add extra storage paths.
Progress source
Monitored paths
The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.
Port checker
Incoming connection test, separate from visual preferences.
disabled
Uses YouGetSignal first. Manual check bypasses the 6h cache.
Peers
Optional peer table helpers.
Easter egg
Optional visual easter egg for loading states and occasional button clicks. Disabled by default.
Changes apply immediately where possible; initial startup loader uses them after reload.
+
Appearance
Theme, typography and interface scale. Torrent view preferences also remember the selected filter, sorting and the height of the General / Files / Trackers panel.
View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.
Browser title
Controls what is shown in the browser tab.
Tracker icons
Visual helper for tracker filters in the sidebar.
Notifications
Toast notifications from automatic systems.
Disk monitor
Choose what the footer disk bar should represent and add extra storage paths.
{% if prefs and prefs.disk_monitor_owner_label %}Owner: {{ prefs.disk_monitor_owner_label }}{% endif %}
Progress source
Monitored paths
The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.
Port checker
Incoming connection test, separate from visual preferences.
disabled
Uses YouGetSignal first. Manual check bypasses the 6h cache.
Peers
Optional peer table helpers.
Easter egg
Optional visual easter egg for loading states and occasional button clicks. Disabled by default.
Changes apply immediately where possible; initial startup loader uses them after reload.
Job scheduling
These settings are stored per active rTorrent profile. Light jobs are control actions such as start, stop, pause, resume, labels, ratio assignment, reannounce and speed limits. Heavy jobs are long or destructive actions such as move, remove and adding torrents.
Operation log retention
Manage operation log retention without changing torrent data.
Default log view
Controls the default category and job log visibility used by the Logs modal. Queued, retry, timeout and recovery events are included.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
@@ -407,7 +407,7 @@
- +