profles_and_ux #7

Merged
gru merged 10 commits from profles_and_ux into master 2026-05-27 14:38:06 +02:00
10 changed files with 346 additions and 165 deletions
Showing only changes of commit 70a9344cdd - Show all commits

View File

@@ -181,8 +181,7 @@ CREATE TABLE IF NOT EXISTS ratio_groups (
CREATE TABLE IF NOT EXISTS rss_feeds ( CREATE TABLE IF NOT EXISTS rss_feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
enabled INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1,
@@ -196,8 +195,7 @@ CREATE TABLE IF NOT EXISTS rss_feeds (
CREATE TABLE IF NOT EXISTS rss_rules ( CREATE TABLE IF NOT EXISTS rss_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
pattern TEXT NOT NULL, pattern TEXT NOT NULL,
exclude_pattern TEXT, exclude_pattern TEXT,
@@ -214,13 +212,12 @@ CREATE TABLE IF NOT EXISTS rss_rules (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at); CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at);
CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled); CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled);
CREATE TABLE IF NOT EXISTS rss_history ( CREATE TABLE IF NOT EXISTS rss_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
feed_id INTEGER, feed_id INTEGER,
rule_id INTEGER, rule_id INTEGER,
title TEXT, title TEXT,
@@ -230,8 +227,7 @@ CREATE TABLE IF NOT EXISTS rss_history (
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status);
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added'); CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added');
CREATE TABLE IF NOT EXISTS ratio_assignments ( CREATE TABLE IF NOT EXISTS ratio_assignments (
@@ -275,7 +271,6 @@ CREATE TABLE IF NOT EXISTS app_backups (
); );
CREATE TABLE IF NOT EXISTS smart_queue_settings ( CREATE TABLE IF NOT EXISTS smart_queue_settings (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
enabled INTEGER DEFAULT 0, enabled INTEGER DEFAULT 0,
max_active_downloads INTEGER DEFAULT 5, max_active_downloads INTEGER DEFAULT 5,
@@ -296,7 +291,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
protect_active_below_cap INTEGER DEFAULT 1, protect_active_below_cap INTEGER DEFAULT 1,
auto_stop_idle INTEGER DEFAULT 0, auto_stop_idle INTEGER DEFAULT 0,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id) PRIMARY KEY(profile_id)
); );
CREATE TABLE IF NOT EXISTS smart_queue_stalled ( CREATE TABLE IF NOT EXISTS smart_queue_stalled (
@@ -317,19 +312,17 @@ CREATE TABLE IF NOT EXISTS smart_queue_start_grace (
); );
CREATE TABLE IF NOT EXISTS smart_queue_exclusions ( CREATE TABLE IF NOT EXISTS smart_queue_exclusions (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL, torrent_hash TEXT NOT NULL,
reason TEXT, reason TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id, torrent_hash) PRIMARY KEY(profile_id, torrent_hash)
); );
CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_history ( CREATE TABLE IF NOT EXISTS smart_queue_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
event TEXT NOT NULL, event TEXT NOT NULL,
paused_count INTEGER DEFAULT 0, paused_count INTEGER DEFAULT 0,
@@ -340,7 +333,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_history (
); );
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_auto_labels ( CREATE TABLE IF NOT EXISTS smart_queue_auto_labels (
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
@@ -418,14 +411,13 @@ CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_
CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS rtorrent_config_overrides ( CREATE TABLE IF NOT EXISTS rtorrent_config_overrides (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT, value TEXT,
baseline_value TEXT, baseline_value TEXT,
apply_on_start INTEGER DEFAULT 0, apply_on_start INTEGER DEFAULT 0,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id, key) PRIMARY KEY(profile_id, key)
); );
CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start); CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start);
@@ -598,7 +590,7 @@ MIGRATIONS = [
"ALTER TABLE automation_history ADD COLUMN rule_name TEXT", "ALTER TABLE automation_history ADD COLUMN rule_name TEXT",
"ALTER TABLE automation_history ADD COLUMN actions_json TEXT", "ALTER TABLE automation_history ADD COLUMN actions_json TEXT",
"ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT", "ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT",
"CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)", "CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')",
"CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))", "CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
@@ -611,15 +603,15 @@ MIGRATIONS = [
"CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)", "CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_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 INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)", "CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)",
"CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)",
"CREATE 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_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_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_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)", "CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
@@ -647,6 +639,81 @@ POST_MIGRATION_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
] ]
PROFILE_ONLY_TABLES = {
"rss_feeds": {
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER DEFAULT 1, interval_minutes INTEGER DEFAULT 30, last_error TEXT, last_checked_at TEXT, next_check_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL",
"copy": ["id", "profile_id", "name", "url", "enabled", "interval_minutes", "last_error", "last_checked_at", "next_check_at", "created_at", "updated_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)"],
},
"rss_rules": {
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, pattern TEXT NOT NULL, exclude_pattern TEXT, min_size_mb INTEGER DEFAULT 0, max_size_mb INTEGER DEFAULT 0, category TEXT, quality TEXT, season INTEGER, episode INTEGER, save_path TEXT, label TEXT, start INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL",
"copy": ["id", "profile_id", "name", "pattern", "exclude_pattern", "min_size_mb", "max_size_mb", "category", "quality", "season", "episode", "save_path", "label", "start", "enabled", "created_at", "updated_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)"],
},
"rss_history": {
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL",
"copy": ["id", "profile_id", "feed_id", "rule_id", "title", "link", "status", "message", "created_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')"],
},
"smart_queue_settings": {
"columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)",
"copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "auto_stop_idle", "updated_at"],
"indexes": [],
},
"smart_queue_exclusions": {
"columns": "profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash)",
"copy": ["profile_id", "torrent_hash", "reason", "created_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)"],
},
"smart_queue_history": {
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, event TEXT NOT NULL, paused_count INTEGER DEFAULT 0, resumed_count INTEGER DEFAULT 0, checked_count INTEGER DEFAULT 0, details_json TEXT, created_at TEXT NOT NULL",
"copy": ["id", "profile_id", "event", "paused_count", "resumed_count", "checked_count", "details_json", "created_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)"],
},
"rtorrent_config_overrides": {
"columns": "profile_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, baseline_value TEXT, apply_on_start INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, key)",
"copy": ["profile_id", "key", "value", "baseline_value", "apply_on_start", "updated_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start)"],
},
}
def _table_columns(conn, table: str) -> set[str]:
try:
return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
except sqlite3.OperationalError:
return set()
def _normalize_profile_only_tables(conn) -> None:
"""Move operational settings from user scope to profile scope on existing databases."""
for table, spec in PROFILE_ONLY_TABLES.items():
columns = _table_columns(conn, table)
if not columns or "user_id" not in columns:
for index_sql in spec["indexes"]:
try:
conn.execute(index_sql)
except sqlite3.OperationalError:
pass
continue
tmp = f"{table}_profile_scope_tmp"
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute(f"DROP TABLE IF EXISTS {tmp}")
conn.execute(f"CREATE TABLE {tmp} ({spec['columns']})")
copy_cols = [col for col in spec["copy"] if col in columns]
if copy_cols:
col_sql = ",".join(copy_cols)
if table in {"smart_queue_settings", "smart_queue_exclusions", "rtorrent_config_overrides"}:
conn.execute(f"INSERT OR REPLACE INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL")
else:
conn.execute(f"INSERT INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL")
conn.execute(f"DROP TABLE {table}")
conn.execute(f"ALTER TABLE {tmp} RENAME TO {table}")
for index_sql in spec["indexes"]:
conn.execute(index_sql)
conn.execute("PRAGMA foreign_keys = ON")
def utcnow() -> str: def utcnow() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -687,6 +754,7 @@ def init_db():
conn.execute(sql) conn.execute(sql)
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
_normalize_profile_only_tables(conn)
now = utcnow() now = utcnow()
conn.execute( conn.execute(
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)", "INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",

View File

@@ -13,11 +13,14 @@ def _active_profile_id() -> int | None:
def backup_list(): def backup_list():
uid = default_user_id() uid = default_user_id()
pid = _active_profile_id() pid = _active_profile_id()
can_app = auth.is_admin()
return ok({ return ok({
"profile_backups": backup_service.list_backups(uid, "profile", pid) if pid else [], "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 [], "app_backups": backup_service.list_backups(uid, "app") if can_app else [],
"auto": backup_service.get_auto_backup_settings(uid) if auth.is_admin() else None, "profile_auto": backup_service.get_auto_backup_settings(uid, "profile", pid) if pid else None,
"can_app_backup": auth.is_admin(), "app_auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None,
"auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None,
"can_app_backup": can_app,
}) })
@@ -58,14 +61,34 @@ def backup_create():
def backup_settings_get(): def backup_settings_get():
if not auth.is_admin(): if not auth.is_admin():
return jsonify({"ok": False, "error": "Application backup settings are admin-only"}), 403 return jsonify({"ok": False, "error": "Application backup settings are admin-only"}), 403
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())}) return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "app")})
@bp.post("/backup/settings") @bp.post("/backup/settings")
def backup_settings_save(): def backup_settings_save():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
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(), "app")})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.get("/backup/profile/settings")
def profile_backup_settings_get():
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "profile", pid)})
@bp.post("/backup/profile/settings")
def profile_backup_settings_save():
data = request.get_json(silent=True) or {}
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "profile", pid)})
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400

