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:
12
.env.example
12
.env.example
@@ -37,6 +37,18 @@ MONITORINK_NAS_URL=http://192.168.0.43:8766/api/status
|
|||||||
# Hermes monté en lecture seule (cf. docker-compose.yml). Laisser vide pour masquer Codex.
|
# Hermes monté en lecture seule (cf. docker-compose.yml). Laisser vide pour masquer Codex.
|
||||||
MONITORINK_CODEX_TOKEN_FILE=/hermes/auth.json
|
MONITORINK_CODEX_TOKEN_FILE=/hermes/auth.json
|
||||||
|
|
||||||
|
# Trackers torrent privés (optionnel) — ratio/envoi/réception du compte, sous le NAS.
|
||||||
|
# Liste des clés actives, puis UN bloc par tracker. v1 : type "unit3d_nuxt" (ex. c411).
|
||||||
|
# Le ratio n'est PAS lisible au token API -> login requis (username/password du compte).
|
||||||
|
# Laisser MONITORINK_TRACKERS vide pour masquer la section.
|
||||||
|
#MONITORINK_TRACKERS=c411
|
||||||
|
#MONITORINK_TRACKER_C411_LABEL=c411
|
||||||
|
#MONITORINK_TRACKER_C411_TYPE=unit3d_nuxt
|
||||||
|
#MONITORINK_TRACKER_C411_URL=https://c411.org
|
||||||
|
#MONITORINK_TRACKER_C411_USER=TonUsername
|
||||||
|
#MONITORINK_TRACKER_C411_PASS=TonMotDePasse
|
||||||
|
#MONITORINK_TRACKER_TTL=1800
|
||||||
|
|
||||||
# Home Assistant (optionnel) — laisser vide pour désactiver
|
# Home Assistant (optionnel) — laisser vide pour désactiver
|
||||||
MONITORINK_HA_URL=http://homeassistant.local:8123
|
MONITORINK_HA_URL=http://homeassistant.local:8123
|
||||||
MONITORINK_HA_TOKEN=
|
MONITORINK_HA_TOKEN=
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ class HAEntity:
|
|||||||
return cls(entity_id=entity_id, label=label, unit=unit)
|
return cls(entity_id=entity_id, label=label, unit=unit)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TrackerSpec:
|
||||||
|
"""Un tracker torrent privé dont on affiche le ratio. `type` choisit le fetcher
|
||||||
|
(v1 : `unit3d_nuxt`). Identifiants jamais versionnés (lus depuis `.env`)."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
base_url: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
type: str = "unit3d_nuxt"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Config:
|
class Config:
|
||||||
# --- Affichage ---
|
# --- Affichage ---
|
||||||
@@ -84,6 +97,13 @@ class Config:
|
|||||||
default_factory=lambda: _get("MONITORINK_CODEX_TOKEN_FILE", "/hermes/auth.json")
|
default_factory=lambda: _get("MONITORINK_CODEX_TOKEN_FILE", "/hermes/auth.json")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Trackers torrent privés (ratio du compte) ---
|
||||||
|
# Le ratio change lentement et le login est coûteux (CSRF + session) : on cache
|
||||||
|
# le résultat plus longtemps que le reste (défaut 30 min).
|
||||||
|
tracker_ttl_seconds: int = field(
|
||||||
|
default_factory=lambda: int(_get("MONITORINK_TRACKER_TTL", "1800"))
|
||||||
|
)
|
||||||
|
|
||||||
# --- Cache / rafraîchissement serveur ---
|
# --- Cache / rafraîchissement serveur ---
|
||||||
cache_ttl_seconds: int = field(
|
cache_ttl_seconds: int = field(
|
||||||
default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120"))
|
default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120"))
|
||||||
@@ -106,6 +126,26 @@ class Config:
|
|||||||
def ha_entities(self) -> list[HAEntity]:
|
def ha_entities(self) -> list[HAEntity]:
|
||||||
return [HAEntity.parse(s) for s in _get_list("MONITORINK_HA_ENTITIES")]
|
return [HAEntity.parse(s) for s in _get_list("MONITORINK_HA_ENTITIES")]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trackers(self) -> list[TrackerSpec]:
|
||||||
|
"""Trackers actifs : `MONITORINK_TRACKERS=c411,autre` + un bloc d'env par clé,
|
||||||
|
ex. `MONITORINK_TRACKER_C411_{URL,USER,PASS,LABEL,TYPE}`."""
|
||||||
|
specs: list[TrackerSpec] = []
|
||||||
|
for key in _get_list("MONITORINK_TRACKERS"):
|
||||||
|
k = key.upper().replace("-", "_")
|
||||||
|
base = _get(f"MONITORINK_TRACKER_{k}_URL").rstrip("/")
|
||||||
|
if not base:
|
||||||
|
continue
|
||||||
|
specs.append(TrackerSpec(
|
||||||
|
key=key,
|
||||||
|
label=_get(f"MONITORINK_TRACKER_{k}_LABEL", key),
|
||||||
|
base_url=base,
|
||||||
|
username=_get(f"MONITORINK_TRACKER_{k}_USER"),
|
||||||
|
password=_get(f"MONITORINK_TRACKER_{k}_PASS"),
|
||||||
|
type=_get(f"MONITORINK_TRACKER_{k}_TYPE", "unit3d_nuxt"),
|
||||||
|
))
|
||||||
|
return specs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ha_enabled(self) -> bool:
|
def ha_enabled(self) -> bool:
|
||||||
return bool(self.ha_base_url and self.ha_token)
|
return bool(self.ha_base_url and self.ha_token)
|
||||||
|
|||||||
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)))
|
||||||
@@ -14,7 +14,7 @@ from playwright.async_api import async_playwright
|
|||||||
|
|
||||||
from config import config
|
from config import config
|
||||||
from fonts import font_face_css
|
from fonts import font_face_css
|
||||||
from integrations import claude_usage, codex, homeassistant, kobo, nas, weather
|
from integrations import claude_usage, codex, homeassistant, kobo, nas, trackers, weather
|
||||||
|
|
||||||
TEMPLATES = Path(__file__).parent / "templates"
|
TEMPLATES = Path(__file__).parent / "templates"
|
||||||
|
|
||||||
@@ -55,12 +55,13 @@ def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]:
|
|||||||
|
|
||||||
async def build_context() -> dict:
|
async def build_context() -> dict:
|
||||||
"""Récupère toutes les sources en parallèle et assemble le contexte du template."""
|
"""Récupère toutes les sources en parallèle et assemble le contexte du template."""
|
||||||
usage, wx, ha, nas_status, codex_status = await asyncio.gather(
|
usage, wx, ha, nas_status, codex_status, tracker_stats = await asyncio.gather(
|
||||||
claude_usage.fetch_usage(),
|
claude_usage.fetch_usage(),
|
||||||
weather.fetch_weather(),
|
weather.fetch_weather(),
|
||||||
homeassistant.fetch_states(),
|
homeassistant.fetch_states(),
|
||||||
nas.fetch_status(),
|
nas.fetch_status(),
|
||||||
codex.fetch_status(),
|
codex.fetch_status(),
|
||||||
|
trackers.fetch_all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(ZoneInfo(config.timezone))
|
now = datetime.now(ZoneInfo(config.timezone))
|
||||||
@@ -77,6 +78,7 @@ async def build_context() -> dict:
|
|||||||
"ha_states": ha,
|
"ha_states": ha,
|
||||||
"nas": nas_status,
|
"nas": nas_status,
|
||||||
"codex": codex_status,
|
"codex": codex_status,
|
||||||
|
"trackers": tracker_stats,
|
||||||
"kobo": kobo.current(),
|
"kobo": kobo.current(),
|
||||||
"updated": now.strftime("%H:%M"),
|
"updated": now.strftime("%H:%M"),
|
||||||
"stale": False,
|
"stale": False,
|
||||||
|
|||||||
@@ -65,6 +65,14 @@
|
|||||||
.rows.grid2 .k { font-size: 28px; } .rows.grid2 .v { font-size: 32px; }
|
.rows.grid2 .k { font-size: 28px; } .rows.grid2 .v { font-size: 32px; }
|
||||||
.ko { display: inline-block; padding: 0 8px; background: var(--ink); color: var(--paper); }
|
.ko { display: inline-block; padding: 0 8px; background: var(--ink); color: var(--paper); }
|
||||||
|
|
||||||
|
/* ---- Trackers (ratio + envoi/réception du compte, sous le NAS) ---- */
|
||||||
|
.trk { padding: 13px 0; border-bottom: 2px solid var(--ink); }
|
||||||
|
.trk:last-child { border-bottom: 0; }
|
||||||
|
.trk .top { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; }
|
||||||
|
.trk .name { font-weight: 700; font-size: 31px; }
|
||||||
|
.trk .ratio { font-size: 46px; font-weight: 800; line-height: .9; }
|
||||||
|
.trk .io { font-size: 25px; font-weight: 500; margin-top: 5px; }
|
||||||
|
|
||||||
/* ============================ JAUGE (signature) ============================
|
/* ============================ JAUGE (signature) ============================
|
||||||
Barre de progression intuitive : le noir se REMPLIT de gauche à droite avec la
|
Barre de progression intuitive : le noir se REMPLIT de gauche à droite avec la
|
||||||
consommation ; le blanc à droite = budget restant. Repère ▼ = ligne d'alerte
|
consommation ; le blanc à droite = budget restant. Repère ▼ = ligne d'alerte
|
||||||
@@ -196,6 +204,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if trackers %}
|
||||||
|
<hr class="div">
|
||||||
|
<div class="label"><span class="t">Trackers</span></div>
|
||||||
|
<div class="rows">
|
||||||
|
{% for t in trackers %}
|
||||||
|
<div class="trk">
|
||||||
|
<div class="top">
|
||||||
|
<span class="name">{{ t.label }}</span>
|
||||||
|
{% if t.ok %}<span class="ratio num">{{ t.ratio_h }}</span>
|
||||||
|
{% else %}<span class="io"><span class="ko">{{ t.error }}</span></span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if t.ok %}<div class="io num">envoyé {{ t.up_h }} · reçu {{ t.down_h }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if ha_states %}
|
{% if ha_states %}
|
||||||
<hr class="div">
|
<hr class="div">
|
||||||
<div class="label"><span class="t">Maison</span></div>
|
<div class="label"><span class="t">Maison</span></div>
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ CTX = {
|
|||||||
"docker_running": 18, "docker_total": 19, "docker_unhealthy": 1,
|
"docker_running": 18, "docker_total": 19, "docker_unhealthy": 1,
|
||||||
"vpn_ok": True, "vpn_port": 51820,
|
"vpn_ok": True, "vpn_port": 51820,
|
||||||
},
|
},
|
||||||
|
"trackers": [
|
||||||
|
{"ok": True, "label": "c411", "ratio_h": "1,04", "up_h": "378 Go", "down_h": "365 Go"},
|
||||||
|
{"ok": True, "label": "torr9", "ratio_h": "0,82", "up_h": "1,21 To", "down_h": "1,47 To"},
|
||||||
|
{"ok": False, "label": "yggreborn", "error": "2FA requise"},
|
||||||
|
],
|
||||||
"ha_states": [
|
"ha_states": [
|
||||||
{"label": "Salon", "display": "21°"},
|
{"label": "Salon", "display": "21°"},
|
||||||
{"label": "Chambre", "display": "19°"},
|
{"label": "Chambre", "display": "19°"},
|
||||||
|
|||||||
Reference in New Issue
Block a user