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:
jerem
2026-06-17 10:04:30 +02:00
parent b1229e3dcc
commit 4680092f8a
6 changed files with 237 additions and 2 deletions

View File

@@ -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=

View File

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

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

View File

@@ -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,

View File

@@ -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>

View File

@@ -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°"},