View File

@@ -2,65 +2,109 @@ from __future__ import annotations
from ._shared import * from ._shared import *
def _active_profile_or_400():
profile = preferences.active_profile()
if not profile:
return None
return profile
@bp.get("/rss") @bp.get("/rss")
def rss_list(): def rss_list():
profile = preferences.active_profile() profile = _active_profile_or_400()
pid = profile["id"] if profile else None if not profile:
return ok({"feeds": [], "rules": [], "history": []})
pid = int(profile["id"])
with connect() as conn: with connect() as conn:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? ORDER BY name", (pid,)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? ORDER BY name", (pid,)).fetchall()
history = conn.execute("SELECT * FROM rss_history WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY id DESC LIMIT 80", (default_user_id(), pid)).fetchall() history = conn.execute("SELECT * FROM rss_history WHERE profile_id=? ORDER BY id DESC LIMIT 80", (pid,)).fetchall()
return ok({"feeds": feeds, "rules": rules, "history": history}) return ok({"feeds": feeds, "rules": rules, "history": history})
@bp.post("/rss/feeds") @bp.post("/rss/feeds")
def rss_feed_save(): def rss_feed_save():
profile = preferences.active_profile() profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
now = utcnow() now = utcnow()
feed_id = data.get("id") feed_id = data.get("id")
pid = int(profile["id"])
with connect() as conn: with connect() as conn:
if feed_id: if feed_id:
conn.execute("UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND user_id=?", (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, default_user_id())) conn.execute(
"UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND profile_id=?",
(data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, pid),
)
else: else:
conn.execute("INSERT INTO rss_feeds(user_id,profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now)) conn.execute(
"INSERT INTO rss_feeds(profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
(pid, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now),
)
return rss_list() return rss_list()
@bp.delete("/rss/feeds/<int:feed_id>") @bp.delete("/rss/feeds/<int:feed_id>")
def rss_feed_delete(feed_id: int): def rss_feed_delete(feed_id: int):
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
with connect() as conn: with connect() as conn:
conn.execute("DELETE FROM rss_feeds WHERE id=? AND user_id=?", (feed_id, default_user_id())) conn.execute("DELETE FROM rss_feeds WHERE id=? AND profile_id=?", (feed_id, int(profile["id"])))
return rss_list() return rss_list()
@bp.post("/rss/rules") @bp.post("/rss/rules")
def rss_rule_save(): def rss_rule_save():
profile = preferences.active_profile() profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
now = utcnow() now = utcnow()
rule_id = data.get("id") rule_id = data.get("id")
values = (data.get("name") or "Rule", data.get("pattern") or ".*", data.get("exclude_pattern") or "", int(data.get("min_size_mb") or 0), int(data.get("max_size_mb") or 0), data.get("category") or "", data.get("quality") or "", data.get("season") or None, data.get("episode") or None, data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1 if data.get("enabled", True) else 0, now) pid = int(profile["id"])
values = (
data.get("name") or "Rule",
data.get("pattern") or ".*",
data.get("exclude_pattern") or "",
int(data.get("min_size_mb") or 0),
int(data.get("max_size_mb") or 0),
data.get("category") or "",
data.get("quality") or "",
data.get("season") or None,
data.get("episode") or None,
data.get("save_path") or active_default_download_path(profile),
data.get("label") or "",
1 if data.get("start", True) else 0,
1 if data.get("enabled", True) else 0,
now,
)
with connect() as conn: with connect() as conn:
if rule_id: if rule_id:
conn.execute("UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND user_id=?", (*values, rule_id, default_user_id())) conn.execute(
"UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND profile_id=?",
(*values, rule_id, pid),
)
else: else:
conn.execute("INSERT INTO rss_rules(user_id,profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, *values, now)) conn.execute(
"INSERT INTO rss_rules(profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(pid, *values, now),
)
return rss_list() return rss_list()
@bp.delete("/rss/rules/<int:rule_id>") @bp.delete("/rss/rules/<int:rule_id>")
def rss_rule_delete(rule_id: int): def rss_rule_delete(rule_id: int):
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
with connect() as conn: with connect() as conn:
conn.execute("DELETE FROM rss_rules WHERE id=? AND user_id=?", (rule_id, default_user_id())) conn.execute("DELETE FROM rss_rules WHERE id=? AND profile_id=?", (rule_id, int(profile["id"])))
return rss_list() return rss_list()
@bp.post("/rss/rules/test") @bp.post("/rss/rules/test")
def rss_rule_test(): def rss_rule_test():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
@@ -71,12 +115,9 @@ def rss_rule_test():
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/rss/check") @bp.post("/rss/check")
def rss_check(): def rss_check():
profile = preferences.active_profile() profile = preferences.active_profile()
if not profile: if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400 return jsonify({"ok": False, "error": "No profile"}), 400
return ok(rss_service.check(profile, default_user_id(), only_due=False)) return ok(rss_service.check(profile, only_due=False))

