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