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";