profles_and_ux #7

Merged
gru merged 10 commits from profles_and_ux into master 2026-05-27 14:38:06 +02:00
9 changed files with 471 additions and 162 deletions
Showing only changes of commit 92d870878f - Show all commits

View File

@@ -82,6 +82,24 @@ CREATE TABLE IF NOT EXISTS user_preferences (
); );
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id); CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
CREATE TABLE IF NOT EXISTS profile_preferences (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
table_columns_json TEXT,
torrent_sort_json TEXT,
active_filter TEXT DEFAULT 'all',
peers_refresh_seconds INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
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_profile_preferences_user_profile ON profile_preferences(user_id, profile_id);
CREATE TABLE IF NOT EXISTS rtorrent_profiles ( CREATE TABLE IF NOT EXISTS rtorrent_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -262,6 +280,8 @@ CREATE TABLE IF NOT EXISTS app_backups (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
backup_type TEXT DEFAULT 'app',
profile_id INTEGER,
payload_json TEXT NOT NULL, payload_json TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -426,6 +446,13 @@ CREATE TABLE IF NOT EXISTS app_settings (
value TEXT value TEXT
); );
CREATE TABLE IF NOT EXISTS poller_settings (
profile_id INTEGER PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
);
CREATE TABLE IF NOT EXISTS download_plan_settings ( CREATE TABLE IF NOT EXISTS download_plan_settings (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -624,6 +651,10 @@ MIGRATIONS = [
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)",
"CREATE TABLE IF NOT EXISTS profile_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, table_columns_json TEXT, torrent_sort_json TEXT, active_filter TEXT DEFAULT 'all', peers_refresh_seconds INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0, tracker_favicons_enabled INTEGER DEFAULT 0, reverse_dns_enabled INTEGER DEFAULT 0, 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))",
"ALTER TABLE app_backups ADD COLUMN backup_type TEXT DEFAULT 'app'",
'ALTER TABLE app_backups ADD COLUMN profile_id INTEGER',
'CREATE TABLE IF NOT EXISTS poller_settings (profile_id INTEGER PRIMARY KEY, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))',
"CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))", "CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
] ]

View File

@@ -1,21 +1,63 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import auth
def _active_profile_id() -> int | None:
profile = preferences.active_profile()
return int(profile["id"]) if profile else None
@bp.get("/backup") @bp.get("/backup")
def backup_list(): def backup_list():
return ok({"backups": backup_service.list_backups(default_user_id()), "auto": backup_service.get_auto_backup_settings(default_user_id())}) uid = default_user_id()
pid = _active_profile_id()
return ok({
"profile_backups": backup_service.list_backups(uid, "profile", pid) if pid else [],
"app_backups": backup_service.list_backups(uid, "app") if auth.is_admin() else [],
"auto": backup_service.get_auto_backup_settings(uid) if auth.is_admin() else None,
"can_app_backup": auth.is_admin(),
})
@bp.post("/backup/profile")
def backup_create_profile():
data = request.get_json(silent=True) or {}
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
return ok({
"backup": backup_service.create_profile_backup(str(data.get("name") or "Profile backup"), pid, default_user_id()),
"profile_backups": backup_service.list_backups(default_user_id(), "profile", pid),
})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/backup/app")
def backup_create_app():
data = request.get_json(silent=True) or {}
try:
return ok({
"backup": backup_service.create_app_backup(str(data.get("name") or "Application backup"), default_user_id()),
"app_backups": backup_service.list_backups(default_user_id(), "app"),
})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.post("/backup") @bp.post("/backup")
def backup_create(): def backup_create():
data = request.get_json(silent=True) or {} # Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings.
return ok({"backup": backup_service.create_backup(str(data.get("name") or "Manual backup"), default_user_id()), "backups": backup_service.list_backups(default_user_id())}) return backup_create_profile()
@bp.get("/backup/settings") @bp.get("/backup/settings")
def backup_settings_get(): def backup_settings_get():
if not auth.is_admin():
return jsonify({"ok": False, "error": "Application backup settings are admin-only"}), 403
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())}) return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())})
@@ -25,7 +67,7 @@ def backup_settings_save():
try: try:
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())}) return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())})
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.get("/backup/<int:backup_id>/preview") @bp.get("/backup/<int:backup_id>/preview")
@@ -36,14 +78,13 @@ def backup_preview(backup_id: int):
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/backup/<int:backup_id>/restore") @bp.post("/backup/<int:backup_id>/restore")
def backup_restore(backup_id: int): def backup_restore(backup_id: int):
try: try:
return ok({"result": backup_service.restore_backup(backup_id, default_user_id())}) pid = _active_profile_id()
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.delete("/backup/<int:backup_id>") @bp.delete("/backup/<int:backup_id>")
@@ -54,7 +95,6 @@ def backup_delete(backup_id: int):
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/backup/<int:backup_id>/download") @bp.get("/backup/<int:backup_id>/download")
def backup_download(backup_id: int): def backup_download(backup_id: int):
try: try:
@@ -62,8 +102,6 @@ def backup_download(backup_id: int):
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8") tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8")
json.dump(payload, tmp, ensure_ascii=False, indent=2) json.dump(payload, tmp, ensure_ascii=False, indent=2)
tmp.close() tmp.close()
return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-backup-{backup_id}.json") return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-{payload.get('backup_type') or 'backup'}-{backup_id}.json")
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400

