from __future__ import annotations import json import time from datetime import datetime, timezone from typing import Any import psutil from ..db import connect, default_user_id, utcnow from . import rtorrent DEFAULTS = { "enabled": False, "name": "Default download plan", "profile_name": "night mode", "dry_run": False, "manual_override_until": "", "night_only_enabled": False, "night_start": "23:00", "night_end": "07:00", "quiet_hours_enabled": False, "quiet_start": "22:00", "quiet_end": "06:00", "weekday_down": 0, "weekday_up": 0, "weekend_down": 0, "weekend_up": 0, "hourly_schedule_enabled": False, "hourly_schedule": [], "auto_pause_cpu_enabled": False, "auto_pause_cpu_percent": 90, "auto_pause_disk_enabled": False, "auto_pause_disk_percent": 95, "network_protection_enabled": False, "network_max_down": 0, "network_max_up": 0, "load_protection_enabled": False, "load_cpu_percent": 95, "auto_resume": True, "auto_resume_grace_seconds": 0, "check_interval_seconds": 30, } _LAST_RUN: dict[int, float] = {} _LAST_LIMITS: dict[int, tuple[int, int]] = {} _HIGH_CPU_SINCE: dict[int, float] = {} def _bool(value: Any) -> bool: if isinstance(value, str): return value.lower() in {"1", "true", "yes", "on"} return bool(value) def _int(value: Any, default: int = 0, lo: int = 0, hi: int = 10**9) -> int: try: return max(lo, min(hi, int(value))) except Exception: return default def _hourly_schedule(value: Any) -> list[dict]: rows = value if isinstance(value, list) else [] by_hour: dict[int, dict] = {} for item in rows: if not isinstance(item, dict): continue try: hour = int(item.get("hour")) except Exception: continue if hour < 0 or hour > 23: continue by_hour[hour] = {"hour": hour, "down": _int(item.get("down"), 0), "up": _int(item.get("up"), 0)} return [by_hour.get(hour, {"hour": hour, "down": 0, "up": 0}) for hour in range(24)] def _hourly_limit_for(settings: dict, hour: int) -> tuple[int, int] | None: if not settings.get("hourly_schedule_enabled"): return None rows = settings.get("hourly_schedule") or [] for item in rows: if int(item.get("hour", -1)) == int(hour): return int(item.get("down") or 0), int(item.get("up") or 0) return 0, 0 def _time_minutes(value: str, fallback: str) -> int: text = str(value or fallback).strip() try: hh, mm = text.split(":", 1) return max(0, min(1439, int(hh) * 60 + int(mm))) except Exception: hh, mm = fallback.split(":", 1) return int(hh) * 60 + int(mm) def _in_window(now_min: int, start: str, end: str) -> bool: s = _time_minutes(start, "00:00") e = _time_minutes(end, "00:00") if s == e: return True if s < e: return s <= now_min < e return now_min >= s or now_min < e def normalize(data: dict | None) -> dict: raw = {**DEFAULTS, **(data or {})} return { "enabled": _bool(raw.get("enabled")), "name": str(raw.get("name") or DEFAULTS["name"]).strip()[:120], "profile_name": str(raw.get("profile_name") or raw.get("name") or DEFAULTS["profile_name"]).strip()[:80], "dry_run": _bool(raw.get("dry_run")), "manual_override_until": str(raw.get("manual_override_until") or "")[:40], "night_only_enabled": _bool(raw.get("night_only_enabled")), "night_start": str(raw.get("night_start") or DEFAULTS["night_start"])[:5], "night_end": str(raw.get("night_end") or DEFAULTS["night_end"])[:5], "quiet_hours_enabled": _bool(raw.get("quiet_hours_enabled")), "quiet_start": str(raw.get("quiet_start") or DEFAULTS["quiet_start"])[:5], "quiet_end": str(raw.get("quiet_end") or DEFAULTS["quiet_end"])[:5], "weekday_down": _int(raw.get("weekday_down"), 0), "weekday_up": _int(raw.get("weekday_up"), 0), "weekend_down": _int(raw.get("weekend_down"), 0), "weekend_up": _int(raw.get("weekend_up"), 0), "hourly_schedule_enabled": _bool(raw.get("hourly_schedule_enabled")), "hourly_schedule": _hourly_schedule(raw.get("hourly_schedule")), "auto_pause_cpu_enabled": _bool(raw.get("auto_pause_cpu_enabled")), "auto_pause_cpu_percent": _int(raw.get("auto_pause_cpu_percent"), 90, 1, 100), "auto_pause_disk_enabled": _bool(raw.get("auto_pause_disk_enabled")), "auto_pause_disk_percent": _int(raw.get("auto_pause_disk_percent"), 95, 1, 100), "network_protection_enabled": _bool(raw.get("network_protection_enabled")), "network_max_down": _int(raw.get("network_max_down"), 0), "network_max_up": _int(raw.get("network_max_up"), 0), "load_protection_enabled": _bool(raw.get("load_protection_enabled")), "load_cpu_percent": _int(raw.get("load_cpu_percent"), 95, 1, 100), "auto_resume": _bool(raw.get("auto_resume")), "auto_resume_grace_seconds": _int(raw.get("auto_resume_grace_seconds"), 0, 0, 86400), "check_interval_seconds": _int(raw.get("check_interval_seconds"), 30, 10, 3600), } def _row(user_id: int, profile_id: int) -> dict | None: with connect() as conn: return conn.execute( "SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?", (user_id, profile_id), ).fetchone() def _preference_row_for_disk_source(profile_id: int, user_id: int | None = None) -> dict | None: from . import preferences user_id = user_id or default_user_id() return preferences.get_disk_monitor_preferences(profile_id, user_id) def _legacy_disk_guard_defaults(profile_id: int, user_id: int | None = None) -> dict: pref = _preference_row_for_disk_source(profile_id, user_id) if not pref or not pref.get("disk_monitor_stop_enabled"): return {} return { "enabled": True, "auto_pause_disk_enabled": True, "auto_pause_disk_percent": _int(pref.get("disk_monitor_stop_threshold"), 95, 1, 100), "auto_resume": True, } def _history_key(profile_id: int) -> str: return f"download_planner.history.{int(profile_id)}" def _override_key(profile_id: int) -> str: return f"download_planner.override_until.{int(profile_id)}" def _parse_iso_ts(value: str | None) -> float: if not value: return 0.0 try: text = str(value).replace("Z", "+00:00") return datetime.fromisoformat(text).timestamp() except Exception: return 0.0 def _override_until(profile_id: int) -> str: with connect() as conn: row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_override_key(profile_id),)).fetchone() return str(row.get("value") or "") if row else "" def set_manual_override(profile_id: int, seconds: int) -> dict: until = "" seconds = _int(seconds, 0, 0, 86400) if seconds: until = datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat() with connect() as conn: conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_override_key(profile_id), until)) return {"manual_override_until": until, "seconds": seconds} def _append_history(profile_id: int, event: str, payload: dict | None = None) -> None: payload = payload or {} with connect() as conn: row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_history_key(profile_id),)).fetchone() try: items = json.loads(row.get("value") or "[]") if row else [] except Exception: items = [] items.append({"at": utcnow(), "event": str(event), **payload}) items = items[-80:] conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_history_key(profile_id), json.dumps(items))) def _history_items(profile_id: int) -> list[dict]: with connect() as conn: row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_history_key(profile_id),)).fetchone() try: items = json.loads(row.get("value") or "[]") if row else [] except Exception: items = [] return items if isinstance(items, list) else [] def history(profile_id: int, limit: int = 40) -> list[dict]: items = _history_items(profile_id) return list(reversed(items[-max(1, min(200, int(limit))):])) def history_count(profile_id: int) -> int: return len(_history_items(profile_id)) def clear_history(profile_id: int) -> int: deleted = history_count(profile_id) with connect() as conn: # Note: Planner history is stored per profile in app_settings; clearing it does not change saved Planner rules. conn.execute("DELETE FROM app_settings WHERE key=?", (_history_key(profile_id),)) return deleted def _profile_label(settings: dict) -> str: return str(settings.get("profile_name") or settings.get("name") or "Planner") def _next_boundary(now: datetime, settings: dict) -> str: candidates: list[datetime] = [] for hour in range(24): if settings.get("hourly_schedule_enabled"): dt = now.replace(hour=hour, minute=0, second=0, microsecond=0) if dt <= now: dt = dt + __import__("datetime").timedelta(days=1) candidates.append(dt) for key in ("night_start", "night_end", "quiet_start", "quiet_end"): value = settings.get(key) if not value: continue minute = _time_minutes(str(value), "00:00") dt = now.replace(hour=minute // 60, minute=minute % 60, second=0, microsecond=0) if dt <= now: dt = dt.replace(day=dt.day) + __import__("datetime").timedelta(days=1) candidates.append(dt) return min(candidates).isoformat() if candidates else "" def get_settings(profile_id: int, user_id: int | None = None) -> dict: user_id = user_id or default_user_id() row = _row(user_id, profile_id) if not row: migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)}) return {**migrated, "profile_id": int(profile_id), "user_id": int(user_id)} try: data = json.loads(row.get("settings_json") or "{}") except Exception: data = {} settings = {**normalize(data), "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": row.get("updated_at")} runtime_override = _override_until(int(profile_id)) if runtime_override: settings["manual_override_until"] = runtime_override return settings def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict: user_id = user_id or default_user_id() settings = normalize(data) now = utcnow() with connect() as conn: conn.execute( """ INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at) VALUES(?,?,?,?) ON CONFLICT(user_id, profile_id) DO UPDATE SET settings_json=excluded.settings_json, updated_at=excluded.updated_at """, (user_id, profile_id, json.dumps(settings), now), ) return {**settings, "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": now} def _active_downloading_hashes(profile: dict) -> list[str]: rows = rtorrent.list_torrents(profile) hashes: list[str] = [] for row in rows: if int(row.get("complete") or 0): continue if int(row.get("state") or 0) and not row.get("paused"): h = str(row.get("hash") or "") if h: hashes.append(h) return hashes def _remember_paused(profile_id: int, hashes: list[str], reason: str) -> None: if not hashes: return now = utcnow() with connect() as conn: for h in hashes: conn.execute( "INSERT OR REPLACE INTO download_plan_paused(profile_id,torrent_hash,reason,created_at,updated_at) VALUES(?,?,?,?,?)", (profile_id, h, reason, now, now), ) def _planned_paused(profile_id: int) -> list[str]: with connect() as conn: rows = conn.execute("SELECT torrent_hash FROM download_plan_paused WHERE profile_id=?", (profile_id,)).fetchall() return [str(row.get("torrent_hash") or "") for row in rows if row.get("torrent_hash")] def _clear_planned(profile_id: int, hashes: list[str] | None = None) -> None: with connect() as conn: if hashes: conn.executemany("DELETE FROM download_plan_paused WHERE profile_id=? AND torrent_hash=?", [(profile_id, h) for h in hashes]) else: conn.execute("DELETE FROM download_plan_paused WHERE profile_id=?", (profile_id,)) def disk_usage(profile: dict, user_id: int | None = None) -> dict | None: profile_id = int(profile.get("id") or 0) pref = _preference_row_for_disk_source(profile_id, user_id) or {} try: paths = json.loads(pref.get("disk_monitor_paths_json") or "[]") except Exception: paths = [] if not isinstance(paths, list): paths = [] try: return rtorrent.disk_usage_for_paths( profile, [str(p) for p in paths if str(p or "").strip()], str(pref.get("disk_monitor_mode") or "default"), str(pref.get("disk_monitor_selected_path") or ""), ) except Exception: return None def _disk_percent(profile: dict, user_id: int | None = None) -> float | None: usage = disk_usage(profile, user_id) if usage and usage.get("ok"): return float(usage.get("percent") or 0) return None def evaluate(profile: dict, settings: dict | None = None, now: datetime | None = None) -> dict: settings = normalize(settings or get_settings(int(profile.get("id") or 0))) now = now or datetime.now().astimezone() override_until = settings.get("manual_override_until") or _override_until(int(profile.get("id") or 0)) override_active = bool(_parse_iso_ts(override_until) > time.time()) now_min = now.hour * 60 + now.minute weekend = now.weekday() >= 5 reasons: list[str] = [] pause_downloads = False quiet = bool(settings["quiet_hours_enabled"] and _in_window(now_min, settings["quiet_start"], settings["quiet_end"])) in_night = _in_window(now_min, settings["night_start"], settings["night_end"]) if quiet: pause_downloads = True reasons.append("quiet_hours") if settings["night_only_enabled"] and not in_night: pause_downloads = True reasons.append("outside_night_window") hourly_limits = _hourly_limit_for(settings, now.hour) if hourly_limits is not None: down, up = hourly_limits reasons.append("hourly_schedule") else: down = int(settings["weekend_down"] if weekend else settings["weekday_down"]) up = int(settings["weekend_up"] if weekend else settings["weekday_up"]) if quiet or pause_downloads: down = 0 cpu = None if settings["load_protection_enabled"]: cpu_load = float(psutil.cpu_percent(interval=None)) if cpu_load >= float(settings["load_cpu_percent"]): pause_downloads = True reasons.append("high_load") if settings["auto_pause_cpu_enabled"]: cpu = float(psutil.cpu_percent(interval=None)) pid = int(profile.get("id") or 0) if cpu >= float(settings["auto_pause_cpu_percent"]): _HIGH_CPU_SINCE.setdefault(pid, time.monotonic()) if time.monotonic() - _HIGH_CPU_SINCE[pid] >= 10: pause_downloads = True reasons.append("high_cpu") else: _HIGH_CPU_SINCE.pop(pid, None) disk = None if settings["auto_pause_disk_enabled"]: disk = _disk_percent(profile, int(settings.get("user_id") or default_user_id())) if disk is not None and disk >= float(settings["auto_pause_disk_percent"]): pause_downloads = True reasons.append("high_disk") if settings["network_protection_enabled"]: nd = int(settings.get("network_max_down") or 0) nu = int(settings.get("network_max_up") or 0) if nd and (not down or down > nd): down = nd reasons.append("network_limit_down") if nu and (not up or up > nu): up = nu reasons.append("network_limit_up") if override_active: pause_downloads = False reasons = ["manual_override"] return { "enabled": bool(settings["enabled"]), "profile_id": int(profile.get("id") or 0), "profile_name": _profile_label(settings), "dry_run": bool(settings.get("dry_run")), "manual_override_until": override_until if override_active else "", "matched_rule": reasons[0] if reasons else ("weekend" if weekend else "weekday"), "next_change_at": _next_boundary(now, settings), "pause_downloads": pause_downloads, "reasons": reasons, "down": down, "up": up, "weekend": weekend, "quiet": quiet, "in_night_window": in_night, "cpu": cpu, "disk": disk, } def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict: profile_id = int(profile.get("id") or 0) user_id = user_id or int(profile.get("user_id") or default_user_id()) # Note: Background planner runs without Flask session state, so settings are resolved with the profile owner. settings = get_settings(profile_id, user_id) if not settings.get("enabled"): return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile, user_id=user_id)} now = time.monotonic() interval = int(settings.get("check_interval_seconds") or 30) if not force and now - _LAST_RUN.get(profile_id, 0) < interval: return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True} _LAST_RUN[profile_id] = now decision = evaluate(profile, settings) result: dict[str, Any] = {"ok": True, "enabled": True, **decision, "limits_changed": False, "paused": 0, "resumed": 0} wanted_limits = (int(decision["down"]), int(decision["up"])) dry_run = bool(settings.get("dry_run")) or bool(force and str(profile.get("dry_run") or "").lower() == "true") result["dry_run"] = dry_run if force or _LAST_LIMITS.get(profile_id) != wanted_limits: if not dry_run: rtorrent.set_limits(profile, wanted_limits[0], wanted_limits[1]) _LAST_LIMITS[profile_id] = wanted_limits result["limits_changed"] = True _append_history(profile_id, "speed_limit_change", {"down": wanted_limits[0], "up": wanted_limits[1], "dry_run": dry_run}) if decision["pause_downloads"]: hashes = _active_downloading_hashes(profile) if hashes: action = {"dry_run": True} if dry_run else rtorrent.action(profile, hashes, "pause", {"source": "download_planner", "reasons": decision["reasons"]}) if not dry_run: _remember_paused(profile_id, hashes, ",".join(decision["reasons"])) result["paused"] = len(hashes) result["pause_result"] = action _append_history(profile_id, "paused_torrents", {"count": len(hashes), "reasons": decision["reasons"], "dry_run": dry_run}) if "high_cpu" in decision["reasons"] or "high_load" in decision["reasons"]: _append_history(profile_id, "cpu_protection_trigger", {"cpu": decision.get("cpu"), "dry_run": dry_run}) if "high_disk" in decision["reasons"]: _append_history(profile_id, "disk_protection_trigger", {"disk": decision.get("disk"), "dry_run": dry_run}) elif settings.get("auto_resume"): grace = int(settings.get("auto_resume_grace_seconds") or 0) last_trigger = 0.0 for item in history(profile_id, 20): if item.get("event") in {"paused_torrents", "cpu_protection_trigger", "disk_protection_trigger"}: last_trigger = _parse_iso_ts(item.get("at")) break if grace and last_trigger and time.time() - last_trigger < grace: result["resume_wait_seconds"] = int(grace - (time.time() - last_trigger)) else: hashes = _planned_paused(profile_id) if hashes: action = {"dry_run": True} if dry_run else rtorrent.action(profile, hashes, "resume", {"source": "download_planner"}) if not dry_run: _clear_planned(profile_id, hashes) result["resumed"] = len(hashes) result["resume_result"] = action _append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run}) result["history"] = history(profile_id, 20) result["history_total"] = history_count(profile_id) result["preview"] = preview(profile, user_id=user_id) return result def preview(profile: dict, user_id: int | None = None) -> dict: profile_id = int(profile.get("id") or 0) user_id = user_id or int(profile.get("user_id") or default_user_id()) settings = get_settings(profile_id, user_id) decision = evaluate(profile, settings) return { "profile_id": profile_id, "profile_name": decision.get("profile_name"), "matched_rule": decision.get("matched_rule"), "next_change_at": decision.get("next_change_at"), "pause_downloads": decision.get("pause_downloads"), "down": decision.get("down"), "up": decision.get("up"), "reasons": decision.get("reasons", []), "manual_override_until": decision.get("manual_override_until", ""), "dry_run": decision.get("dry_run", False), } def start_scheduler(socketio=None) -> None: def loop(): while True: try: from .preferences import active_profile from .websocket import emit_profile_event from . import auth profiles: list[dict] if auth.enabled(): with connect() as conn: profiles = conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall() else: profile = active_profile() profiles = [profile] if profile else [] for profile in profiles: try: result = enforce(profile, force=False) if socketio and result.get("enabled") and not result.get("skipped"): emit_profile_event(socketio, "download_plan_update", result, int(profile["id"])) except Exception as exc: if socketio: emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": int(profile.get("id") or 0), "error": str(exc)}, int(profile.get("id") or 0)) except Exception: pass if socketio: socketio.sleep(30) else: time.sleep(30) if socketio: socketio.start_background_task(loop)