User permissions and profiles #22
+58
-2
@@ -137,8 +137,8 @@ CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
|
CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
profile_id INTEGER NOT NULL,
|
|
||||||
paths_json TEXT,
|
paths_json TEXT,
|
||||||
mode TEXT DEFAULT 'default',
|
mode TEXT DEFAULT 'default',
|
||||||
selected_path TEXT,
|
selected_path TEXT,
|
||||||
@@ -146,10 +146,10 @@ CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
|
|||||||
stop_threshold INTEGER DEFAULT 98,
|
stop_threshold INTEGER DEFAULT 98,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id),
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||||
);
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS labels (
|
CREATE TABLE IF NOT EXISTS labels (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -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_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_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_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 (
|
CREATE TABLE IF NOT EXISTS app_backups (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -273,6 +275,8 @@ CREATE TABLE IF NOT EXISTS app_backups (
|
|||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
created_at 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 (
|
CREATE TABLE IF NOT EXISTS smart_queue_settings (
|
||||||
profile_id INTEGER NOT NULL,
|
profile_id INTEGER NOT NULL,
|
||||||
@@ -450,6 +454,7 @@ CREATE TABLE IF NOT EXISTS download_plan_settings (
|
|||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS download_plan_paused (
|
||||||
profile_id INTEGER NOT NULL,
|
profile_id INTEGER NOT NULL,
|
||||||
@@ -508,6 +513,7 @@ CREATE TABLE IF NOT EXISTS operation_log_settings (
|
|||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
||||||
domain TEXT PRIMARY KEY,
|
domain TEXT PRIMARY KEY,
|
||||||
source_url TEXT,
|
source_url TEXT,
|
||||||
@@ -525,6 +531,55 @@ def create_schema(conn: sqlite3.Connection) -> None:
|
|||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_profile_scoped_disk_monitor_preferences(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Migrate disk monitor settings from user+profile rows to one shared row per profile."""
|
||||||
|
columns = conn.execute("PRAGMA table_info(disk_monitor_preferences)").fetchall()
|
||||||
|
pk_columns = [str(row["name"]) for row in columns if int(row.get("pk") or 0)]
|
||||||
|
if pk_columns == ["profile_id"]:
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
conn.execute("DROP INDEX IF EXISTS idx_disk_monitor_preferences_owner")
|
||||||
|
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_new")
|
||||||
|
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_old_user_profile")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE disk_monitor_preferences_new (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
paths_json TEXT,
|
||||||
|
mode TEXT DEFAULT 'default',
|
||||||
|
selected_path TEXT,
|
||||||
|
stop_enabled INTEGER DEFAULT 0,
|
||||||
|
stop_threshold INTEGER DEFAULT 98,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO disk_monitor_preferences_new(
|
||||||
|
profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at
|
||||||
|
)
|
||||||
|
SELECT profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,
|
||||||
|
COALESCE(created_at, ?), COALESCE(updated_at, ?)
|
||||||
|
FROM (
|
||||||
|
SELECT d.*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY profile_id
|
||||||
|
ORDER BY COALESCE(updated_at, created_at, '') DESC, user_id ASC
|
||||||
|
) AS rn
|
||||||
|
FROM disk_monitor_preferences d
|
||||||
|
WHERE profile_id IS NOT NULL
|
||||||
|
)
|
||||||
|
WHERE rn=1
|
||||||
|
""", (now, now))
|
||||||
|
conn.execute("ALTER TABLE disk_monitor_preferences RENAME TO disk_monitor_preferences_old_user_profile")
|
||||||
|
conn.execute("ALTER TABLE disk_monitor_preferences_new RENAME TO disk_monitor_preferences")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
||||||
|
|
||||||
|
|
||||||
def seed_default_user(conn: sqlite3.Connection) -> None:
|
def seed_default_user(conn: sqlite3.Connection) -> None:
|
||||||
"""Ensure the built-in admin user and default preferences exist."""
|
"""Ensure the built-in admin user and default preferences exist."""
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
@@ -578,6 +633,7 @@ def init_db():
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
create_schema(conn)
|
create_schema(conn)
|
||||||
|
ensure_profile_scoped_disk_monitor_preferences(conn)
|
||||||
seed_default_user(conn)
|
seed_default_user(conn)
|
||||||
try:
|
try:
|
||||||
from .services.auth import ensure_admin_user
|
from .services.auth import ensure_admin_user
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ def operation_logs_settings_save():
|
|||||||
profile = _active_profile_or_400()
|
profile = _active_profile_or_400()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
|
try:
|
||||||
result = operation_logs.apply_retention(int(profile["id"]))
|
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
|
||||||
return ok({"settings": settings, "retention": result})
|
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")
|
@bp.post("/operation-logs/clear")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
from ..services.rtorrent.diagnostics import profile_diagnostics
|
from ..services.rtorrent.diagnostics import profile_diagnostics
|
||||||
|
from ..services import auth
|
||||||
|
|
||||||
@bp.get("/profiles")
|
@bp.get("/profiles")
|
||||||
def profiles_list():
|
def profiles_list():
|
||||||
@@ -108,8 +109,19 @@ def prefs_table_columns_recommended():
|
|||||||
def labels_list():
|
def labels_list():
|
||||||
profile = preferences.active_profile()
|
profile = preferences.active_profile()
|
||||||
pid = profile["id"] if profile else None
|
pid = profile["id"] if profile else None
|
||||||
|
if not pid:
|
||||||
|
return ok({"labels": []})
|
||||||
with connect() as conn:
|
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})
|
return ok({"labels": rows})
|
||||||
|
|
||||||
|
|
||||||
@@ -123,9 +135,15 @@ def labels_save():
|
|||||||
name = str(data.get("name") or "").strip()
|
name = str(data.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({"ok": False, "error": "Missing label name"}), 400
|
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()
|
now = utcnow()
|
||||||
with connect() as conn:
|
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()
|
return labels_list()
|
||||||
|
|
||||||
|
|
||||||
@@ -134,8 +152,10 @@ def labels_save():
|
|||||||
def labels_delete(label_id: int):
|
def labels_delete(label_id: int):
|
||||||
profile = preferences.active_profile()
|
profile = preferences.active_profile()
|
||||||
pid = profile["id"] if profile else None
|
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:
|
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()
|
return labels_list()
|
||||||
|
|
||||||
|
|
||||||
@@ -145,8 +165,17 @@ def ratio_groups_list():
|
|||||||
profile = preferences.active_profile()
|
profile = preferences.active_profile()
|
||||||
pid = profile["id"] if profile else None
|
pid = profile["id"] if profile else None
|
||||||
with connect() as conn:
|
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()
|
rows = conn.execute(
|
||||||
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 []
|
"""
|
||||||
|
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})
|
return ok({"groups": rows, "history": history})
|
||||||
|
|
||||||
|
|
||||||
@@ -160,14 +189,23 @@ def ratio_groups_save():
|
|||||||
name = str(data.get("name") or "").strip()
|
name = str(data.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({"ok": False, "error": "Missing group name"}), 400
|
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()
|
now = utcnow()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
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()
|
||||||
"""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 = (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)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
if existing:
|
||||||
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""",
|
conn.execute(
|
||||||
(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),
|
"""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()
|
return ratio_groups_list()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+149
-39
@@ -15,27 +15,46 @@ APP_BACKUP_TABLES = [
|
|||||||
"rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
|
"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 = [
|
PROFILE_BACKUP_TABLES = [
|
||||||
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
|
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
|
||||||
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
|
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
|
||||||
"automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings",
|
"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": "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 = {
|
PROFILE_TABLE_FILTERS = {
|
||||||
"rtorrent_profiles": "id=?",
|
"rtorrent_profiles": "id=?",
|
||||||
"profile_preferences": "user_id=? AND profile_id=?",
|
"profile_preferences": "user_id=? AND profile_id=?",
|
||||||
"disk_monitor_preferences": "user_id=? AND profile_id=?",
|
"disk_monitor_preferences": "profile_id=?",
|
||||||
"labels": "user_id=? AND profile_id=?",
|
"labels": "profile_id=?",
|
||||||
"ratio_groups": "user_id=? AND profile_id=?",
|
"ratio_groups": "profile_id=?",
|
||||||
"rss_feeds": "profile_id=?",
|
"rss_feeds": "profile_id=?",
|
||||||
"rss_rules": "profile_id=?",
|
"rss_rules": "profile_id=?",
|
||||||
"smart_queue_settings": "profile_id=?",
|
"smart_queue_settings": "profile_id=?",
|
||||||
"smart_queue_exclusions": "profile_id=?",
|
"smart_queue_exclusions": "profile_id=?",
|
||||||
"automation_rules": "user_id=? AND profile_id=?",
|
"automation_rules": "profile_id=?",
|
||||||
"rtorrent_config_overrides": "profile_id=?",
|
"rtorrent_config_overrides": "profile_id=?",
|
||||||
"poller_settings": "profile_id=?",
|
"poller_settings": "profile_id=?",
|
||||||
"download_plan_settings": "user_id=? AND profile_id=?",
|
"download_plan_settings": "profile_id=?",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||||
@@ -91,6 +110,41 @@ def _table_rows(conn, table: str, where: str | None = None, params: tuple = ())
|
|||||||
return []
|
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:
|
def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
cur = conn.execute(
|
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:
|
with connect() as conn:
|
||||||
for table in PROFILE_BACKUP_TABLES:
|
for table in PROFILE_BACKUP_TABLES:
|
||||||
where = PROFILE_TABLE_FILTERS.get(table)
|
where = PROFILE_TABLE_FILTERS.get(table)
|
||||||
if where == "id=?" or where == "profile_id=?":
|
payload["tables"][table] = _table_rows(conn, table, where, _profile_filter_params(table, user_id, int(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)
|
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]:
|
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()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
clauses = ["user_id=?"]
|
clauses: list[str] = []
|
||||||
params: list[object] = [user_id]
|
params: list[object] = []
|
||||||
if backup_type:
|
if backup_type:
|
||||||
clauses.append("COALESCE(backup_type,'app')=?")
|
clauses.append("COALESCE(backup_type,'app')=?")
|
||||||
params.append(backup_type)
|
params.append(backup_type)
|
||||||
if profile_id is not None:
|
if profile_id is not None:
|
||||||
clauses.append("profile_id=?")
|
clauses.append("profile_id=?")
|
||||||
params.append(int(profile_id))
|
params.append(int(profile_id))
|
||||||
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
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),
|
tuple(params),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
result = []
|
result = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
if not _backup_row_visible(row, user_id):
|
||||||
|
continue
|
||||||
payload = _loads(row.get("payload_json") or "{}")
|
payload = _loads(row.get("payload_json") or "{}")
|
||||||
tables = payload.get("tables") 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({
|
result.append({
|
||||||
"id": row.get("id"),
|
"id": row.get("id"),
|
||||||
"name": row.get("name"),
|
"name": row.get("name"),
|
||||||
|
"owner_user_id": row.get("user_id"),
|
||||||
|
"owner_name": owner_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",
|
"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"),
|
"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
|
return result
|
||||||
|
|
||||||
|
def payload_for_backup(backup_id: int, user_id: int | None = None, require_write: bool = False) -> dict:
|
||||||
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
|
|
||||||
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()
|
||||||
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 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:
|
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")
|
raise ValueError("Backup not found")
|
||||||
return json.loads(row["payload_json"] or "{}")
|
return json.loads(row["payload_json"] or "{}")
|
||||||
|
|
||||||
|
|
||||||
def _backup_type(payload: dict) -> str:
|
def _backup_type(payload: dict) -> str:
|
||||||
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
|
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:
|
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()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
_require_admin(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":
|
if _backup_type(payload) != "app":
|
||||||
raise ValueError("This is not an application backup")
|
raise ValueError("This is not an application backup")
|
||||||
tables = payload.get("tables") or {}
|
tables = payload.get("tables") or {}
|
||||||
@@ -212,6 +273,12 @@ def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
|
|||||||
return {"restored": restored, "backup_type": "app"}
|
return {"restored": restored, "backup_type": "app"}
|
||||||
|
|
||||||
|
|
||||||
|
def _single_profile_row(rows: list[dict]) -> list[dict]:
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
return [sorted(rows, key=lambda row: str(row.get("updated_at") or row.get("created_at") or ""), reverse=True)[0]]
|
||||||
|
|
||||||
|
|
||||||
def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict:
|
def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict:
|
||||||
clean = dict(row)
|
clean = dict(row)
|
||||||
if table == "rtorrent_profiles":
|
if table == "rtorrent_profiles":
|
||||||
@@ -234,7 +301,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()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
if not auth.can_write_profile(target_profile_id, user_id):
|
if not auth.can_write_profile(target_profile_id, user_id):
|
||||||
raise PermissionError("No write access to profile")
|
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":
|
if _backup_type(payload) != "profile":
|
||||||
raise ValueError("This is not a profile backup")
|
raise ValueError("This is not a profile backup")
|
||||||
tables = payload.get("tables") or {}
|
tables = payload.get("tables") or {}
|
||||||
@@ -244,11 +311,10 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
|
|||||||
try:
|
try:
|
||||||
for table in PROFILE_BACKUP_TABLES:
|
for table in PROFILE_BACKUP_TABLES:
|
||||||
rows = tables.get(table) or []
|
rows = tables.get(table) or []
|
||||||
|
if table == "disk_monitor_preferences":
|
||||||
|
rows = _single_profile_row([dict(row) for row in rows])
|
||||||
where = PROFILE_TABLE_FILTERS.get(table)
|
where = PROFILE_TABLE_FILTERS.get(table)
|
||||||
if where == "id=?" or where == "profile_id=?":
|
params = _profile_filter_params(table, user_id, int(target_profile_id))
|
||||||
params = (int(target_profile_id),)
|
|
||||||
else:
|
|
||||||
params = (user_id, int(target_profile_id))
|
|
||||||
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
|
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
@@ -269,7 +335,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:
|
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":
|
if _backup_type(payload) == "profile":
|
||||||
target = profile_id or payload.get("source_profile_id")
|
target = profile_id or payload.get("source_profile_id")
|
||||||
if not target:
|
if not target:
|
||||||
@@ -281,26 +347,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:
|
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()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
with connect() as conn:
|
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:
|
if not cur.rowcount:
|
||||||
raise ValueError("Backup not found")
|
raise ValueError("Backup not found")
|
||||||
return {"deleted": backup_id}
|
return {"deleted": backup_id}
|
||||||
|
|
||||||
|
|
||||||
def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str:
|
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()
|
uid = user_id or auth.current_user_id() or default_user_id()
|
||||||
scope = "profile" if backup_type == "profile" else "app"
|
scope = "profile" if backup_type == "profile" else "app"
|
||||||
if scope == "profile":
|
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}"
|
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:
|
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')=?"]
|
clauses = ["COALESCE(backup_type,'app')=?"]
|
||||||
params: list[object] = [user_id, backup_type]
|
params: list[object] = [backup_type]
|
||||||
if backup_type == "profile":
|
if backup_type == "profile":
|
||||||
clauses.append("profile_id=?")
|
clauses.append("profile_id=?")
|
||||||
params.append(int(profile_id or 0))
|
params.append(int(profile_id or 0))
|
||||||
|
else:
|
||||||
|
clauses.append("user_id=?")
|
||||||
|
params.append(user_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
|
f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||||
@@ -308,7 +378,6 @@ def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_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:
|
||||||
if value is None or isinstance(value, (int, float, bool)):
|
if value is None or isinstance(value, (int, float, bool)):
|
||||||
return value
|
return value
|
||||||
@@ -325,9 +394,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:
|
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)
|
key = _settings_row_key(user_id, backup_type, profile_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()
|
||||||
|
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 = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")}
|
||||||
settings["enabled"] = bool(settings.get("enabled"))
|
settings["enabled"] = bool(settings.get("enabled"))
|
||||||
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
|
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
|
||||||
@@ -335,6 +408,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"
|
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
|
||||||
if backup_type == "profile":
|
if backup_type == "profile":
|
||||||
settings["profile_id"] = int(profile_id or 0)
|
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
|
return settings
|
||||||
|
|
||||||
|
|
||||||
@@ -361,11 +437,28 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_typ
|
|||||||
return settings
|
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:
|
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||||
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 {}
|
||||||
|
owner = _backup_owner_info(backup_id)
|
||||||
return {
|
return {
|
||||||
"version": payload.get("version"),
|
"version": payload.get("version"),
|
||||||
|
"owner_user_id": owner.get("owner_user_id"),
|
||||||
|
"owner_name": owner.get("owner_name"),
|
||||||
"created_at": payload.get("created_at"),
|
"created_at": payload.get("created_at"),
|
||||||
"backup_type": _backup_type(payload),
|
"backup_type": _backup_type(payload),
|
||||||
"source_profile_id": payload.get("source_profile_id"),
|
"source_profile_id": payload.get("source_profile_id"),
|
||||||
@@ -385,16 +478,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:
|
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()
|
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")
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
|
||||||
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?", "created_at<?"]
|
clauses = ["COALESCE(backup_type,'app')=?", "created_at<?"]
|
||||||
params: list[object] = [user_id, backup_type, cutoff]
|
params: list[object] = [backup_type, cutoff]
|
||||||
if backup_type == "profile":
|
if backup_type == "profile":
|
||||||
clauses.append("profile_id=?")
|
clauses.append("profile_id=?")
|
||||||
params.append(int(profile_id or 0))
|
params.append(int(profile_id or 0))
|
||||||
|
else:
|
||||||
|
clauses.append("user_id=?")
|
||||||
|
params.append(user_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
|
cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
|
||||||
return int(cur.rowcount or 0)
|
return int(cur.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
def _should_run(settings: dict, last_value: str | None) -> bool:
|
def _should_run(settings: dict, last_value: str | None) -> bool:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
try:
|
try:
|
||||||
@@ -433,18 +528,33 @@ def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str =
|
|||||||
|
|
||||||
def _profile_schedule_keys() -> list[tuple[int, int]]:
|
def _profile_schedule_keys() -> list[tuple[int, int]]:
|
||||||
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
|
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
|
||||||
keys: list[tuple[int, int]] = []
|
keys: set[tuple[int, int]] = set()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
|
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
parts = str(row.get("key") or "").split(":")
|
parts = str(row.get("key") or "").split(":")
|
||||||
try:
|
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:
|
except Exception:
|
||||||
continue
|
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:
|
def start_scheduler() -> None:
|
||||||
global _scheduler_started
|
global _scheduler_started
|
||||||
with _scheduler_lock:
|
with _scheduler_lock:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from ..db import connect, default_user_id, utcnow
|
from ..db import connect, default_user_id, utcnow
|
||||||
from . import rtorrent
|
from . import auth, rtorrent
|
||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"enabled": False,
|
"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:
|
with connect() as conn:
|
||||||
return conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
|
"SELECT * FROM download_plan_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1",
|
||||||
(user_id, profile_id),
|
(profile_id,),
|
||||||
).fetchone()
|
).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:
|
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)
|
row = _row(user_id, profile_id)
|
||||||
if not row:
|
if not row:
|
||||||
migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)})
|
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:
|
try:
|
||||||
data = json.loads(row.get("settings_json") or "{}")
|
data = json.loads(row.get("settings_json") or "{}")
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {}
|
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))
|
runtime_override = _override_until(int(profile_id))
|
||||||
if runtime_override:
|
if runtime_override:
|
||||||
settings["manual_override_until"] = 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:
|
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||||
user_id = user_id or default_user_id()
|
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)
|
settings = normalize(data)
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM download_plan_settings WHERE profile_id=?", (int(profile_id),))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
|
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
|
||||||
VALUES(?,?,?,?)
|
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),
|
(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]:
|
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:
|
def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict:
|
||||||
profile_id = int(profile.get("id") or 0)
|
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 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.
|
user_id = int(settings.get("owner_user_id") or user_id or profile.get("user_id") or default_user_id())
|
||||||
settings = get_settings(profile_id, 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"):
|
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)}
|
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()
|
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:
|
def preview(profile: dict, user_id: int | None = None) -> dict:
|
||||||
profile_id = int(profile.get("id") or 0)
|
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 or int(profile.get("user_id") or default_user_id()))
|
||||||
settings = get_settings(profile_id, user_id)
|
|
||||||
decision = evaluate(profile, settings)
|
decision = evaluate(profile, settings)
|
||||||
return {
|
return {
|
||||||
"profile_id": profile_id,
|
"profile_id": profile_id,
|
||||||
|
|||||||
@@ -104,12 +104,13 @@ def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
|
|||||||
profile_id = int(profile_id or 0)
|
profile_id = int(profile_id or 0)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM operation_log_settings WHERE user_id=? AND profile_id=?",
|
"SELECT * FROM operation_log_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1",
|
||||||
(user_id, profile_id),
|
(profile_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
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 = {**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_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_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"]))
|
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"])))
|
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"])))
|
lines = max(100, min(1_000_000, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"])))
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
|
if not auth.can_write_profile(profile_id, user_id):
|
||||||
|
raise PermissionError("No write access to profile")
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM operation_log_settings WHERE profile_id=?", (profile_id,))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at)
|
INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?)
|
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),
|
(user_id, profile_id, mode, days, lines, now, now),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -296,17 +296,39 @@ def legacy_disk_monitor_preferences(user_id: int | None = None) -> dict:
|
|||||||
return _normalize_disk_monitor(row)
|
return _normalize_disk_monitor(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_monitor_owner_label(row: dict | None) -> str:
|
||||||
|
if not row:
|
||||||
|
return ""
|
||||||
|
return str(row.get("owner_display_name") or row.get("owner_username") or row.get("owner_email") or (f"user #{row.get('user_id')}" if row.get("user_id") else "")).strip()
|
||||||
|
|
||||||
|
|
||||||
def get_disk_monitor_preferences(profile_id: int | None = None, user_id: int | None = None) -> dict:
|
def get_disk_monitor_preferences(profile_id: int | None = None, user_id: int | None = None) -> dict:
|
||||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||||
if not profile_id:
|
if not profile_id:
|
||||||
return legacy_disk_monitor_preferences(user_id)
|
return legacy_disk_monitor_preferences(user_id)
|
||||||
|
if not auth.can_access_profile(profile_id, user_id):
|
||||||
|
return legacy_disk_monitor_preferences(user_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute("SELECT * FROM disk_monitor_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT d.*, u.username AS owner_username, u.display_name AS owner_display_name, u.email AS owner_email
|
||||||
|
FROM disk_monitor_preferences d
|
||||||
|
LEFT JOIN users u ON u.id=d.user_id
|
||||||
|
WHERE d.profile_id=?
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
return _normalize_disk_monitor(row)
|
clean = _normalize_disk_monitor(row)
|
||||||
|
clean["disk_monitor_owner_user_id"] = int(row.get("user_id") or 0)
|
||||||
|
clean["disk_monitor_owner_label"] = _disk_monitor_owner_label(row)
|
||||||
|
return clean
|
||||||
# Backward-compatible seed: existing global disk monitor values become defaults for first use of a profile.
|
# Backward-compatible seed: existing global disk monitor values become defaults for first use of a profile.
|
||||||
return legacy_disk_monitor_preferences(user_id)
|
clean = legacy_disk_monitor_preferences(user_id)
|
||||||
|
clean["disk_monitor_owner_user_id"] = 0
|
||||||
|
clean["disk_monitor_owner_label"] = ""
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict:
|
def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict:
|
||||||
@@ -314,6 +336,8 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
|
|||||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||||
if not profile_id:
|
if not profile_id:
|
||||||
return legacy_disk_monitor_preferences(user_id)
|
return legacy_disk_monitor_preferences(user_id)
|
||||||
|
if not auth.can_write_profile(profile_id, user_id):
|
||||||
|
raise PermissionError("No write access to profile")
|
||||||
current = get_disk_monitor_preferences(profile_id, user_id)
|
current = get_disk_monitor_preferences(profile_id, user_id)
|
||||||
merged = dict(current)
|
merged = dict(current)
|
||||||
for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"):
|
for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"):
|
||||||
@@ -323,10 +347,14 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
|
|||||||
now = utcnow()
|
now = utcnow()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO disk_monitor_preferences(user_id,profile_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) "
|
"INSERT INTO disk_monitor_preferences(profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) "
|
||||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at",
|
"ON CONFLICT(profile_id) DO UPDATE SET user_id=excluded.user_id, paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at",
|
||||||
(user_id, profile_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now),
|
(profile_id, user_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now),
|
||||||
)
|
)
|
||||||
|
clean["disk_monitor_owner_user_id"] = int(user_id)
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute("SELECT display_name AS owner_display_name, username AS owner_username, email AS owner_email, id AS user_id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
clean["disk_monitor_owner_label"] = _disk_monitor_owner_label(row)
|
||||||
return clean
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from ..db import connect, utcnow, default_user_id
|
from ..db import connect, utcnow, default_user_id
|
||||||
from . import rtorrent
|
from . import auth, rtorrent
|
||||||
from .workers import enqueue
|
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:
|
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"])
|
profile_id = int(profile["id"])
|
||||||
with connect() as conn:
|
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()}
|
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
|
applied = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
queued_jobs = []
|
queued_jobs = []
|
||||||
@@ -93,6 +95,11 @@ def check(profile: dict, user_id: int | None = None) -> dict:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
action = str(group.get("action") or "stop")
|
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}}
|
payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}}
|
||||||
if action == "remove_data":
|
if action == "remove_data":
|
||||||
api_action = "remove"
|
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 ""
|
payload["label"] = group.get("set_label") or group.get("name") or ""
|
||||||
else:
|
else:
|
||||||
api_action = action if action in {"stop", "remove", "pause"} else "stop"
|
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)
|
queued_jobs.append(job_id)
|
||||||
applied += 1
|
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}
|
return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs}
|
||||||
|
|
||||||
|
|
||||||
@@ -127,12 +134,15 @@ def start_scheduler(socketio=None) -> None:
|
|||||||
try:
|
try:
|
||||||
from .preferences import get_profile
|
from .preferences import get_profile
|
||||||
with connect() as conn:
|
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:
|
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:
|
if not profile:
|
||||||
continue
|
continue
|
||||||
result = check(profile, int(row["user_id"]))
|
result = check(profile)
|
||||||
if socketio and result.get("applied"):
|
if socketio and result.get("applied"):
|
||||||
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ def _clear_disk_refresh_cache(profile_id: int) -> None:
|
|||||||
|
|
||||||
def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None:
|
def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None:
|
||||||
_clear_disk_refresh_cache(profile_id)
|
_clear_disk_refresh_cache(profile_id)
|
||||||
# Note: The browser performs the fresh /api/system/disk read so user-specific disk monitor preferences stay respected.
|
# Note: The browser performs the fresh /api/system/disk read so profile-scoped disk monitor preferences stay respected.
|
||||||
_emit("disk_refresh_requested", {
|
_emit("disk_refresh_requested", {
|
||||||
"profile_id": int(profile_id),
|
"profile_id": int(profile_id),
|
||||||
"hash_count": int(hash_count or 0),
|
"hash_count": int(hash_count or 0),
|
||||||
|
|||||||
@@ -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 '<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) · ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n const type=preview.backup_type==='app'?'application':'profile';\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\">${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>`;\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',`<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');\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||[]) : '<div class=\"empty-mini\">Application backups are admin-only.</div>';\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 '<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 const type=preview.backup_type==='app'?'application':'profile';\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\">${esc(type)} backup \u00b7 Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 Owner: ${esc(preview.owner_name||'-')} \u00b7 sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\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',`<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');\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||[]) : '<div class=\"empty-mini\">Application backups are admin-only.</div>';\n 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
@@ -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=>`<div class=\"label-manager-row\"><span class=\"chip\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</span><button class=\"btn btn-xs btn-outline-danger delete-label\" data-id=\"${esc(l.id)}\" title=\"Delete label\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div>`).join(''):'<div class=\"empty-state\"><i class=\"fa-solid fa-tags\"></i><b>No labels.</b><span>Add first label above.</span></div>'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class=\"chip label-selected\" data-label=\"${esc(l)}\" title=\"Remove\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)} <i class=\"fa-solid fa-xmark ms-1\"></i></button>`).join('') || '<span class=\"text-muted small\">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class=\"chip label-chip ${modalLabels.has(l.name)?'active':''}\" data-label=\"${esc(l.name)}\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</button>`).join('') || '<span class=\"text-muted small\">No saved labels.</span>'; }\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=>`<div class=\"label-manager-row\"><span class=\"chip\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</span><small class=\"text-muted\">Owner: ${esc(l.owner_name||'-')}</small><button class=\"btn btn-xs btn-outline-danger delete-label\" data-id=\"${esc(l.id)}\" title=\"Delete label\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div>`).join(''):'<div class=\"empty-state\"><i class=\"fa-solid fa-tags\"></i><b>No labels.</b><span>Add first label above.</span></div>'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class=\"chip label-selected\" data-label=\"${esc(l)}\" title=\"Remove\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)} <i class=\"fa-solid fa-xmark ms-1\"></i></button>`).join('') || '<span class=\"text-muted small\">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class=\"chip label-chip ${modalLabels.has(l.name)?'active':''}\" data-label=\"${esc(l.name)}\" title=\"Owner: ${esc(l.owner_name||'-')}\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</button>`).join('') || '<span class=\"text-muted small\">No saved labels.</span>'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n";
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }";
|
export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }";
|
||||||
|
|||||||
@@ -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=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`<h6>Groups</h6>${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']))}<h6 class=\"mt-3\">Applied history</h6>${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=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`<h6>Groups</h6>${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']))}<h6 class=\"mt-3\">Applied history</h6>${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";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user