Trackers: section ratio/envoi/réception sous le NAS (c411, extensible)
Nouveau module integrations/trackers.py : pour chaque tracker configuré (env MONITORINK_TRACKERS + bloc par clé), récupère ratio/uploaded/downloaded. Type unit3d_nuxt (c411) : login session (CSRF meta + /api/auth/login) car le ratio n'est pas lisible au token API ; session réutilisée, résultat caché (TTL 30 min). Section dashboard sous le NAS, style instrument 1-bit. Architecture par type pour ajouter d'autres trackers ensuite.
This commit is contained in:
151
backend/integrations/trackers.py
Normal file
151
backend/integrations/trackers.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Ratio des trackers torrent privés (envoi / réception / ratio du compte).
|
||||
|
||||
Conçu pour PLUSIEURS trackers : chaque `TrackerSpec` a un `type` qui sélectionne le
|
||||
fetcher (`_FETCHERS`). v1 implémente `unit3d_nuxt` — frontend Nuxt au-dessus de UNIT3D
|
||||
(ex. c411). Le ratio n'y est PAS lisible avec une clé API : il faut une session (login).
|
||||
Flux :
|
||||
1. GET {base}/login -> pose le cookie `__csrf` + un meta `<csrf-token>`
|
||||
2. POST {base}/api/auth/login -> header `csrf-token` + {username,password} ;
|
||||
pose le cookie de session (~7 j)
|
||||
3. GET {base}/api/users/{user} -> profil JSON avec ratio/uploaded/downloaded
|
||||
La session (cookies) est réutilisée entre rendus ; on ne relogue que si elle a expiré
|
||||
(le profil revient non authentifié). Le résultat est caché (`tracker_ttl_seconds`) car
|
||||
le ratio bouge lentement et le login est coûteux.
|
||||
|
||||
Pour ajouter un tracker d'une autre techno : écrire `async def _fetch_xxx(spec)` qui
|
||||
renvoie un `TrackerStat`, et l'enregistrer dans `_FETCHERS` sous sa clé de `type`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from config import TrackerSpec, config
|
||||
|
||||
_UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
|
||||
_CSRF_META = re.compile(r'name="csrf-token"\s+content="([^"]+)"')
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerStat:
|
||||
key: str
|
||||
label: str
|
||||
ok: bool
|
||||
error: str | None = None
|
||||
ratio: float = 0.0
|
||||
up_bytes: int = 0
|
||||
down_bytes: int = 0
|
||||
|
||||
@staticmethod
|
||||
def _human(b: int) -> str:
|
||||
gb = b / 1e9
|
||||
if gb >= 1000:
|
||||
return f"{gb / 1000:.2f}".replace(".", ",") + " To"
|
||||
return f"{gb:.0f} Go"
|
||||
|
||||
@property
|
||||
def ratio_h(self) -> str:
|
||||
return f"{self.ratio:.2f}".replace(".", ",")
|
||||
|
||||
@property
|
||||
def up_h(self) -> str:
|
||||
return self._human(self.up_bytes)
|
||||
|
||||
@property
|
||||
def down_h(self) -> str:
|
||||
return self._human(self.down_bytes)
|
||||
|
||||
|
||||
# Sessions réutilisées (cookies httpx) et derniers résultats connus, par tracker.
|
||||
_sessions: dict[str, httpx.Cookies] = {}
|
||||
_cache: dict[str, dict] = {}
|
||||
|
||||
|
||||
class _AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _login_unit3d(client: httpx.AsyncClient, spec: TrackerSpec) -> None:
|
||||
"""Récupère le token CSRF de /login puis poste les identifiants ; les cookies de
|
||||
session restent dans le jar du `client`."""
|
||||
page = await client.get(f"{spec.base_url}/login")
|
||||
m = _CSRF_META.search(page.text)
|
||||
if not m:
|
||||
raise _AuthError("csrf introuvable")
|
||||
resp = await client.post(
|
||||
f"{spec.base_url}/api/auth/login",
|
||||
headers={"csrf-token": m.group(1), "Content-Type": "application/json"},
|
||||
json={"username": spec.username, "password": spec.password},
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
raise _AuthError(f"login HTTP {resp.status_code}")
|
||||
if data.get("mfaRequired"):
|
||||
raise _AuthError("2FA requise")
|
||||
if not data.get("success"):
|
||||
raise _AuthError(str(data.get("message") or "login refusé"))
|
||||
|
||||
|
||||
async def _fetch_unit3d(spec: TrackerSpec) -> TrackerStat:
|
||||
if not (spec.base_url and spec.username and spec.password):
|
||||
return TrackerStat(spec.key, spec.label, ok=False, error="non configuré")
|
||||
|
||||
cookies = _sessions.get(spec.key) or httpx.Cookies()
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20, follow_redirects=True, cookies=cookies,
|
||||
headers={"User-Agent": _UA, "Accept": "application/json"},
|
||||
) as client:
|
||||
url = f"{spec.base_url}/api/users/{spec.username}"
|
||||
resp = await client.get(url)
|
||||
data = resp.json() if resp.status_code == 200 else {}
|
||||
if "ratio" not in data: # session absente/expirée (route 404-masque sans auth)
|
||||
await _login_unit3d(client, spec)
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200 or "ratio" not in (data := resp.json()):
|
||||
raise _AuthError(f"profil indisponible (HTTP {resp.status_code})")
|
||||
_sessions[spec.key] = client.cookies
|
||||
|
||||
return TrackerStat(
|
||||
spec.key, spec.label, ok=True,
|
||||
ratio=float(data.get("ratio", 0) or 0),
|
||||
up_bytes=int(data.get("uploaded", 0) or 0),
|
||||
down_bytes=int(data.get("downloaded", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
_FETCHERS = {"unit3d_nuxt": _fetch_unit3d}
|
||||
|
||||
|
||||
async def _fetch_one(spec: TrackerSpec) -> TrackerStat:
|
||||
now = time.time()
|
||||
cached = _cache.get(spec.key)
|
||||
if cached and cached["value"].ok and now - cached["ts"] < config.tracker_ttl_seconds:
|
||||
return cached["value"]
|
||||
|
||||
fetcher = _FETCHERS.get(spec.type)
|
||||
if fetcher is None:
|
||||
return TrackerStat(spec.key, spec.label, ok=False, error=f"type {spec.type} inconnu")
|
||||
|
||||
try:
|
||||
value = await fetcher(spec)
|
||||
except (_AuthError, httpx.HTTPError, ValueError) as exc:
|
||||
_sessions.pop(spec.key, None) # session douteuse -> repartir propre au prochain coup
|
||||
if cached and cached["value"].ok:
|
||||
return cached["value"] # repli sur la dernière valeur connue
|
||||
return TrackerStat(spec.key, spec.label, ok=False, error=str(exc) or "injoignable")
|
||||
|
||||
_cache[spec.key] = {"value": value, "ts": now}
|
||||
return value
|
||||
|
||||
|
||||
async def fetch_all() -> list[TrackerStat]:
|
||||
specs = config.trackers
|
||||
if not specs:
|
||||
return []
|
||||
return list(await asyncio.gather(*(_fetch_one(s) for s in specs)))
|
||||
Reference in New Issue
Block a user