Files
pyTorrent/pytorrent/services/ratio_rules.py
2026-05-19 13:43:37 +00:00

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