View File

@@ -15,7 +15,7 @@ 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 only the active profile context and current user's profile-scoped preferences. # Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user.
PROFILE_BACKUP_TABLES = [ 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",
@@ -28,12 +28,12 @@ PROFILE_TABLE_FILTERS = {
"disk_monitor_preferences": "user_id=? AND profile_id=?", "disk_monitor_preferences": "user_id=? AND profile_id=?",
"labels": "user_id=? AND profile_id=?", "labels": "user_id=? AND profile_id=?",
"ratio_groups": "user_id=? AND profile_id=?", "ratio_groups": "user_id=? AND profile_id=?",
"rss_feeds": "user_id=? AND profile_id=?", "rss_feeds": "profile_id=?",
"rss_rules": "user_id=? AND profile_id=?", "rss_rules": "profile_id=?",
"smart_queue_settings": "user_id=? AND profile_id=?", "smart_queue_settings": "profile_id=?",
"smart_queue_exclusions": "user_id=? AND profile_id=?", "smart_queue_exclusions": "profile_id=?",
"automation_rules": "user_id=? AND profile_id=?", "automation_rules": "user_id=? AND profile_id=?",
"rtorrent_config_overrides": "user_id=? AND profile_id=?", "rtorrent_config_overrides": "profile_id=?",
"poller_settings": "profile_id=?", "poller_settings": "profile_id=?",
"download_plan_settings": "user_id=? AND profile_id=?", "download_plan_settings": "user_id=? AND profile_id=?",
} }
@@ -76,6 +76,13 @@ def _loads(value: str) -> dict:
return {} return {}
def _table_columns(conn, table: str) -> set[str]:
try:
return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
except Exception:
return set()
def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]: def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]:
try: try:
sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "") sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "")
@@ -191,7 +198,10 @@ def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
rows = tables.get(table) or [] rows = tables.get(table) or []
if not rows: if not rows:
continue continue
columns = list(rows[0].keys()) available = _table_columns(conn, table)
columns = [col for col in rows[0].keys() if col in available]
if not columns:
continue
placeholders = ",".join("?" for _ in columns) placeholders = ",".join("?" for _ in columns)
conn.execute(f"DELETE FROM {table}") conn.execute(f"DELETE FROM {table}")
for row in rows: for row in rows:
@@ -245,7 +255,10 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
count = 0 count = 0
for row in rows: for row in rows:
clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id)) clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id))
columns = list(clean.keys()) available = _table_columns(conn, table)
columns = [col for col in clean.keys() if col in available]
if not columns:
continue
placeholders = ",".join("?" for _ in columns) placeholders = ",".join("?" for _ in columns)
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns]) conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns])
count += 1 count += 1
@@ -274,15 +287,24 @@ def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
return {"deleted": backup_id} return {"deleted": backup_id}
def _settings_row_key(user_id: int | None = None) -> str: def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str:
return f"{AUTO_BACKUP_SETTINGS_KEY}:{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"
if scope == "profile":
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{uid}:{int(profile_id or 0)}"
return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}"
def _latest_backup_created_at(user_id: int) -> str | None: def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None:
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?"]
params: list[object] = [user_id, backup_type]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT created_at FROM app_backups WHERE user_id=? AND COALESCE(backup_type,'app')='app' ORDER BY created_at DESC, id DESC LIMIT 1", f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
(user_id,), tuple(params),
).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
@@ -302,20 +324,29 @@ def _preview_row(row: dict) -> dict:
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, backup_type: str = "app", profile_id: int | None = None) -> dict:
key = _settings_row_key(user_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()
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))
settings["retention_days"] = max(1, int(settings.get("retention_days") or 30)) settings["retention_days"] = max(1, int(settings.get("retention_days") or 30))
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
if backup_type == "profile":
settings["profile_id"] = int(profile_id or 0)
return settings return settings
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, backup_type: str = "app", profile_id: int | None = None) -> dict:
_require_admin(user_id) user_id = user_id or auth.current_user_id() or default_user_id()
current = get_auto_backup_settings(user_id) backup_type = "profile" if backup_type == "profile" else "app"
if backup_type == "app":
_require_admin(user_id)
else:
if not profile_id or not auth.can_access_profile(int(profile_id), user_id):
raise PermissionError("No access to profile")
current = get_auto_backup_settings(user_id, backup_type, profile_id)
settings = { settings = {
**current, **current,
"enabled": bool(data.get("enabled")), "enabled": bool(data.get("enabled")),
@@ -323,7 +354,7 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
"retention_days": max(1, int(data.get("retention_days") or current["retention_days"])), "retention_days": max(1, int(data.get("retention_days") or current["retention_days"])),
"last_run_at": data.get("last_run_at", current.get("last_run_at")), "last_run_at": data.get("last_run_at", current.get("last_run_at")),
} }
key = _settings_row_key(user_id) key = _settings_row_key(user_id, backup_type, profile_id)
with connect() as conn: with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings))) conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings)))
return settings return settings
@@ -350,39 +381,69 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
} }
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int: def prune_old_backups(user_id: int | None = None, retention_days: int = 30, backup_type: str = "app", profile_id: int | None = None) -> int:
user_id = user_id or auth.current_user_id() or default_user_id() 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<?"]
params: list[object] = [user_id, backup_type, cutoff]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
with connect() as conn: with connect() as conn:
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND COALESCE(backup_type,'app')='app' AND created_at<?", (user_id, cutoff)) 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 maybe_create_automatic_backup(user_id: int | None = None) -> dict | None: def _should_run(settings: dict, last_value: str | None) -> bool:
user_id = user_id or default_user_id()
if not _is_admin_user(user_id):
return None
settings = get_auto_backup_settings(user_id)
if not settings.get("enabled"):
return None
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id)
try: try:
last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None
except Exception: except Exception:
last = None last = None
if last and now - last < timedelta(hours=settings["interval_hours"]): return not last or now - last >= timedelta(hours=settings["interval_hours"])
def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict | None:
user_id = user_id or default_user_id()
backup_type = "profile" if backup_type == "profile" else "app"
if backup_type == "app" and not _is_admin_user(user_id):
return None
if backup_type == "profile" and (not profile_id or not auth.can_access_profile(int(profile_id), user_id)):
return None
settings = get_auto_backup_settings(user_id, backup_type, profile_id)
if not settings.get("enabled"):
return None
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id, backup_type, profile_id)
if not _should_run(settings, last_value):
if settings.get("last_run_at") != last_value: if settings.get("last_run_at") != last_value:
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, backup_type, profile_id)
return None return None
backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) now = datetime.now(timezone.utc)
if backup_type == "profile":
backup = create_profile_backup(f"Automatic profile backup {now.isoformat(timespec='seconds')}", int(profile_id or 0), user_id, automatic=True)
else:
backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds") 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, backup_type, profile_id)
prune_old_backups(user_id, settings["retention_days"]) prune_old_backups(user_id, settings["retention_days"], backup_type, profile_id)
return backup return backup
def _profile_schedule_keys() -> list[tuple[int, int]]:
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
keys: list[tuple[int, int]] = []
with connect() as conn:
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
for row in rows:
parts = str(row.get("key") or "").split(":")
try:
keys.append((int(parts[-2]), int(parts[-1])))
except Exception:
continue
return keys
def start_scheduler() -> None: def start_scheduler() -> None:
global _scheduler_started global _scheduler_started
with _scheduler_lock: with _scheduler_lock:
@@ -397,7 +458,9 @@ def start_scheduler() -> None:
rows = conn.execute("SELECT id FROM users WHERE is_active=1 AND role='admin'").fetchall() 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, "app")
for uid, pid in _profile_schedule_keys():
maybe_create_automatic_backup(uid, "profile", pid)
except Exception: except Exception:
pass pass
time.sleep(300) time.sleep(300)

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timezone, timedelta
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from typing import Iterable from typing import Iterable
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow
from . import rtorrent from . import rtorrent
from .workers import enqueue from .workers import enqueue
@@ -122,12 +122,12 @@ def matches_rule(rule: dict, item: dict) -> tuple[bool, str]:
return True, "matched" return True, "matched"
def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None: def _log(profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None:
with connect() as conn: with connect() as conn:
try: try:
conn.execute( conn.execute(
"INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)", "INSERT INTO rss_history(profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?)",
(user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()), (profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
) )
except Exception: except Exception:
# Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads. # Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads.
@@ -135,15 +135,14 @@ def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None
def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict: def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict:
user_id = user_id or default_user_id()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
if only_due: if only_due:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (profile_id, now)).fetchall()
else: else:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall()
queued = 0 queued = 0
tested = 0 tested = 0
errors: list[dict] = [] errors: list[dict] = []
@@ -160,11 +159,11 @@ def check(profile: dict, user_id: int | None = None, only_due: bool = False) ->
continue continue
link = item.get("link") or "" link = item.get("link") or ""
if not link: if not link:
_log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link") _log(profile_id, feed["id"], rule["id"], item, "skipped", "missing link")
continue continue
enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id) enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id)
queued += 1 queued += 1
_log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason) _log(profile_id, feed["id"], rule["id"], item, "queued", reason)
with connect() as conn: with connect() as conn:
conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"])) conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"]))
except Exception as exc: except Exception as exc:
@@ -200,11 +199,11 @@ 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 rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall() profiles = conn.execute("SELECT DISTINCT profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
for row in profiles: for row in profiles:
profile = get_profile(int(row["profile_id"]), int(row["user_id"])) profile = get_profile(int(row["profile_id"]))
if profile: if profile:
result = check(profile, int(row["user_id"]), only_due=True) result = check(profile, only_due=True)
if socketio and result.get("queued"): if socketio and result.get("queued"):
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}") socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
except Exception: except Exception:

View File

@@ -54,11 +54,10 @@ def _normalize_config_value(meta: dict, value):
def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]: def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, int(profile_id)), (int(profile_id),),
).fetchall() ).fetchall()
return {r["key"]: r for r in rows} return {r["key"]: r for r in rows}
@@ -129,7 +128,6 @@ def _read_rtorrent_config_value(client, key: str, meta: dict) -> str:
def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]: def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]:
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS} known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
user_id = default_user_id()
now = utcnow() now = utcnow()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
baseline_values = baseline_values or {} baseline_values = baseline_values or {}
@@ -139,8 +137,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
for key in clear_set: for key in clear_set:
if key in known: if key in known:
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
) )
for key, value in (values or {}).items(): for key, value in (values or {}).items():
if key in clear_set: if key in clear_set:
@@ -150,8 +148,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
continue continue
normalized = _normalize_config_value(meta, value) normalized = _normalize_config_value(meta, value)
existing = conn.execute( existing = conn.execute(
"SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "SELECT baseline_value FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
).fetchone() ).fetchone()
existing_baseline = existing.get("baseline_value") if existing else None existing_baseline = existing.get("baseline_value") if existing else None
@@ -165,18 +163,18 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
if baseline not in (None, "") and normalized == baseline: if baseline not in (None, "") and normalized == baseline:
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
) )
continue continue
conn.execute( conn.execute(
"INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)", "INSERT OR REPLACE INTO rtorrent_config_overrides(profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?)",
(user_id, profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now), (profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
) )
stored.append(key) stored.append(key)
conn.execute( conn.execute(
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?", "UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE profile_id=?",
(1 if apply_on_start else 0, now, user_id, profile_id), (1 if apply_on_start else 0, now, profile_id),
) )
return stored return stored
@@ -220,17 +218,16 @@ def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_sta
def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict: def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict:
"""Remove saved UI overrides and return the freshly read rTorrent config.""" """Remove saved UI overrides and return the freshly read rTorrent config."""
# Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent. # Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent.
user_id = user_id or default_user_id()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
removed = int((row or {}).get("count") or 0) removed = int((row or {}).get("count") or 0)
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, profile_id), (profile_id,),
) )
config = get_config(profile) config = get_config(profile)
config["reset_removed"] = removed config["reset_removed"] = removed

