first commit
This commit is contained in:
117
pytorrent/services/traffic_history.py
Normal file
117
pytorrent/services/traffic_history.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect, utcnow
|
||||
from . import retention
|
||||
|
||||
_LAST_WRITE: dict[int, float] = {}
|
||||
WRITE_EVERY_SECONDS = 60
|
||||
|
||||
|
||||
def _now_ts() -> float:
|
||||
return datetime.now(timezone.utc).timestamp()
|
||||
|
||||
|
||||
def record(profile_id: int, down_rate: int = 0, up_rate: int = 0, total_down: int = 0, total_up: int = 0, force: bool = False) -> None:
|
||||
"""Store compact transfer samples. One sample per minute per profile keeps SQLite small."""
|
||||
profile_id = int(profile_id)
|
||||
now_ts = _now_ts()
|
||||
if not force and now_ts - _LAST_WRITE.get(profile_id, 0.0) < WRITE_EVERY_SECONDS:
|
||||
return
|
||||
_LAST_WRITE[profile_id] = now_ts
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO traffic_history(profile_id,down_rate,up_rate,total_down,total_up,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(profile_id, int(down_rate or 0), int(up_rate or 0), int(total_down or 0), int(total_up or 0), utcnow()),
|
||||
)
|
||||
retention.cleanup()
|
||||
|
||||
|
||||
def _range_to_cutoff(range_name: str) -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
if range_name == "15m":
|
||||
return now - timedelta(minutes=15)
|
||||
if range_name == "1h":
|
||||
return now - timedelta(hours=1)
|
||||
if range_name == "3h":
|
||||
return now - timedelta(hours=3)
|
||||
if range_name == "6h":
|
||||
return now - timedelta(hours=6)
|
||||
if range_name == "24h":
|
||||
return now - timedelta(hours=24)
|
||||
if range_name == "30d":
|
||||
return now - timedelta(days=30)
|
||||
if range_name == "90d":
|
||||
return now - timedelta(days=90)
|
||||
return now - timedelta(days=7)
|
||||
|
||||
|
||||
def _bucket_for(range_name: str) -> str:
|
||||
if range_name in {"15m", "1h", "3h"}:
|
||||
return "%Y-%m-%d %H:%M"
|
||||
if range_name in {"6h", "24h"}:
|
||||
return "%Y-%m-%d %H:00"
|
||||
return "%Y-%m-%d"
|
||||
|
||||
|
||||
def _row_value(row: Any, key: str, index: int, default: Any = 0) -> Any:
|
||||
# connect() uses dict_factory, so SQLite rows are dicts. The fallback keeps
|
||||
# this function compatible with tuple/list rows in tests or future refactors.
|
||||
if isinstance(row, dict):
|
||||
return row.get(key, default)
|
||||
try:
|
||||
return row[index]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def history(profile_id: int, range_name: str = "7d") -> dict[str, Any]:
|
||||
cutoff = _range_to_cutoff(range_name)
|
||||
bucket = _bucket_for(range_name)
|
||||
cutoff_s = cutoff.isoformat(timespec="seconds")
|
||||
bucket_name = "minute" if range_name in {"15m", "1h", "3h"} else ("hour" if range_name in {"6h", "24h"} else "day")
|
||||
with connect() as conn:
|
||||
raw = conn.execute(
|
||||
"""
|
||||
SELECT down_rate, up_rate, total_down, total_up, created_at
|
||||
FROM traffic_history
|
||||
WHERE profile_id=? AND created_at >= ?
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(int(profile_id), cutoff_s),
|
||||
).fetchall()
|
||||
|
||||
rows_by_bucket: dict[str, dict[str, Any]] = {}
|
||||
prev_down = prev_up = None
|
||||
for r in raw:
|
||||
created = str(_row_value(r, "created_at", 4, ""))
|
||||
try:
|
||||
dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
continue
|
||||
b = dt.strftime(bucket)
|
||||
item = rows_by_bucket.setdefault(b, {"bucket": b, "avg_down_rate": 0, "avg_up_rate": 0, "downloaded": 0, "uploaded": 0, "samples": 0})
|
||||
down_rate = int(_row_value(r, "down_rate", 0, 0) or 0)
|
||||
up_rate = int(_row_value(r, "up_rate", 1, 0) or 0)
|
||||
total_down = int(_row_value(r, "total_down", 2, 0) or 0)
|
||||
total_up = int(_row_value(r, "total_up", 3, 0) or 0)
|
||||
item["avg_down_rate"] += down_rate
|
||||
item["avg_up_rate"] += up_rate
|
||||
item["samples"] += 1
|
||||
if prev_down is not None and total_down >= prev_down:
|
||||
item["downloaded"] += total_down - prev_down
|
||||
if prev_up is not None and total_up >= prev_up:
|
||||
item["uploaded"] += total_up - prev_up
|
||||
prev_down, prev_up = total_down, total_up
|
||||
|
||||
rows = []
|
||||
for item in rows_by_bucket.values():
|
||||
samples = max(1, int(item["samples"] or 1))
|
||||
item["avg_down_rate"] = round(item["avg_down_rate"] / samples)
|
||||
item["avg_up_rate"] = round(item["avg_up_rate"] / samples)
|
||||
rows.append(item)
|
||||
rows.sort(key=lambda x: x["bucket"])
|
||||
return {"range": range_name, "bucket": bucket_name, "retention_days": TRAFFIC_HISTORY_RETENTION_DAYS, "rows": rows}
|
||||
Reference in New Issue
Block a user