147 lines
7.0 KiB
Python
147 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
from ..db import connect, utcnow, default_user_id
|
|
from . import rtorrent
|
|
from .workers import enqueue
|
|
|
|
|
|
def _age_minutes_from_epoch(value) -> int:
|
|
try:
|
|
created = datetime.fromtimestamp(int(value or 0), timezone.utc)
|
|
return max(0, int((datetime.now(timezone.utc) - created).total_seconds() // 60))
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def _is_private(profile: dict, torrent_hash: str) -> bool:
|
|
try:
|
|
value = rtorrent.client_for(profile).call("d.is_private", torrent_hash)
|
|
return bool(int(value or 0))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _group_for_torrent(groups_by_name: dict[str, dict], torrent: dict) -> dict | None:
|
|
name = str(torrent.get("ratio_group") or "").strip()
|
|
return groups_by_name.get(name) if name else None
|
|
|
|
|
|
def _record(user_id: int, profile_id: int, group: dict, torrent: dict, action: str, status: str, reason: str, details: dict | None = None) -> None:
|
|
now = utcnow()
|
|
with connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO ratio_history(user_id,profile_id,group_id,group_name,torrent_hash,torrent_name,action,status,reason,details_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
|
(user_id, profile_id, group.get("id"), group.get("name"), torrent.get("hash"), torrent.get("name"), action, status, reason, json.dumps(details or {}), now),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO ratio_assignments(profile_id,torrent_hash,group_id,group_name,applied_at,last_status,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(profile_id,torrent_hash) DO UPDATE SET group_id=excluded.group_id,group_name=excluded.group_name,applied_at=excluded.applied_at,last_status=excluded.last_status,updated_at=excluded.updated_at",
|
|
(profile_id, torrent.get("hash"), group.get("id"), group.get("name"), now if status == "applied" else None, status, now),
|
|
)
|
|
|
|
|
|
def _should_apply(profile: dict, group: dict, torrent: dict) -> tuple[bool, str]:
|
|
if not int(group.get("enabled") or 0):
|
|
return False, "group disabled"
|
|
if not torrent.get("complete"):
|
|
return False, "torrent is not complete"
|
|
if int(group.get("ignore_private") or 0) and _is_private(profile, torrent["hash"]):
|
|
return False, "private torrent is excluded"
|
|
min_ratio = float(group.get("min_ratio") or 0)
|
|
max_ratio = float(group.get("max_ratio") or 0)
|
|
wanted_ratio = max(min_ratio, max_ratio)
|
|
seed_time = max(int(group.get("seed_time_minutes") or 0), int(group.get("min_seed_time_minutes") or 0))
|
|
ratio_ok = float(torrent.get("ratio") or 0) >= wanted_ratio if wanted_ratio else True
|
|
seed_ok = _age_minutes_from_epoch(torrent.get("created")) >= seed_time if seed_time else True
|
|
if not ratio_ok:
|
|
return False, "ratio threshold not reached"
|
|
if not seed_ok:
|
|
return False, "minimum seed time not reached"
|
|
min_upload = int(group.get("active_upload_min_bytes") or 1024)
|
|
if int(group.get("ignore_active_upload") or 0) and int(torrent.get("up_rate") or 0) >= min_upload:
|
|
return False, "active upload is above exception threshold"
|
|
return True, "ratio rule applied"
|
|
|
|
|
|
def check(profile: dict, user_id: int | None = None) -> dict:
|
|
user_id = user_id or default_user_id()
|
|
profile_id = int(profile["id"])
|
|
with connect() as conn:
|
|
groups = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
|
already = {row["torrent_hash"] for row in conn.execute("SELECT torrent_hash FROM ratio_assignments WHERE profile_id=? AND last_status='applied'", (profile_id,)).fetchall()}
|
|
groups_by_name = {str(g.get("name") or ""): g for g in groups}
|
|
applied = 0
|
|
skipped = 0
|
|
queued_jobs = []
|
|
for torrent in rtorrent.list_torrents(profile):
|
|
group = _group_for_torrent(groups_by_name, torrent)
|
|
if not group:
|
|
continue
|
|
if torrent.get("hash") in already:
|
|
skipped += 1
|
|
continue
|
|
ok, reason = _should_apply(profile, group, torrent)
|
|
if not ok:
|
|
skipped += 1
|
|
with connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO ratio_assignments(profile_id,torrent_hash,group_id,group_name,last_status,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(profile_id,torrent_hash) DO UPDATE SET group_id=excluded.group_id,group_name=excluded.group_name,last_status=excluded.last_status,updated_at=excluded.updated_at",
|
|
(profile_id, torrent.get("hash"), group.get("id"), group.get("name"), reason, utcnow()),
|
|
)
|
|
continue
|
|
action = str(group.get("action") or "stop")
|
|
payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}}
|
|
if action == "remove_data":
|
|
api_action = "remove"
|
|
payload["remove_data"] = True
|
|
elif action == "move":
|
|
api_action = "move"
|
|
payload.update({"path": group.get("move_path") or torrent.get("path") or "", "move_data": True, "recheck": False, "keep_seeding": False})
|
|
elif action == "set_label":
|
|
api_action = "set_label"
|
|
payload["label"] = group.get("set_label") or group.get("name") or ""
|
|
else:
|
|
api_action = action if action in {"stop", "remove", "pause"} else "stop"
|
|
job_id = enqueue(api_action, profile_id, payload, user_id=user_id)
|
|
queued_jobs.append(job_id)
|
|
applied += 1
|
|
_record(user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action})
|
|
return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs}
|
|
|
|
|
|
_scheduler_started = False
|
|
|
|
|
|
def start_scheduler(socketio=None) -> None:
|
|
global _scheduler_started
|
|
if _scheduler_started:
|
|
return
|
|
_scheduler_started = True
|
|
|
|
def loop() -> None:
|
|
# Note: Ratio rules are evaluated periodically and actions are executed through the existing safe job queue.
|
|
while True:
|
|
try:
|
|
from .preferences import get_profile
|
|
with connect() as conn:
|
|
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM ratio_groups 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"]))
|
|
if not profile:
|
|
continue
|
|
result = check(profile, int(row["user_id"]))
|
|
if socketio and result.get("applied"):
|
|
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
|
except Exception:
|
|
pass
|
|
time.sleep(300)
|
|
|
|
if socketio:
|
|
socketio.start_background_task(loop)
|
|
else:
|
|
import threading
|
|
threading.Thread(target=loop, daemon=True, name="pytorrent-ratio-scheduler").start()
|