View File

@@ -5,15 +5,39 @@ import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from . import auth
# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped. # Note: Application backups are admin-only because they include users, permissions and all profiles.
BACKUP_TABLES = [ APP_BACKUP_TABLES = [
"users", "user_profile_permissions", "user_preferences", "rtorrent_profiles", "users", "user_profile_permissions", "user_preferences", "profile_preferences", "rtorrent_profiles",
"disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules", "disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules",
"smart_queue_settings", "smart_queue_exclusions", "automation_rules", "smart_queue_settings", "smart_queue_exclusions", "automation_rules",
"rtorrent_config_overrides", "app_settings", "download_plan_settings", "rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
] ]
# Note: Profile backups contain only the active profile context and current user's profile-scoped preferences.
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",
]
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=?",
"rss_feeds": "user_id=? AND profile_id=?",
"rss_rules": "user_id=? AND profile_id=?",
"smart_queue_settings": "user_id=? AND profile_id=?",
"smart_queue_exclusions": "user_id=? AND profile_id=?",
"automation_rules": "user_id=? AND profile_id=?",
"rtorrent_config_overrides": "user_id=? AND profile_id=?",
"poller_settings": "profile_id=?",
"download_plan_settings": "user_id=? AND profile_id=?",
}
DEFAULT_AUTO_BACKUP_SETTINGS = { DEFAULT_AUTO_BACKUP_SETTINGS = {
"enabled": False, "enabled": False,
"interval_hours": 24, "interval_hours": 24,
@@ -22,44 +46,107 @@ DEFAULT_AUTO_BACKUP_SETTINGS = {
} }
BACKUP_PREVIEW_VALUE_LIMIT = 80 BACKUP_PREVIEW_VALUE_LIMIT = 80
BACKUP_PREVIEW_ROW_LIMIT = 3 BACKUP_PREVIEW_ROW_LIMIT = 3
BACKUP_PREVIEW_SENSITIVE_KEYS = { BACKUP_PREVIEW_SENSITIVE_KEYS = {"password", "password_hash", "token", "token_hash", "api_key", "secret"}
"password",
"password_hash",
"token",
"token_hash",
"api_key",
"secret",
}
AUTO_BACKUP_SETTINGS_KEY = "backup:auto" AUTO_BACKUP_SETTINGS_KEY = "backup:auto"
_scheduler_started = False _scheduler_started = False
_scheduler_lock = threading.Lock() _scheduler_lock = threading.Lock()
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict: def _is_admin_user(user_id: int | None = None) -> bool:
"""Create a settings backup and return a table-count summary. if not auth.enabled():
return True
Note: The automatic flag is metadata only; restore/download behavior remains unchanged. uid = user_id or auth.current_user_id()
""" if not uid:
user_id = user_id or default_user_id() return False
payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn: with connect() as conn:
for table in BACKUP_TABLES: row = conn.execute("SELECT role,is_active FROM users WHERE id=?", (uid,)).fetchone()
return bool(row and row.get("role") == "admin" and int(row.get("is_active") or 0))
def _require_admin(user_id: int | None = None) -> None:
if not _is_admin_user(user_id):
raise PermissionError("Application backups are available only to admins")
def _loads(value: str) -> dict:
try: try:
payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall() data = json.loads(value or "{}")
return data if isinstance(data, dict) else {}
except Exception: except Exception:
payload["tables"][table] = [] return {}
def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]:
try:
sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "")
return [dict(row) for row in conn.execute(sql, params).fetchall()]
except Exception:
return []
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( cur = conn.execute(
"INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)", "INSERT INTO app_backups(user_id,name,backup_type,profile_id,payload_json,created_at) VALUES(?,?,?,?,?,?)",
(user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]), (user_id, name or f"Backup {payload['created_at']}", backup_type, profile_id, json.dumps(payload), payload["created_at"]),
) )
backup_id = cur.lastrowid backup_id = cur.lastrowid
return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}} return {
"id": backup_id,
"name": name,
"backup_type": backup_type,
"profile_id": profile_id,
"created_at": payload["created_at"],
"automatic": bool(payload.get("automatic")),
"tables": {k: len(v) for k, v in (payload.get("tables") or {}).items()},
}
def list_backups(user_id: int | None = None) -> list[dict]: def create_app_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
user_id = user_id or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
_require_admin(user_id)
payload = {"version": 2, "backup_type": "app", "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn: with connect() as conn:
rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall() for table in APP_BACKUP_TABLES:
payload["tables"][table] = _table_rows(conn, table)
return _store_backup(user_id, name, "app", None, payload)
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
if not auth.can_access_profile(profile_id, user_id):
raise PermissionError("No access to profile")
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
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)
return _store_backup(user_id, name, "profile", int(profile_id), payload)
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
return create_app_backup(name, user_id, automatic)
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]
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))
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",
tuple(params),
).fetchall()
result = [] result = []
for row in rows: for row in rows:
payload = _loads(row.get("payload_json") or "{}") payload = _loads(row.get("payload_json") or "{}")
@@ -68,6 +155,8 @@ def list_backups(user_id: int | None = None) -> list[dict]:
"id": row.get("id"), "id": row.get("id"),
"name": row.get("name"), "name": row.get("name"),
"created_at": row.get("created_at"), "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"),
"automatic": bool(payload.get("automatic")), "automatic": bool(payload.get("automatic")),
"tables": {key: len(value or []) for key, value in tables.items()}, "tables": {key: len(value or []) for key, value in tables.items()},
}) })
@@ -75,7 +164,7 @@ def list_backups(user_id: int | None = None) -> list[dict]:
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) -> dict:
user_id = user_id or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone() row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
if not row: if not row:
@@ -83,15 +172,22 @@ def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
return json.loads(row["payload_json"] or "{}") return json.loads(row["payload_json"] or "{}")
def restore_backup(backup_id: int, user_id: int | None = None) -> dict: def _backup_type(payload: dict) -> str:
user_id = user_id or default_user_id() return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
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)
if _backup_type(payload) != "app":
raise ValueError("This is not an application backup")
tables = payload.get("tables") or {} tables = payload.get("tables") or {}
restored = {} restored = {}
with connect() as conn: with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF") conn.execute("PRAGMA foreign_keys = OFF")
try: try:
for table in BACKUP_TABLES: for table in APP_BACKUP_TABLES:
rows = tables.get(table) or [] rows = tables.get(table) or []
if not rows: if not rows:
continue continue
@@ -103,50 +199,95 @@ def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
restored[table] = len(rows) restored[table] = len(rows)
finally: finally:
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored} return {"restored": restored, "backup_type": "app"}
def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict:
clean = dict(row)
if table == "rtorrent_profiles":
clean["id"] = target_profile_id
clean["user_id"] = user_id
clean["is_default"] = int(clean.get("is_default") or 0)
return clean
if "profile_id" in clean:
clean["profile_id"] = target_profile_id
if "user_id" in clean:
clean["user_id"] = user_id
if table == "poller_settings":
clean["profile_id"] = target_profile_id
if "id" in clean and table != "rtorrent_profiles":
clean.pop("id", None)
return clean
def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int | None = None) -> dict:
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)
if _backup_type(payload) != "profile":
raise ValueError("This is not a profile backup")
tables = payload.get("tables") or {}
restored = {}
with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
try:
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))
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
if not rows:
continue
count = 0
for row in rows:
clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id))
columns = list(clean.keys())
placeholders = ",".join("?" for _ in columns)
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns])
count += 1
restored[table] = count
finally:
conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored, "backup_type": "profile", "profile_id": int(target_profile_id)}
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)
if _backup_type(payload) == "profile":
target = profile_id or payload.get("source_profile_id")
if not target:
raise ValueError("Missing target profile")
return restore_profile_backup(backup_id, int(target), user_id)
return restore_app_backup(backup_id, user_id)
def delete_backup(backup_id: int, user_id: int | None = None) -> dict: def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn: with connect() as conn:
cur = conn.execute( cur = conn.execute("DELETE FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id))
"DELETE FROM app_backups WHERE id=? AND user_id=?",
(backup_id, user_id),
)
if not cur.rowcount: if not cur.rowcount:
raise ValueError("Backup not found") raise ValueError("Backup not found")
return {"deleted": backup_id} return {"deleted": backup_id}
def _loads(value: str) -> dict:
try:
data = json.loads(value or "{}")
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _settings_row_key(user_id: int | None = None) -> str: def _settings_row_key(user_id: int | None = None) -> str:
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}" return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or auth.current_user_id() or default_user_id()}"
def _latest_backup_created_at(user_id: int) -> str | None: def _latest_backup_created_at(user_id: int) -> str | None:
"""Return the newest persisted backup timestamp for scheduler recovery after restarts.
Note: Automatic scheduling is based on the latest database backup record, so process
restarts cannot create repeated backups before the configured interval elapses.
"""
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1", "SELECT created_at FROM app_backups WHERE user_id=? AND COALESCE(backup_type,'app')='app' ORDER BY created_at DESC, id DESC LIMIT 1",
(user_id,), (user_id,),
).fetchone() ).fetchone()
return str(row["created_at"] or "") if row and row.get("created_at") else None return str(row["created_at"] or "") if row and row.get("created_at") else None
def _preview_value(value: object) -> object: def _preview_value(value: object) -> object:
"""Return a safe, compact value for backup previews without exposing secrets."""
if value is None or isinstance(value, (int, float, bool)): if value is None or isinstance(value, (int, float, bool)):
return value return value
text = str(value) text = str(value)
@@ -157,18 +298,11 @@ def _preview_row(row: dict) -> dict:
output = {} output = {}
for key, value in row.items(): for key, value in row.items():
lowered = str(key).lower() lowered = str(key).lower()
if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS): output[key] = "[hidden]" if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS) else _preview_value(value)
output[key] = "[hidden]"
else:
output[key] = _preview_value(value)
return output return output
def get_auto_backup_settings(user_id: int | None = None) -> dict: def get_auto_backup_settings(user_id: int | None = None) -> dict:
"""Return automatic backup schedule settings for the current user.
Note: The UI uses this as the single source for interval and retention controls.
"""
key = _settings_row_key(user_id) key = _settings_row_key(user_id)
with connect() as conn: with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone() row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
@@ -180,10 +314,7 @@ def get_auto_backup_settings(user_id: int | None = None) -> dict:
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict: def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
"""Persist automatic backup schedule settings after validating UI input. _require_admin(user_id)
Note: Minimum interval is one hour to avoid creating excessive database rows.
"""
current = get_auto_backup_settings(user_id) current = get_auto_backup_settings(user_id)
settings = { settings = {
**current, **current,
@@ -199,15 +330,13 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
def preview_backup(backup_id: int, user_id: int | None = None) -> dict: def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
"""Return a compact backup preview without exposing the full JSON payload in the list view.
Note: The preview shows included tables and example keys so users can verify settings coverage.
"""
payload = payload_for_backup(backup_id, user_id) payload = payload_for_backup(backup_id, user_id)
tables = payload.get("tables") or {} tables = payload.get("tables") or {}
return { return {
"version": payload.get("version"), "version": payload.get("version"),
"created_at": payload.get("created_at"), "created_at": payload.get("created_at"),
"backup_type": _backup_type(payload),
"source_profile_id": payload.get("source_profile_id"),
"automatic": bool(payload.get("automatic")), "automatic": bool(payload.get("automatic")),
"tables": [ "tables": [
{ {
@@ -222,23 +351,17 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int: def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int:
"""Delete backups older than the configured retention window for the selected user. user_id = user_id or auth.current_user_id() or default_user_id()
Note: Retention is applied only to backup records, not to restored application settings.
"""
user_id = user_id or default_user_id()
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds") cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
with connect() as conn: with connect() as conn:
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at<?", (user_id, cutoff)) cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND COALESCE(backup_type,'app')='app' AND created_at<?", (user_id, cutoff))
return int(cur.rowcount or 0) return int(cur.rowcount or 0)
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None: def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
"""Create an automatic backup when the saved interval has elapsed.
Note: The scheduler calls this periodically, while the UI controls the interval and retention values.
"""
user_id = user_id or default_user_id() user_id = user_id or default_user_id()
if not _is_admin_user(user_id):
return None
settings = get_auto_backup_settings(user_id) settings = get_auto_backup_settings(user_id)
if not settings.get("enabled"): if not settings.get("enabled"):
return None return None
@@ -253,7 +376,7 @@ def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
settings["last_run_at"] = last_value settings["last_run_at"] = last_value
save_auto_backup_settings(settings, user_id) save_auto_backup_settings(settings, user_id)
return None return None
backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds") settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds")
save_auto_backup_settings(settings, user_id) save_auto_backup_settings(settings, user_id)
prune_old_backups(user_id, settings["retention_days"]) prune_old_backups(user_id, settings["retention_days"])
@@ -261,10 +384,6 @@ def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
def start_scheduler() -> None: def start_scheduler() -> None:
"""Start a lightweight automatic-backup scheduler.
Note: It scans configured users and never blocks normal request handling.
"""
global _scheduler_started global _scheduler_started
with _scheduler_lock: with _scheduler_lock:
if _scheduler_started: if _scheduler_started:
@@ -275,7 +394,7 @@ def start_scheduler() -> None:
while True: while True:
try: try:
with connect() as conn: with connect() as conn:
rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall() rows = conn.execute("SELECT id FROM users WHERE is_active=1 AND role='admin'").fetchall()
user_ids = [int(row["id"]) for row in rows] or [default_user_id()] user_ids = [int(row["id"]) for row in rows] or [default_user_id()]
for uid in user_ids: for uid in user_ids:
maybe_create_automatic_backup(uid) maybe_create_automatic_backup(uid)

View File

@@ -73,9 +73,19 @@ def normalize_settings(data: dict | None) -> dict:
def get_settings(profile_id: int) -> dict: def get_settings(profile_id: int) -> dict:
with connect() as conn: with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone() row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
if not row:
# Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read.
legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
if legacy:
try: try:
data = json.loads(row.get("value") or "{}") if row else {} settings = normalize_settings(json.loads(legacy.get("value") or "{}"))
except Exception:
settings = normalize_settings({})
conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
return settings
try:
data = json.loads(row.get("settings_json") or "{}") if row else {}
except Exception: except Exception:
data = {} data = {}
return normalize_settings(data) return normalize_settings(data)
@@ -84,7 +94,7 @@ def get_settings(profile_id: int) -> dict:
def save_settings(profile_id: int, data: dict) -> dict: def save_settings(profile_id: int, data: dict) -> dict:
settings = normalize_settings(data) settings = normalize_settings(data)
with connect() as conn: with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings))) conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
return settings return settings

View File

@@ -58,17 +58,21 @@ def recommended_table_columns_json() -> str:
return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":")) return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":"))
def apply_recommended_table_columns(user_id: int | None = None): def apply_recommended_table_columns(user_id: int | None = None, profile_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
get_preferences(user_id) profile_id = profile_id or _active_profile_id_for_user(user_id)
if not profile_id:
return get_preferences(user_id)
get_preferences(user_id, profile_id)
now = utcnow() now = utcnow()
value = recommended_table_columns_json() value = recommended_table_columns_json()
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
"UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", "INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,created_at,updated_at) VALUES(?,?,?,?,?) "
(value, now, user_id), "ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, updated_at=excluded.updated_at",
(user_id, profile_id, value, now, now),
) )
return get_preferences(user_id) return get_preferences(user_id, profile_id)
def bootstrap_css_url(theme: str | None) -> str: def bootstrap_css_url(theme: str | None) -> str:
from .frontend_assets import bootstrap_css_path from .frontend_assets import bootstrap_css_path
@@ -317,8 +321,117 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
return clean return clean
PROFILE_PREFERENCE_COLUMNS = {
"table_columns_json",
"torrent_sort_json",
"active_filter",
"peers_refresh_seconds",
"port_check_enabled",
"tracker_favicons_enabled",
"reverse_dns_enabled",
}
def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
now = utcnow()
legacy = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {}
row = conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
if row:
return dict(row)
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
conn.execute(
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(
user_id,
profile_id,
legacy.get("table_columns_json"),
legacy.get("torrent_sort_json"),
legacy.get("active_filter") or "all",
int(legacy.get("peers_refresh_seconds") or 0),
int(legacy.get("port_check_enabled") or 0),
int(legacy.get("tracker_favicons_enabled") or 0),
int(legacy.get("reverse_dns_enabled") or 0),
now,
now,
),
)
return dict(conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() or {})
def get_profile_preferences(user_id: int, profile_id: int | None) -> dict:
if not profile_id:
return {}
with connect() as conn:
return _seed_profile_preferences(conn, user_id, int(profile_id))
def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -> None:
if not profile_id:
return
profile_id = int(profile_id)
now = utcnow()
with connect() as conn:
current = _seed_profile_preferences(conn, user_id, profile_id)
updates: dict[str, object] = {}
if data.get("table_columns_json") is not None:
updates["table_columns_json"] = str(data.get("table_columns_json"))
if data.get("peers_refresh_seconds") is not None:
sec = int(data.get("peers_refresh_seconds") or 0)
updates["peers_refresh_seconds"] = sec if sec in {0, 10, 15, 30, 60} else 0
if data.get("port_check_enabled") is not None:
updates["port_check_enabled"] = 1 if data.get("port_check_enabled") else 0
if data.get("tracker_favicons_enabled") is not None:
updates["tracker_favicons_enabled"] = 1 if data.get("tracker_favicons_enabled") else 0
if data.get("reverse_dns_enabled") is not None:
# Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency.
updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0
if data.get("torrent_sort_json") is not None:
value = data.get("torrent_sort_json") if isinstance(data.get("torrent_sort_json"), str) else json.dumps(data.get("torrent_sort_json"))
parsed = json.loads(value or "{}")
if not isinstance(parsed, dict):
parsed = {}
try:
direction = int(parsed.get("dir") or 1)
except (TypeError, ValueError):
direction = 1
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
sort_key = str(parsed.get("key") or "name")
if sort_key not in allowed_sort_keys:
sort_key = "name"
updates["torrent_sort_json"] = json.dumps({"key": sort_key, "dir": 1 if direction >= 0 else -1})
if data.get("active_filter") is not None:
value = str(data.get("active_filter") or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
updates["active_filter"] = value
if not updates:
return
merged = {**current, **updates}
conn.execute(
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, torrent_sort_json=excluded.torrent_sort_json, active_filter=excluded.active_filter, peers_refresh_seconds=excluded.peers_refresh_seconds, port_check_enabled=excluded.port_check_enabled, tracker_favicons_enabled=excluded.tracker_favicons_enabled, reverse_dns_enabled=excluded.reverse_dns_enabled, updated_at=excluded.updated_at",
(
user_id,
profile_id,
merged.get("table_columns_json"),
merged.get("torrent_sort_json"),
merged.get("active_filter") or "all",
int(merged.get("peers_refresh_seconds") or 0),
int(merged.get("port_check_enabled") or 0),
int(merged.get("tracker_favicons_enabled") or 0),
int(merged.get("reverse_dns_enabled") or 0),
merged.get("created_at") or now,
now,
),
)
def get_preferences(user_id: int | None = None, profile_id: int | None = None): def get_preferences(user_id: int | None = None, profile_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
profile_id = profile_id or _active_profile_id_for_user(user_id)
with connect() as conn: with connect() as conn:
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
if not pref: if not pref:
@@ -326,22 +439,19 @@ def get_preferences(user_id: int | None = None, profile_id: int | None = None):
conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now)) conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now))
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
merged = dict(pref or {}) merged = dict(pref or {})
if profile_id:
merged.update(_seed_profile_preferences(conn, user_id, int(profile_id)))
merged.update(get_disk_monitor_preferences(profile_id, user_id)) merged.update(get_disk_monitor_preferences(profile_id, user_id))
return merged return merged
def save_preferences(data: dict, user_id: int | None = None): def save_preferences(data: dict, user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
profile_id = _active_profile_id_for_user(user_id)
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
table_columns_json = data.get("table_columns_json")
peers_refresh_seconds = data.get("peers_refresh_seconds")
port_check_enabled = data.get("port_check_enabled")
footer_items_json = data.get("footer_items_json") footer_items_json = data.get("footer_items_json")
title_speed_enabled = data.get("title_speed_enabled") title_speed_enabled = data.get("title_speed_enabled")
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
reverse_dns_enabled = data.get("reverse_dns_enabled")
automation_toasts_enabled = data.get("automation_toasts_enabled") automation_toasts_enabled = data.get("automation_toasts_enabled")
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled") smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
disk_monitor_paths_json = data.get("disk_monitor_paths_json") disk_monitor_paths_json = data.get("disk_monitor_paths_json")
@@ -352,8 +462,6 @@ def save_preferences(data: dict, user_id: int | None = None):
interface_scale = data.get("interface_scale") interface_scale = data.get("interface_scale")
compact_torrent_list_enabled = data.get("compact_torrent_list_enabled") compact_torrent_list_enabled = data.get("compact_torrent_list_enabled")
detail_panel_height = data.get("detail_panel_height") detail_panel_height = data.get("detail_panel_height")
torrent_sort_json = data.get("torrent_sort_json")
active_filter = data.get("active_filter")
disk_payload = None disk_payload = None
if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)): if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)):
disk_payload = { disk_payload = {
@@ -371,21 +479,8 @@ def save_preferences(data: dict, user_id: int | None = None):
conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id)) conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id))
if font_family: if font_family:
conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id)) conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id))
if table_columns_json is not None:
conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id))
if peers_refresh_seconds is not None:
sec = int(peers_refresh_seconds or 0)
if sec not in {0, 10, 15, 30, 60}: sec = 0
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
if port_check_enabled is not None:
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
if title_speed_enabled is not None: if title_speed_enabled is not None:
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
if tracker_favicons_enabled is not None:
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
if reverse_dns_enabled is not None:
# Note: Reverse DNS is optional because peer PTR lookups can add latency on busy swarms.
conn.execute("UPDATE user_preferences SET reverse_dns_enabled=?, updated_at=? WHERE user_id=?", (1 if reverse_dns_enabled else 0, now, user_id))
if automation_toasts_enabled is not None: if automation_toasts_enabled is not None:
# Note: Lets users silence automation-created toast noise without hiding job/history data. # Note: Lets users silence automation-created toast noise without hiding job/history data.
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
@@ -415,30 +510,7 @@ def save_preferences(data: dict, user_id: int | None = None):
if height < 160: height = 160 if height < 160: height = 160
if height > 720: height = 720 if height > 720: height = 720
conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id)) conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id))
if torrent_sort_json is not None: save_profile_preferences(user_id, profile_id, data)
# Note: Persist only a compact sort object; unknown keys are ignored on the client.
value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json)
parsed = json.loads(value or "{}")
if not isinstance(parsed, dict):
parsed = {}
try:
direction = int(parsed.get("dir") or 1)
except (TypeError, ValueError):
direction = 1
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
sort_key = str(parsed.get("key") or "name")
if sort_key not in allowed_sort_keys:
sort_key = "name"
clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1}
conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id))
if active_filter is not None:
value = str(active_filter or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id))
if disk_payload is not None: if disk_payload is not None:
save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id) save_disk_monitor_preferences(profile_id, disk_payload, user_id)
return get_preferences(user_id) return get_preferences(user_id, profile_id)