View File

@@ -135,9 +135,8 @@ def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, defaul
return max(minimum, int(default)) return max(minimum, int(default))
def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: def _default_settings(profile_id: int) -> dict[str, Any]:
return { return {
'user_id': user_id,
'profile_id': profile_id, 'profile_id': profile_id,
'enabled': 0, 'enabled': 0,
'max_active_downloads': 5, 'max_active_downloads': 5,
@@ -162,18 +161,16 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]: def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?', 'SELECT * FROM smart_queue_settings WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
settings = dict(row or _default_settings(user_id, profile_id)) settings = dict(row or _default_settings(profile_id))
return settings return settings
def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
current = get_settings(profile_id, user_id) current = get_settings(profile_id, user_id)
settings = { settings = {
'enabled': 1 if data.get('enabled', current.get('enabled')) else 0, 'enabled': 1 if data.get('enabled', current.get('enabled')) else 0,
@@ -214,9 +211,9 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) '''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET ON CONFLICT(profile_id) DO UPDATE SET
enabled=excluded.enabled, enabled=excluded.enabled,
max_active_downloads=excluded.max_active_downloads, max_active_downloads=excluded.max_active_downloads,
stalled_seconds=excluded.stalled_seconds, stalled_seconds=excluded.stalled_seconds,
@@ -234,80 +231,74 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
refill_enabled=excluded.refill_enabled, refill_enabled=excluded.refill_enabled,
refill_interval_minutes=excluded.refill_interval_minutes, refill_interval_minutes=excluded.refill_interval_minutes,
updated_at=excluded.updated_at''', updated_at=excluded.updated_at''',
(user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), (profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now),
) )
return get_settings(profile_id, user_id) return get_settings(profile_id, user_id)
def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]: def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
return conn.execute( return conn.execute(
'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC', 'SELECT * FROM smart_queue_exclusions WHERE profile_id=? ORDER BY created_at DESC',
(user_id, profile_id), (profile_id,),
).fetchall() ).fetchall()
def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None: def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
if excluded: if excluded:
conn.execute( conn.execute(
'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)', 'INSERT OR REPLACE INTO smart_queue_exclusions(profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?)',
(user_id, profile_id, torrent_hash, reason, now), (profile_id, torrent_hash, reason, now),
) )
else: else:
conn.execute( conn.execute(
'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?', 'DELETE FROM smart_queue_exclusions WHERE profile_id=? AND torrent_hash=?',
(user_id, profile_id, torrent_hash), (profile_id, torrent_hash),
) )
def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None: def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
paused = paused or [] paused = paused or []
resumed = resumed or [] resumed = resumed or []
details = details or {} details = details or {}
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)', 'INSERT INTO smart_queue_history(profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?)',
(user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), (profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()),
) )
def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
return conn.execute( return conn.execute(
'SELECT * FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', 'SELECT * FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT ?',
(user_id, profile_id, max(1, min(int(limit or 30), 100))), (profile_id, max(1, min(int(limit or 30), 100))),
).fetchall() ).fetchall()
def clear_history(profile_id: int, user_id: int | None = None) -> int: def clear_history(profile_id: int, user_id: int | None = None) -> int:
"""Delete Smart Queue history rows for the current profile and return the removed count.""" """Delete Smart Queue history rows for the current profile and return the removed count."""
# Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched. # Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched.
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
count = int((row or {}).get('count') or 0) count = int((row or {}).get('count') or 0)
conn.execute( conn.execute(
'DELETE FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'DELETE FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
) )
return count return count
def count_history(profile_id: int, user_id: int | None = None) -> int: def count_history(profile_id: int, user_id: int | None = None) -> int:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
return int((row or {}).get('count') or 0) return int((row or {}).get('count') or 0)
@@ -315,11 +306,10 @@ def count_history(profile_id: int, user_id: int | None = None) -> int:
def _latest_history_event(profile_id: int, user_id: int | None = None) -> str: def _latest_history_event(profile_id: int, user_id: int | None = None) -> str:
"""Return the newest Smart Queue history event for duplicate suppression.""" """Return the newest Smart Queue history event for duplicate suppression."""
# Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream. # Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream.
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1', 'SELECT event FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT 1',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
return str((row or {}).get('event') or '') return str((row or {}).get('event') or '')
@@ -338,8 +328,8 @@ def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[s
return True return True
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]: def _excluded_hashes(profile_id: int, user_id: int | None = None) -> set[str]:
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)} return {r['torrent_hash'] for r in list_exclusions(profile_id)}
@@ -891,7 +881,7 @@ def _refill_mode(settings: dict[str, Any]) -> str:
def _mark_refill_run(profile_id: int, user_id: int) -> None: def _mark_refill_run(profile_id: int, user_id: int) -> None:
# Note: Custom refill interval is measured from the last lightweight refill attempt. # Note: Custom refill interval is measured from the last lightweight refill attempt.
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]: def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]:
@@ -1090,13 +1080,13 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
def mark_run(profile_id: int, user_id: int | None = None) -> None: def mark_run(profile_id: int, user_id: int | None = None) -> None:
user_id = user_id or default_user_id() user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]: def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]:
# Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact. # Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact.
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (now, now, user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE profile_id=?', (now, now, profile_id))
add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id) add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id)
settings = get_settings(profile_id, user_id) settings = get_settings(profile_id, user_id)
return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'} return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long