profles_and_ux #7

Merged
gru merged 10 commits from profles_and_ux into master 2026-05-27 14:38:06 +02:00
32 changed files with 2587 additions and 555 deletions

View File

@@ -55,33 +55,39 @@ CREATE TABLE IF NOT EXISTS user_preferences (
bootstrap_theme TEXT DEFAULT 'default',
font_family TEXT DEFAULT 'default',
active_rtorrent_id INTEGER,
table_columns_json TEXT,
keyboard_json TEXT,
mobile_mode INTEGER DEFAULT 0,
compact_torrent_list_enabled INTEGER DEFAULT 0,
peers_refresh_seconds INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0,
footer_items_json TEXT,
title_speed_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
automation_toasts_enabled INTEGER DEFAULT 1,
smart_queue_toasts_enabled INTEGER DEFAULT 1,
disk_monitor_paths_json TEXT,
disk_monitor_mode TEXT DEFAULT 'default',
disk_monitor_selected_path TEXT,
disk_monitor_stop_enabled INTEGER DEFAULT 0,
disk_monitor_stop_threshold INTEGER DEFAULT 98,
interface_scale INTEGER DEFAULT 100,
detail_panel_height INTEGER DEFAULT 255,
torrent_sort_json TEXT,
active_filter TEXT DEFAULT 'all',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
CREATE TABLE IF NOT EXISTS profile_preferences (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
table_columns_json TEXT,
torrent_sort_json TEXT,
active_filter TEXT DEFAULT 'all',
peers_refresh_seconds INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id),
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
);
CREATE INDEX IF NOT EXISTS idx_profile_preferences_user_profile ON profile_preferences(user_id, profile_id);
CREATE TABLE IF NOT EXISTS rtorrent_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
@@ -175,8 +181,7 @@ CREATE TABLE IF NOT EXISTS ratio_groups (
CREATE TABLE IF NOT EXISTS rss_feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
profile_id INTEGER NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
@@ -190,8 +195,7 @@ CREATE TABLE IF NOT EXISTS rss_feeds (
CREATE TABLE IF NOT EXISTS rss_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
profile_id INTEGER NOT NULL,
name TEXT NOT NULL,
pattern TEXT NOT NULL,
exclude_pattern TEXT,
@@ -208,13 +212,12 @@ CREATE TABLE IF NOT EXISTS rss_rules (
created_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_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled);
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_profile_enabled ON rss_rules(profile_id, enabled);
CREATE TABLE IF NOT EXISTS rss_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
profile_id INTEGER NOT NULL,
feed_id INTEGER,
rule_id INTEGER,
title TEXT,
@@ -224,8 +227,7 @@ CREATE TABLE IF NOT EXISTS rss_history (
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_user_profile_created ON rss_history(user_id, profile_id, created_at);
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 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 (
@@ -262,12 +264,13 @@ CREATE TABLE IF NOT EXISTS app_backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
backup_type TEXT DEFAULT 'app',
profile_id INTEGER,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS smart_queue_settings (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
enabled INTEGER DEFAULT 0,
max_active_downloads INTEGER DEFAULT 5,
@@ -288,7 +291,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
protect_active_below_cap INTEGER DEFAULT 1,
auto_stop_idle INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id)
PRIMARY KEY(profile_id)
);
CREATE TABLE IF NOT EXISTS smart_queue_stalled (
@@ -309,19 +312,17 @@ CREATE TABLE IF NOT EXISTS smart_queue_start_grace (
);
CREATE TABLE IF NOT EXISTS smart_queue_exclusions (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL,
reason TEXT,
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
event TEXT NOT NULL,
paused_count INTEGER DEFAULT 0,
@@ -332,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_user_profile_created ON smart_queue_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_auto_labels (
profile_id INTEGER NOT NULL,
@@ -410,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 TABLE IF NOT EXISTS rtorrent_config_overrides (
user_id INTEGER NOT NULL,
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(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);
@@ -426,6 +426,13 @@ CREATE TABLE IF NOT EXISTS app_settings (
value TEXT
);
CREATE TABLE IF NOT EXISTS poller_settings (
profile_id INTEGER PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
);
CREATE TABLE IF NOT EXISTS download_plan_settings (
user_id INTEGER NOT NULL,
@@ -514,18 +521,12 @@ MIGRATIONS = [
"ALTER TABLE users ADD COLUMN updated_at TEXT",
"ALTER TABLE user_preferences ADD COLUMN mobile_mode INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN compact_torrent_list_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN peers_refresh_seconds INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN port_check_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN reverse_dns_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN active_filter TEXT DEFAULT 'all'",
"ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5",
"ALTER TABLE rtorrent_profiles ADD COLUMN light_parallel_jobs INTEGER DEFAULT 4",
"ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300",
@@ -560,11 +561,6 @@ MIGRATIONS = [
"CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)",
"ALTER TABLE user_preferences ADD COLUMN automation_toasts_enabled INTEGER DEFAULT 1",
"ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_paths_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_mode TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_selected_path TEXT",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_threshold INTEGER DEFAULT 98",
"ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10",
"ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT",
"ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1",
@@ -594,7 +590,7 @@ MIGRATIONS = [
"ALTER TABLE automation_history ADD COLUMN rule_name TEXT",
"ALTER TABLE automation_history ADD COLUMN actions_json 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 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))",
@@ -607,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_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_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, 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_history_user_profile_created ON rss_history(user_id, profile_id, created_at)",
"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_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)",
"CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(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_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_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, 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_exclusions_profile_created ON smart_queue_exclusions(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_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)",
@@ -624,6 +620,10 @@ MIGRATIONS = [
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)",
"CREATE TABLE IF NOT EXISTS profile_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, table_columns_json TEXT, torrent_sort_json TEXT, active_filter TEXT DEFAULT 'all', peers_refresh_seconds INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0, tracker_favicons_enabled INTEGER DEFAULT 0, reverse_dns_enabled INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))",
"ALTER TABLE app_backups ADD COLUMN backup_type TEXT DEFAULT 'app'",
'ALTER TABLE app_backups ADD COLUMN profile_id INTEGER',
'CREATE TABLE IF NOT EXISTS poller_settings (profile_id INTEGER PRIMARY KEY, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))',
"CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
]
@@ -639,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)",
]
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:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -679,6 +754,7 @@ def init_db():
conn.execute(sql)
except sqlite3.OperationalError:
pass
_normalize_profile_only_tables(conn)
now = utcnow()
conn.execute(
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",

View File

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

View File

@@ -2,65 +2,109 @@ from __future__ import annotations
from ._shared import *
def _active_profile_or_400():
profile = preferences.active_profile()
if not profile:
return None
return profile
@bp.get("/rss")
def rss_list():
profile = preferences.active_profile()
pid = profile["id"] if profile else None
profile = _active_profile_or_400()
if not profile:
return ok({"feeds": [], "rules": [], "history": []})
pid = int(profile["id"])
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()
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()
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()
feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? ORDER BY name", (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 profile_id=? ORDER BY id DESC LIMIT 80", (pid,)).fetchall()
return ok({"feeds": feeds, "rules": rules, "history": history})
@bp.post("/rss/feeds")
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 {}
now = utcnow()
feed_id = data.get("id")
pid = int(profile["id"])
with connect() as conn:
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:
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()
@bp.delete("/rss/feeds/<int:feed_id>")
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:
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()
@bp.post("/rss/rules")
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 {}
now = utcnow()
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:
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:
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()
@bp.delete("/rss/rules/<int:rule_id>")
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:
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()
@bp.post("/rss/rules/test")
def rss_rule_test():
data = request.get_json(silent=True) or {}
@@ -71,12 +115,9 @@ def rss_rule_test():
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/rss/check")
def rss_check():
profile = preferences.active_profile()
if not profile:
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

@@ -198,12 +198,17 @@ def cleanup_jobs():
@bp.post("/cleanup/smart-queue")
def cleanup_smart_queue():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
if not exists:
deleted = 0
else:
cur = conn.execute("DELETE FROM smart_queue_history")
# Note: Cleanup is limited to the active profile so read/write permissions never affect other profiles.
cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (profile_id,))
deleted = int(cur.rowcount or 0)
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@@ -232,13 +237,17 @@ def cleanup_planner():
@bp.post("/cleanup/automations")
def cleanup_automations():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
if not exists:
deleted = 0
else:
# Note: Cleanup panel removes only automation logs, not saved automation rules.
cur = conn.execute("DELETE FROM automation_history")
# Note: Cleanup panel removes only active profile automation logs, not saved automation rules.
cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (profile_id,))
deleted = int(cur.rowcount or 0)
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@@ -256,13 +265,13 @@ def cleanup_all():
if not exists:
deleted_smart = 0
else:
cur = conn.execute("DELETE FROM smart_queue_history")
cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (active_profile_id,))
deleted_smart = int(cur.rowcount or 0)
exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
if not exists_auto:
deleted_auto = 0
else:
cur = conn.execute("DELETE FROM automation_history")
cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (active_profile_id,))
deleted_auto = int(cur.rowcount or 0)
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})

View File

@@ -31,11 +31,16 @@ RTORRENT_WRITE_PREFIXES = (
"/api/rss",
"/api/smart-queue",
"/api/automations",
"/api/download-planner",
"/api/poller/settings",
"/api/operation-logs",
"/api/jobs",
"/api/cleanup",
)
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
# Note: API reads that expose rTorrent/profile data must also respect profile permissions.
# Note: Planner, poller and operation-log endpoints are profile-scoped and must follow the active profile context.
PROFILE_READ_PREFIXES = (
"/api/torrents",
"/api/torrent-stats",
@@ -50,6 +55,9 @@ PROFILE_READ_PREFIXES = (
"/api/smart-queue",
"/api/traffic/history",
"/api/automations",
"/api/download-planner",
"/api/poller/settings",
"/api/operation-logs",
)

View File

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

View File

@@ -55,6 +55,10 @@ PYTORRENT_APP_THEMES = {
"nord": "pyTorrent Nord",
"crimson": "pyTorrent Crimson",
"sky": "pyTorrent Sky",
"bootstrap22": "Bootstrap 2 Classic",
"bootstrap22-inverse": "Bootstrap 2 Inverse",
"bootstrap3": "Bootstrap 3 Glyph",
"bootstrap3-inverse": "Bootstrap 3 Inverse",
}

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timezone, timedelta
from email.utils import parsedate_to_datetime
from typing import Iterable
from ..db import connect, utcnow, default_user_id
from ..db import connect, utcnow
from . import rtorrent
from .workers import enqueue
@@ -122,12 +122,12 @@ def matches_rule(rule: dict, item: dict) -> tuple[bool, str]:
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:
try:
conn.execute(
"INSERT INTO rss_history(user_id,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()),
"INSERT INTO rss_history(profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?)",
(profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
)
except Exception:
# 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:
user_id = user_id or default_user_id()
profile_id = int(profile["id"])
now = utcnow()
with connect() as conn:
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:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules 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 profile_id=? AND enabled=1", (profile_id,)).fetchall()
queued = 0
tested = 0
errors: list[dict] = []
@@ -160,11 +159,11 @@ def check(profile: dict, user_id: int | None = None, only_due: bool = False) ->
continue
link = item.get("link") or ""
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
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
_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:
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:
@@ -200,11 +199,11 @@ def start_scheduler(socketio=None) -> None:
try:
from .preferences import get_profile
with connect() as conn:
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM 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:
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
profile = get_profile(int(row["profile_id"]))
if profile:
result = check(profile, int(row["user_id"]), only_due=True)
result = check(profile, only_due=True)
if socketio and result.get("queued"):
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
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]:
user_id = user_id or default_user_id()
with connect() as conn:
rows = conn.execute(
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
(user_id, int(profile_id)),
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE profile_id=?",
(int(profile_id),),
).fetchall()
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]:
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
user_id = default_user_id()
now = utcnow()
profile_id = int(profile["id"])
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:
if key in known:
conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
(user_id, profile_id, key),
"DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(profile_id, key),
)
for key, value in (values or {}).items():
if key in clear_set:
@@ -150,8 +148,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
continue
normalized = _normalize_config_value(meta, value)
existing = conn.execute(
"SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
(user_id, profile_id, key),
"SELECT baseline_value FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(profile_id, key),
).fetchone()
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:
conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
(user_id, profile_id, key),
"DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(profile_id, key),
)
continue
conn.execute(
"INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,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),
"INSERT OR REPLACE INTO rtorrent_config_overrides(profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?)",
(profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
)
stored.append(key)
conn.execute(
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?",
(1 if apply_on_start else 0, now, user_id, profile_id),
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE profile_id=?",
(1 if apply_on_start else 0, now, profile_id),
)
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:
"""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.
user_id = user_id or default_user_id()
profile_id = int(profile["id"])
with connect() as conn:
row = conn.execute(
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
(user_id, profile_id),
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE profile_id=?",
(profile_id,),
).fetchone()
removed = int((row or {}).get("count") or 0)
conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
(user_id, profile_id),
"DELETE FROM rtorrent_config_overrides WHERE profile_id=?",
(profile_id,),
)
config = get_config(profile)
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))
def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
def _default_settings(profile_id: int) -> dict[str, Any]:
return {
'user_id': user_id,
'profile_id': profile_id,
'enabled': 0,
'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]:
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute(
'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?',
(user_id, profile_id),
'SELECT * FROM smart_queue_settings WHERE profile_id=?',
(profile_id,),
).fetchone()
settings = dict(row or _default_settings(user_id, profile_id))
settings = dict(row or _default_settings(profile_id))
return settings
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)
settings = {
'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()
with connect() as conn:
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)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
'''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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET
enabled=excluded.enabled,
max_active_downloads=excluded.max_active_downloads,
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_interval_minutes=excluded.refill_interval_minutes,
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)
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:
return conn.execute(
'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC',
(user_id, profile_id),
'SELECT * FROM smart_queue_exclusions WHERE profile_id=? ORDER BY created_at DESC',
(profile_id,),
).fetchall()
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()
with connect() as conn:
if excluded:
conn.execute(
'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)',
(user_id, profile_id, torrent_hash, reason, now),
'INSERT OR REPLACE INTO smart_queue_exclusions(profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?)',
(profile_id, torrent_hash, reason, now),
)
else:
conn.execute(
'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?',
(user_id, profile_id, torrent_hash),
'DELETE FROM smart_queue_exclusions WHERE profile_id=? AND 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:
user_id = user_id or default_user_id()
paused = paused or []
resumed = resumed or []
details = details or {}
with connect() as conn:
conn.execute(
'INSERT INTO smart_queue_history(user_id,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()),
'INSERT INTO smart_queue_history(profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?)',
(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]]:
user_id = user_id or default_user_id()
with connect() as conn:
return conn.execute(
'SELECT * FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?',
(user_id, profile_id, max(1, min(int(limit or 30), 100))),
'SELECT * FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT ?',
(profile_id, max(1, min(int(limit or 30), 100))),
).fetchall()
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."""
# 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:
row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?',
(user_id, profile_id),
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
(profile_id,),
).fetchone()
count = int((row or {}).get('count') or 0)
conn.execute(
'DELETE FROM smart_queue_history WHERE user_id=? AND profile_id=?',
(user_id, profile_id),
'DELETE FROM smart_queue_history WHERE profile_id=?',
(profile_id,),
)
return count
def count_history(profile_id: int, user_id: int | None = None) -> int:
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?',
(user_id, profile_id),
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
(profile_id,),
).fetchone()
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:
"""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.
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute(
'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1',
(user_id, profile_id),
'SELECT event FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT 1',
(profile_id,),
).fetchone()
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
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]:
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)}
def _excluded_hashes(profile_id: int, user_id: int | None = None) -> set[str]:
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:
# Note: Custom refill interval is measured from the last lightweight refill attempt.
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]:
@@ -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:
user_id = user_id or default_user_id()
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]:
# Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact.
now = utcnow()
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)
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.'}

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,247 @@
/* Note: Bootstrap 2 Inverse keeps the old beveled control language with darker navigation chrome. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 5px;
--bs-font-sans-serif: "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #eceff1;
--bs-body-color: #2f2f2f;
--bs-primary: #2f96b4;
--bs-primary-rgb: 47, 150, 180;
--bs-secondary-bg: #d9dee2;
--bs-secondary-bg-rgb: 217, 222, 226;
--bs-secondary-color: #4d5963;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #b8c0c7;
--bs-link-color: #2f96b4;
}
[data-bs-theme="dark"] {
--bs-body-bg: #161a1d;
--bs-body-color: #e7ecef;
--bs-primary: #49afcd;
--bs-primary-rgb: 73, 175, 205;
--bs-secondary-bg: #252b30;
--bs-secondary-bg-rgb: 37, 43, 48;
--bs-secondary-color: #c9d3da;
--bs-tertiary-bg: #20262a;
--bs-border-color: #48535c;
--bs-link-color: #5bc0de;
}
/* Note: Bootstrap 2 surfaces were simple grey panels with subtle inset highlights. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.table,
.toast {
background-image: linear-gradient(#ffffff, #f7f7f7);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08);
}
[data-bs-theme="dark"] .card,
[data-bs-theme="dark"] .dropdown-menu,
[data-bs-theme="dark"] .modal-content,
[data-bs-theme="dark"] .surface-section,
[data-bs-theme="dark"] .smart-setting-row,
[data-bs-theme="dark"] .table,
[data-bs-theme="dark"] .toast {
background-image: linear-gradient(#303840, #252c33);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.25);
}
.btn {
border-color: rgba(0, 0, 0, 0.22);
border-radius: 4px;
border-width: 1px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 1px 2px rgba(0, 0, 0, 0.08);
font-weight: 600;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#08c, #04c);
border-color: #0044cc #0044cc #002a80;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-image: linear-gradient(#0077d9, #003bb3);
border-color: #003bb3 #003bb3 #001f66;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
border-color: #51a351 #51a351 #387038;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
border-color: #bd362f #bd362f #802420;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-image: linear-gradient(#ffffff, #e6e6e6);
border-color: #cccccc #cccccc #b3b3b3;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-image: linear-gradient(#08c, #04c);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-image: linear-gradient(#fbb450, #f89406);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-image: linear-gradient(#5bc0de, #2f96b4);
color: #ffffff;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-image: linear-gradient(#e6e6e6, #cfcfcf);
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-image: linear-gradient(#4b545d, #303840);
border-color: #5c6670;
color: #f0f0f0;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.45);
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-image: linear-gradient(#68737e, #48515a);
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: rgba(82, 168, 236, 0.8);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
.modal-header,
.card-header,
.dropdown-header,
.table thead th {
background-image: linear-gradient(#f9f9f9, #eeeeee);
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .dropdown-header,
[data-bs-theme="dark"] .table thead th {
background-image: linear-gradient(#39434c, #2c343b);
}
.badge,
.label {
border-radius: 3px;
}
.alert {
border-radius: 3px;
border-width: 1px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.progress {
background-image: linear-gradient(#f5f5f5, #e6e6e6);
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.progress-bar {
background-image: linear-gradient(#149bdf, #0480be);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}
/* Note: Inverse variant reproduces the dark Bootstrap 2 navbar strip. */
.navbar,
.sidebar,
.topbar {
background-image: linear-gradient(#444444, #222222);
border-color: #252525;
color: #eeeeee;
}

View File

@@ -0,0 +1,237 @@
/* Note: Bootstrap 2 Classic maps the app UI to the old beveled Bootstrap 2 control language. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 5px;
--bs-font-sans-serif: "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f5f5f5;
--bs-body-color: #333333;
--bs-primary: #006dcc;
--bs-primary-rgb: 0, 109, 204;
--bs-secondary-bg: #eeeeee;
--bs-secondary-bg-rgb: 238, 238, 238;
--bs-secondary-color: #555555;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #c8c8c8;
--bs-link-color: #0088cc;
}
[data-bs-theme="dark"] {
--bs-body-bg: #1f252b;
--bs-body-color: #e6e6e6;
--bs-primary: #4aa3df;
--bs-primary-rgb: 74, 163, 223;
--bs-secondary-bg: #2f363d;
--bs-secondary-bg-rgb: 47, 54, 61;
--bs-secondary-color: #c7d0d8;
--bs-tertiary-bg: #252b31;
--bs-border-color: #4a535c;
--bs-link-color: #6bbdf0;
}
/* Note: Bootstrap 2 surfaces were simple grey panels with subtle inset highlights. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.table,
.toast {
background-image: linear-gradient(#ffffff, #f7f7f7);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08);
}
[data-bs-theme="dark"] .card,
[data-bs-theme="dark"] .dropdown-menu,
[data-bs-theme="dark"] .modal-content,
[data-bs-theme="dark"] .surface-section,
[data-bs-theme="dark"] .smart-setting-row,
[data-bs-theme="dark"] .table,
[data-bs-theme="dark"] .toast {
background-image: linear-gradient(#303840, #252c33);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.25);
}
.btn {
border-color: rgba(0, 0, 0, 0.22);
border-radius: 4px;
border-width: 1px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 1px 2px rgba(0, 0, 0, 0.08);
font-weight: 600;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#08c, #04c);
border-color: #0044cc #0044cc #002a80;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-image: linear-gradient(#0077d9, #003bb3);
border-color: #003bb3 #003bb3 #001f66;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
border-color: #51a351 #51a351 #387038;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
border-color: #bd362f #bd362f #802420;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-image: linear-gradient(#ffffff, #e6e6e6);
border-color: #cccccc #cccccc #b3b3b3;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-image: linear-gradient(#08c, #04c);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-image: linear-gradient(#fbb450, #f89406);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-image: linear-gradient(#5bc0de, #2f96b4);
color: #ffffff;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-image: linear-gradient(#e6e6e6, #cfcfcf);
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-image: linear-gradient(#4b545d, #303840);
border-color: #5c6670;
color: #f0f0f0;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.45);
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-image: linear-gradient(#68737e, #48515a);
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: rgba(82, 168, 236, 0.8);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
.modal-header,
.card-header,
.dropdown-header,
.table thead th {
background-image: linear-gradient(#f9f9f9, #eeeeee);
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .dropdown-header,
[data-bs-theme="dark"] .table thead th {
background-image: linear-gradient(#39434c, #2c343b);
}
.badge,
.label {
border-radius: 3px;
}
.alert {
border-radius: 3px;
border-width: 1px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.progress {
background-image: linear-gradient(#f5f5f5, #e6e6e6);
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.progress-bar {
background-image: linear-gradient(#149bdf, #0480be);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}

View File

@@ -0,0 +1,268 @@
/* Note: Bootstrap 3 Inverse keeps the dark navbar era with Bootstrap 3 panel and button shapes. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 4px;
--bs-font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #eceff2;
--bs-body-color: #2d3338;
--bs-primary: #428bca;
--bs-primary-rgb: 66, 139, 202;
--bs-success: #5cb85c;
--bs-danger: #d9534f;
--bs-warning: #f0ad4e;
--bs-info: #5bc0de;
--bs-secondary-bg: #dfe4e8;
--bs-secondary-bg-rgb: 223, 228, 232;
--bs-secondary-color: #4c5963;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #c5cdd4;
--bs-link-color: #2a6496;
}
[data-bs-theme="dark"] {
--bs-body-bg: #151a1f;
--bs-body-color: #edf2f6;
--bs-primary: #6fb4eb;
--bs-primary-rgb: 111, 180, 235;
--bs-success: #7dd37d;
--bs-danger: #ec7773;
--bs-warning: #f6c572;
--bs-info: #83d8ee;
--bs-secondary-bg: #232b33;
--bs-secondary-bg-rgb: 35, 43, 51;
--bs-secondary-color: #c3ccd4;
--bs-tertiary-bg: #1d242b;
--bs-border-color: #3f4b56;
--bs-link-color: #94cdf5;
}
/* Note: Bootstrap 3 panels and wells are represented through shared app containers. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.toast {
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.navbar,
.topbar {
background-image: linear-gradient(#ffffff, #f2f2f2);
border-bottom: 1px solid var(--bs-border-color);
}
[data-bs-theme="dark"] .navbar,
[data-bs-theme="dark"] .topbar {
background-image: linear-gradient(#303941, #20272e);
}
.btn {
border-width: 1px;
border-radius: 4px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#428bca, #2d6ca2);
border-color: #2b669a;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: #2d6ca2;
color: #ffffff;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-color: #5cb85c;
border-color: #4cae4c;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-color: #d9534f;
border-color: #d43f3a;
color: #ffffff;
}
.btn-warning,
.btn-warning:hover,
.btn-warning:focus-visible {
background-color: #f0ad4e;
border-color: #eea236;
color: #111111;
}
.btn-info,
.btn-info:hover,
.btn-info:focus-visible {
background-color: #5bc0de;
border-color: #46b8da;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-color: #ffffff;
border-color: #cccccc;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: #e6e6e6;
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-color: #323b44;
border-color: #53606b;
color: #edf2f6;
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-color: #46515b;
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
}
.card-header,
.modal-header,
.table thead th {
background-color: #f5f5f5;
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .table thead th {
background-color: #303941;
}
.badge,
.label {
border-radius: 4px;
}
.alert {
border-radius: 4px;
border-width: 1px;
}
.progress {
background-color: #f5f5f5;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar {
background-image: linear-gradient(#5bc0de, #339bb9);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}
/* Note: Inverse variant reproduces Bootstrap 3's dark default navbar treatment. */
.navbar,
.sidebar,
.topbar {
background-image: linear-gradient(#3c3c3c, #222222);
border-color: #080808;
color: #eeeeee;
}

View File

@@ -0,0 +1,258 @@
/* Note: Bootstrap 3 Glyph uses flat panels, square controls and the Bootstrap 3 palette. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 4px;
--bs-font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f8f8f8;
--bs-body-color: #333333;
--bs-primary: #337ab7;
--bs-primary-rgb: 51, 122, 183;
--bs-success: #5cb85c;
--bs-danger: #d9534f;
--bs-warning: #f0ad4e;
--bs-info: #5bc0de;
--bs-secondary-bg: #eeeeee;
--bs-secondary-bg-rgb: 238, 238, 238;
--bs-secondary-color: #555555;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #dddddd;
--bs-link-color: #337ab7;
}
[data-bs-theme="dark"] {
--bs-body-bg: #1f252b;
--bs-body-color: #e7edf2;
--bs-primary: #5dade2;
--bs-primary-rgb: 93, 173, 226;
--bs-success: #70c770;
--bs-danger: #e26b67;
--bs-warning: #f4bd65;
--bs-info: #73cde6;
--bs-secondary-bg: #2b333b;
--bs-secondary-bg-rgb: 43, 51, 59;
--bs-secondary-color: #bac4cd;
--bs-tertiary-bg: #252c33;
--bs-border-color: #46515b;
--bs-link-color: #8cc8f0;
}
/* Note: Bootstrap 3 panels and wells are represented through shared app containers. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.toast {
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.navbar,
.topbar {
background-image: linear-gradient(#ffffff, #f2f2f2);
border-bottom: 1px solid var(--bs-border-color);
}
[data-bs-theme="dark"] .navbar,
[data-bs-theme="dark"] .topbar {
background-image: linear-gradient(#303941, #20272e);
}
.btn {
border-width: 1px;
border-radius: 4px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#428bca, #2d6ca2);
border-color: #2b669a;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: #2d6ca2;
color: #ffffff;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-color: #5cb85c;
border-color: #4cae4c;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-color: #d9534f;
border-color: #d43f3a;
color: #ffffff;
}
.btn-warning,
.btn-warning:hover,
.btn-warning:focus-visible {
background-color: #f0ad4e;
border-color: #eea236;
color: #111111;
}
.btn-info,
.btn-info:hover,
.btn-info:focus-visible {
background-color: #5bc0de;
border-color: #46b8da;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-color: #ffffff;
border-color: #cccccc;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: #e6e6e6;
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-color: #323b44;
border-color: #53606b;
color: #edf2f6;
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-color: #46515b;
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
}
.card-header,
.modal-header,
.table thead th {
background-color: #f5f5f5;
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .table thead th {
background-color: #303941;
}
.badge,
.label {
border-radius: 4px;
}
.alert {
border-radius: 4px;
border-width: 1px;
}
.progress {
background-color: #f5f5f5;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar {
background-image: linear-gradient(#5bc0de, #339bb9);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}

View File

@@ -0,0 +1,142 @@
/* Note: Bootstrap 4 Cards uses the v4 blue, subtler radii and card-first surfaces. */
:root {
--bs-border-radius: .25rem;
--bs-border-radius-sm: .2rem;
--bs-border-radius-lg: .3rem;
--bs-font-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f8f9fa;
--bs-body-color: #212529;
--bs-primary: #007bff;
--bs-primary-rgb: 0, 123, 255;
--bs-success: #28a745;
--bs-danger: #dc3545;
--bs-warning: #ffc107;
--bs-info: #17a2b8;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-secondary-color: #6c757d;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #dee2e6;
--bs-link-color: #007bff;
}
[data-bs-theme="dark"] {
--bs-body-bg: #181c20;
--bs-body-color: #e9ecef;
--bs-primary: #4dabf7;
--bs-primary-rgb: 77, 171, 247;
--bs-success: #51cf66;
--bs-danger: #ff6b6b;
--bs-warning: #ffd43b;
--bs-info: #3bc9db;
--bs-secondary-bg: #2a3036;
--bs-secondary-bg-rgb: 42, 48, 54;
--bs-secondary-color: #c1c7cd;
--bs-tertiary-bg: #22272c;
--bs-border-color: #444c55;
--bs-link-color: #74c0fc;
}
.card,
.surface-section,
.modal-content,
.dropdown-menu {
border-radius: .25rem;
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-color: #0069d9;
border-color: #0062cc;
}
[data-bs-theme="dark"] .btn-primary:hover,
[data-bs-theme="dark"] .btn-primary:focus-visible {
background-color: #228be6;
border-color: #1c7ed6;
}
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: var(--bs-primary);
color: #ffffff;
}
.btn {
border-width: 1px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease, filter .15s ease;
}
.btn:hover,
.btn:focus-visible {
filter: none;
text-decoration: none;
}
.btn-primary,
.btn-success,
.btn-danger,
.btn-warning,
.btn-info {
color: #ffffff;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info,
.btn-outline-secondary {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: var(--bs-secondary-color);
border-color: var(--bs-secondary-color);
color: var(--bs-body-bg);
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
}

View File

@@ -0,0 +1,146 @@
/* Note: Bootstrap 5 Soft keeps the modern variable palette with stronger action hover contrast. */
:root {
--bs-border-radius: .5rem;
--bs-border-radius-sm: .375rem;
--bs-border-radius-lg: .75rem;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f6f8fb;
--bs-body-color: #1f2937;
--bs-primary: #0d6efd;
--bs-primary-rgb: 13, 110, 253;
--bs-success: #198754;
--bs-danger: #dc3545;
--bs-warning: #ffc107;
--bs-info: #0dcaf0;
--bs-secondary-bg: #edf2f7;
--bs-secondary-bg-rgb: 237, 242, 247;
--bs-secondary-color: #64748b;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #d9e2ec;
--bs-link-color: #0d6efd;
}
[data-bs-theme="dark"] {
--bs-body-bg: #101827;
--bs-body-color: #e5e7eb;
--bs-primary: #60a5fa;
--bs-primary-rgb: 96, 165, 250;
--bs-success: #34d399;
--bs-danger: #f87171;
--bs-warning: #fbbf24;
--bs-info: #22d3ee;
--bs-secondary-bg: #1e293b;
--bs-secondary-bg-rgb: 30, 41, 59;
--bs-secondary-color: #cbd5e1;
--bs-tertiary-bg: #172033;
--bs-border-color: #334155;
--bs-link-color: #93c5fd;
}
.card,
.surface-section,
.modal-content,
.dropdown-menu {
border-radius: .75rem;
box-shadow: 0 .35rem 1rem rgba(15, 23, 42, .08);
}
.btn {
border-radius: .5rem;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-color: #0b5ed7;
border-color: #0a58ca;
}
[data-bs-theme="dark"] .btn-primary:hover,
[data-bs-theme="dark"] .btn-primary:focus-visible {
background-color: #3b82f6;
border-color: #2563eb;
}
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: var(--bs-primary);
color: #ffffff;
}
.btn {
border-width: 1px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease, filter .15s ease;
}
.btn:hover,
.btn:focus-visible {
filter: none;
text-decoration: none;
}
.btn-primary,
.btn-success,
.btn-danger,
.btn-warning,
.btn-info {
color: #ffffff;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info,
.btn-outline-secondary {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: var(--bs-secondary-color);
border-color: var(--bs-secondary-color);
color: var(--bs-body-bg);
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
}

View File

@@ -840,12 +840,15 @@ body.resizing-details {
}
.toast-count {
flex: 0 0 auto;
padding: 0.05rem 0.35rem;
border-radius: 999px;
align-items: center;
background: rgba(255, 255, 255, 0.22);
border-radius: 999px;
display: inline-flex;
flex: 0 0 auto;
font-size: 0.78rem;
font-weight: 700;
gap: 0.1rem;
padding: 0.05rem 0.35rem;
}
@media (max-width: 1100px) {
:root {
@@ -3077,6 +3080,43 @@ body.mobile-mode .mobile-filter-bar {
margin-bottom: 0.7rem;
}
.planner-current-summary ul {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1rem;
margin-bottom: 0;
}
.planner-disclosure > summary {
cursor: pointer;
justify-content: space-between;
list-style: none;
margin-bottom: 0;
}
.planner-disclosure > summary::-webkit-details-marker {
display: none;
}
.planner-disclosure[open] > summary {
margin-bottom: 0.7rem;
}
.planner-card-title span {
align-items: center;
display: inline-flex;
gap: 0.45rem;
}
.planner-card-chevron {
color: var(--bs-secondary-color);
transition: transform 0.15s ease;
}
.planner-disclosure[open] .planner-card-chevron {
transform: rotate(180deg);
}
.planner-card-time,
.planner-card-protection {
display: grid;
@@ -3198,9 +3238,6 @@ body.mobile-mode .mobile-filter-bar {
.planner-hour-row small {
color: var(--bs-secondary-color);
}
.planner-hour-row small {
white-space: nowrap;
}
@@ -3444,7 +3481,16 @@ body.mobile-mode .mobile-filter-bar {
.profile-diagnostics-card{border:1px solid var(--bs-border-color);border-radius:.5rem;padding:.5rem;background:var(--bs-body-bg);}
.profile-diagnostics-card small{display:block;color:var(--bs-secondary-color);}
.labels-manager { display: grid; gap: 0.5rem; }
.labels-manager {
display: grid;
gap: 0.5rem;
}
.labels-manager .empty-state {
align-items: flex-start;
justify-self: stretch;
text-align: left;
}
/* UI hygiene: keep long status/footer content inside the app instead of widening the browser viewport. */
html,
@@ -4311,6 +4357,11 @@ body,
/* Operation logs */
.logs-tools-layout {
display: grid;
gap: 0.85rem;
}
.operation-log-toolbar,
.operation-log-toolbar-main,
.operation-log-settings-grid,
@@ -4336,9 +4387,6 @@ body,
.operation-log-view-settings {
align-items: center;
border-top: 1px solid var(--bs-border-color);
margin-top: 1rem;
padding-top: 1rem;
}
.operation-log-view-settings > div:first-child {
@@ -4437,25 +4485,159 @@ body,
}
}
/* Note: Peers tables keep hostnames readable without letting the Host column dominate the layout. */
.peers-table {
table-layout: auto;
min-width: 960px;
table-layout: fixed;
width: 100%;
}
.peers-table th,
.peers-table td {
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.peers-table .peer-progress-wide {
min-width: 108px;
width: clamp(108px, 12vw, 126px);
width: 100%;
}
.peers-table-hosts th:nth-child(1),
.peers-table-hosts td:nth-child(1) {
width: 4%;
}
.peers-table-hosts th:nth-child(2),
.peers-table-hosts td:nth-child(2) {
width: 13%;
}
.peers-table-hosts th:nth-child(3),
.peers-table-hosts td:nth-child(3) {
width: 15%;
}
.peers-table-hosts th:nth-child(4),
.peers-table-hosts td:nth-child(4),
.peers-table-hosts th:nth-child(5),
.peers-table-hosts td:nth-child(5) {
width: 8%;
}
.peers-table-hosts th:nth-child(6),
.peers-table-hosts td:nth-child(6) {
width: 15%;
}
.peers-table-hosts th:nth-child(7),
.peers-table-hosts td:nth-child(7) {
width: 10%;
}
.peers-table-hosts th:nth-child(8),
.peers-table-hosts td:nth-child(8),
.peers-table-hosts th:nth-child(9),
.peers-table-hosts td:nth-child(9) {
width: 6%;
}
.peers-table-hosts th:nth-child(10),
.peers-table-hosts td:nth-child(10) {
width: 5%;
}
.peers-table-hosts th:nth-child(11),
.peers-table-hosts td:nth-child(11) {
width: 10%;
}
.peer-host {
display: inline-block;
max-width: 220px;
display: block;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
/* Note: Mobile torrent details use a narrower fixed table so long reverse-DNS names cannot stretch the modal. */
.mobile-details-modal .modal-body {
overflow-x: hidden;
}
.mobile-details-modal .responsive-table-wrap {
max-width: 100%;
}
.mobile-details-peers-table {
min-width: 720px;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(1),
.mobile-details-peers-table.peers-table-hosts td:nth-child(1) {
width: 5%;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(2),
.mobile-details-peers-table.peers-table-hosts td:nth-child(2) {
width: 14%;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(3),
.mobile-details-peers-table.peers-table-hosts td:nth-child(3),
.mobile-details-peers-table.peers-table-hosts th:nth-child(4),
.mobile-details-peers-table.peers-table-hosts td:nth-child(4) {
width: 15%;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(5),
.mobile-details-peers-table.peers-table-hosts td:nth-child(5) {
width: 16%;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(6),
.mobile-details-peers-table.peers-table-hosts td:nth-child(6) {
width: 10%;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(7),
.mobile-details-peers-table.peers-table-hosts td:nth-child(7),
.mobile-details-peers-table.peers-table-hosts th:nth-child(8),
.mobile-details-peers-table.peers-table-hosts td:nth-child(8) {
width: 7%;
}
.mobile-details-peers-table.peers-table-hosts th:nth-child(9),
.mobile-details-peers-table.peers-table-hosts td:nth-child(9),
.mobile-details-peers-table.peers-table-hosts th:nth-child(10),
.mobile-details-peers-table.peers-table-hosts td:nth-child(10) {
width: 6%;
}
/* App modal widths stay consistent while Bootstrap still handles full-screen mobile breakpoints. */
.app-modal-dialog,
.modal-dialog.modal-xl {
--bs-modal-width: min(1140px, calc(100vw - 2rem));
}
@media (max-width: 575.98px) {
.app-modal-dialog {
--bs-modal-width: 100vw;
}
}
/* Current rTorrent settings uses the same card rhythm as Diagnostics for faster scanning. */
.rt-config-current-summary {
margin-bottom: 1rem;
}
.rt-config-value-note {
word-break: break-word;
}
.file-row-actions {
align-items: center;
display: inline-flex;
@@ -4912,10 +5094,6 @@ body,
margin-bottom: 0;
}
.mobile-details-peers-table {
min-width: 720px;
}
.mobile-details-files-table {
min-width: 760px;
}
@@ -5036,3 +5214,63 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
opacity: 0.85;
pointer-events: none;
}
/* Note: Mobile sort uses Font Awesome icons instead of text arrows to avoid broken glyph spacing. */
.mobile-sort-cycle-label {
align-items: center;
display: inline-flex;
gap: 0.45rem;
justify-content: center;
min-width: 0;
}
.mobile-sort-cycle-label i {
flex: 0 0 auto;
line-height: 1;
width: 1rem;
}
/* Note: Planner Current Settings inherits Poller Diagnostics card chrome from .smart-setting-row. */
.planner-current-summary {
align-items: flex-start;
}
.planner-diagnostic-line {
align-items: center;
color: var(--bs-secondary-color);
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.55rem;
line-height: 1.45;
margin-top: 0.2rem;
}
/* Note: Keep each Current Settings entry on a single visual line so labels do not break above values. */
.planner-diagnostic-item {
align-items: baseline;
display: inline-flex;
gap: 0.25rem;
white-space: nowrap;
}
.planner-diagnostic-item b {
color: var(--bs-body-color);
display: inline;
font-weight: 700;
}
/* Note: Add breathing room around Current Settings separators to match Poller Diagnostics readability. */
.planner-diagnostic-line .diagnostic-separator {
margin: 0 0.18rem;
}
.diagnostic-separator,
.modal-meta-separator {
color: var(--bs-secondary-color);
font-size: 0.38rem;
opacity: 0.8;
vertical-align: middle;
}

File diff suppressed because one or more lines are too long

199
scripts/db_cleanup.py Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
import shutil
import sqlite3
from datetime import datetime
from pathlib import Path
DB_PATH = Path("/opt/pyTorrent/data/pytorrent.sqlite3")
DROP_COLUMNS = {
"rss_feeds": [
"user_id",
],
"rss_rules": [
"user_id",
],
"rss_history": [
"user_id",
],
"smart_queue_settings": [
"user_id",
],
"smart_queue_exclusions": [
"user_id",
],
"smart_queue_history": [
"user_id",
],
"rtorrent_config_overrides": [
"user_id",
],
"user_preferences": [
"table_columns_json",
"peers_refresh_seconds",
"port_check_enabled",
"tracker_favicons_enabled",
"reverse_dns_enabled",
"disk_monitor_paths_json",
"disk_monitor_mode",
"disk_monitor_selected_path",
"disk_monitor_stop_enabled",
"disk_monitor_stop_threshold",
"torrent_sort_json",
"active_filter",
],
}
DROP_INDEXES = [
"idx_rss_feeds_user_profile",
"idx_rss_rules_user_profile",
"idx_rss_history_user_profile",
"idx_smart_queue_settings_user_profile",
"idx_smart_queue_exclusions_user_profile",
"idx_smart_queue_history_user_profile",
"idx_rtorrent_config_overrides_user_profile",
]
EXPECTED_PROFILE_TABLES = {
"rss_feeds": ["profile_id"],
"rss_rules": ["profile_id"],
"rss_history": ["profile_id"],
"smart_queue_settings": ["profile_id"],
"smart_queue_exclusions": ["profile_id"],
"smart_queue_history": ["profile_id"],
"rtorrent_config_overrides": ["profile_id"],
}
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(table,),
).fetchone()
return row is not None
def index_exists(conn: sqlite3.Connection, index: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
(index,),
).fetchone()
return row is not None
def columns(conn: sqlite3.Connection, table: str) -> list[str]:
return [row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')]
def quote_ident(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def validate_profile_tables(conn: sqlite3.Connection) -> None:
print("Checking required profile scoped tables...")
for table, required_columns in EXPECTED_PROFILE_TABLES.items():
if not table_exists(conn, table):
print(f"SKIP table missing: {table}")
continue
table_columns = columns(conn, table)
for column in required_columns:
if column not in table_columns:
raise RuntimeError(
f"Unsafe cleanup: table {table} does not contain required column {column}"
)
print(f"OK {table}: has {', '.join(required_columns)}")
def drop_indexes(conn: sqlite3.Connection) -> None:
print("\nDropping obsolete indexes if present...")
for index in DROP_INDEXES:
if not index_exists(conn, index):
print(f"SKIP index missing: {index}")
continue
conn.execute(f"DROP INDEX {quote_ident(index)}")
print(f"DROPPED index: {index}")
def drop_obsolete_columns(conn: sqlite3.Connection) -> None:
print("\nDropping obsolete columns if present...")
for table, obsolete_columns in DROP_COLUMNS.items():
if not table_exists(conn, table):
print(f"SKIP table missing: {table}")
continue
current_columns = columns(conn, table)
for column in obsolete_columns:
if column not in current_columns:
print(f"SKIP column missing: {table}.{column}")
continue
try:
conn.execute(
f"ALTER TABLE {quote_ident(table)} DROP COLUMN {quote_ident(column)}"
)
print(f"DROPPED column: {table}.{column}")
current_columns.remove(column)
except sqlite3.OperationalError as exc:
print(f"FAILED column: {table}.{column} -> {exc}")
print("This usually means the column is used by an index, constraint, or old SQLite version.")
def vacuum(conn: sqlite3.Connection) -> None:
print("\nRunning VACUUM...")
conn.execute("VACUUM")
print("VACUUM done.")
def main() -> None:
if not DB_PATH.exists():
raise SystemExit(f"Database not found: {DB_PATH}")
backup_path = DB_PATH.with_suffix(
DB_PATH.suffix + f".cleanup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.bak"
)
print(f"Database: {DB_PATH}")
print(f"Backup: {backup_path}")
shutil.copy2(DB_PATH, backup_path)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA foreign_keys = OFF")
validate_profile_tables(conn)
conn.execute("BEGIN")
drop_indexes(conn)
drop_obsolete_columns(conn)
conn.commit()
conn.execute("PRAGMA foreign_keys = ON")
vacuum(conn)
print("\nCleanup completed successfully.")
print(f"Backup saved as: {backup_path}")
except Exception:
conn.rollback()
print("\nCleanup failed. Database rollback completed.")
print(f"Backup is available at: {backup_path}")
raise
finally:
conn.close()
if __name__ == "__main__":
main()