File diff suppressed because one or more lines are too long

View File

@@ -1 +1,33 @@
export const rssSource = " async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[]; if($('rssManager')) $('rssManager').innerHTML=`<h6>Feeds</h6>${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),`<button class=\"btn btn-xs btn-outline-primary rss-edit-feed\" data-feed='${esc(JSON.stringify(f))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-feed\" data-id=\"${esc(f.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">Rules</h6>${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),`<button class=\"btn btn-xs btn-outline-primary rss-edit-rule\" data-rule='${esc(JSON.stringify(r))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-rule\" data-id=\"${esc(r.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">RSS log</h6>${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`; }\n \n\n function fillBackupSettings(settings={}){\n if($('backupAutoEnabled')) $('backupAutoEnabled').checked=!!settings.enabled;\n if($('backupAutoInterval')) $('backupAutoInterval').value=settings.interval_hours||24;\n if($('backupRetentionDays')) $('backupRetentionDays').value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '<div class=\"backup-preview-empty\">No saved rows in this table.</div>';\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=>`<details class=\"backup-preview-table-details\"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n return `<div class=\"surface-section backup-preview-card\"><div class=\"section-title\"><i class=\"fa-solid fa-eye\"></i> Backup preview</div><div class=\"small text-muted mb-2\">Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n const rows=j.backups||[];\n fillBackupSettings(j.auto||{});\n if($('backupManager')) $('backupManager').innerHTML=responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`<div class=\"table-action-group backup-actions\"><button class=\"btn btn-xs btn-outline-info backup-preview-btn\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-eye\"></i> Preview</button><a class=\"btn btn-xs btn-outline-secondary\" href=\"/api/backup/${esc(b.id)}/download\"><i class=\"fa-solid fa-download\"></i> Download</a><button class=\"btn btn-xs btn-outline-warning backup-restore\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-rotate-left\"></i> Restore</button><button class=\"btn btn-xs btn-outline-danger backup-delete\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button></div>`]),'backup-table');\n }\n\n"; export const rssSource = " async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[]; if($('rssManager')) $('rssManager').innerHTML=`<h6>Feeds</h6>${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),`<button class=\"btn btn-xs btn-outline-primary rss-edit-feed\" data-feed='${esc(JSON.stringify(f))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-feed\" data-id=\"${esc(f.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">Rules</h6>${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),`<button class=\"btn btn-xs btn-outline-primary rss-edit-rule\" data-rule='${esc(JSON.stringify(r))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-rule\" data-id=\"${esc(r.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">RSS log</h6>${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`; }\n \n\n function fillBackupSettings(settings={}){
if($('backupAutoEnabled')) $('backupAutoEnabled').checked=!!settings.enabled;
if($('backupAutoInterval')) $('backupAutoInterval').value=settings.interval_hours||24;
if($('backupRetentionDays')) $('backupRetentionDays').value=settings.retention_days||30;
}
function backupPreviewDetails(table={}){
const sample=table.sample||[];
if(!sample.length) return '<div class="backup-preview-empty">No saved rows in this table.</div>';
const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);
return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');
}
function backupPreviewTable(preview={}){
const tables=preview.tables||[];
const rows=tables.map(t=>`<details class="backup-preview-table-details"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) · ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');
const type=preview.backup_type==='app'?'application':'profile';
return `<div class="surface-section backup-preview-card"><div class="section-title"><i class="fa-solid fa-eye"></i> Backup preview</div><div class="small text-muted mb-2">${esc(type)} backup · Created: ${esc(preview.created_at||'-')} · ${preview.automatic?'automatic':'manual'} · sensitive values hidden</div>${rows || '<div class="empty-mini">Backup has no previewable settings.</div>'}</div>`;
}
function backupRows(rows=[]){
return responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`<div class="table-action-group backup-actions"><button class="btn btn-xs btn-outline-info backup-preview-btn" data-id="${esc(b.id)}"><i class="fa-solid fa-eye"></i> Preview</button><a class="btn btn-xs btn-outline-secondary" href="/api/backup/${esc(b.id)}/download"><i class="fa-solid fa-download"></i> Download</a><button class="btn btn-xs btn-outline-warning backup-restore" data-id="${esc(b.id)}" data-type="${esc(b.backup_type||'profile')}"><i class="fa-solid fa-rotate-left"></i> Restore</button><button class="btn btn-xs btn-outline-danger backup-delete" data-id="${esc(b.id)}"><i class="fa-solid fa-trash-can"></i> Delete</button></div>`]),'backup-table');
}
function switchBackupPane(pane){
document.querySelectorAll('[data-backup-pane]').forEach(x=>x.classList.toggle('active',x.dataset.backupPane===pane));
document.querySelectorAll('[data-backup-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.backupPanel!==pane));
}
async function loadBackup(){
const j=await (await fetch('/api/backup')).json();
fillBackupSettings(j.auto||{});
if($('profileBackupManager')) $('profileBackupManager').innerHTML=backupRows(j.profile_backups||[]);
if($('appBackupManager')) $('appBackupManager').innerHTML=j.can_app_backup ? backupRows(j.app_backups||[]) : '<div class="empty-mini">Application backups are admin-only.</div>';
if(!j.can_app_backup) document.querySelector('[data-backup-pane="app"]')?.classList.add('disabled');
}
\n\n";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long