changes in db

This commit is contained in:
Mateusz Gruszczyński
2026-05-26 09:25:47 +02:00
parent 8268ad87cf
commit 70a9344cdd
10 changed files with 346 additions and 165 deletions

View File

@@ -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)

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.'}