diff --git a/.env.example b/.env.example index feef5a6..7090dcf 100644 --- a/.env.example +++ b/.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. 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 MONITORINK_HA_URL=http://homeassistant.local:8123 MONITORINK_HA_TOKEN= diff --git a/backend/config.py b/backend/config.py index db78d53..b05d803 100644 --- a/backend/config.py +++ b/backend/config.py @@ -39,6 +39,19 @@ class HAEntity: 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) class Config: # --- Affichage --- @@ -84,6 +97,13 @@ class Config: 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_ttl_seconds: int = field( default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120")) @@ -106,6 +126,26 @@ class Config: def ha_entities(self) -> list[HAEntity]: 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 def ha_enabled(self) -> bool: return bool(self.ha_base_url and self.ha_token) diff --git a/backend/integrations/trackers.py b/backend/integrations/trackers.py new file mode 100644 index 0000000..0a7d559 --- /dev/null +++ b/backend/integrations/trackers.py @@ -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 `` + 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))) diff --git a/backend/render.py b/backend/render.py index f06bae7..c5dfd0f 100644 --- a/backend/render.py +++ b/backend/render.py @@ -14,7 +14,7 @@ from playwright.async_api import async_playwright from config import config 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" @@ -55,12 +55,13 @@ def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]: async def build_context() -> dict: """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(), weather.fetch_weather(), homeassistant.fetch_states(), nas.fetch_status(), codex.fetch_status(), + trackers.fetch_all(), ) now = datetime.now(ZoneInfo(config.timezone)) @@ -77,6 +78,7 @@ async def build_context() -> dict: "ha_states": ha, "nas": nas_status, "codex": codex_status, + "trackers": tracker_stats, "kobo": kobo.current(), "updated": now.strftime("%H:%M"), "stale": False, diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html index 682a1db..9f78aa5 100644 --- a/backend/templates/dashboard.html +++ b/backend/templates/dashboard.html @@ -65,6 +65,14 @@ .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); } + /* ---- 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) ============================ Barre de progression intuitive : le noir se REMPLIT de gauche à droite avec la consommation ; le blanc à droite = budget restant. Repère ▼ = ligne d'alerte @@ -196,6 +204,23 @@ {% endif %} + {% if trackers %} +
+
Trackers
+
+ {% for t in trackers %} +
+
+ {{ t.label }} + {% if t.ok %}{{ t.ratio_h }} + {% else %}{{ t.error }}{% endif %} +
+ {% if t.ok %}
envoyé {{ t.up_h }} · reçu {{ t.down_h }}
{% endif %} +
+ {% endfor %} +
+ {% endif %} + {% if ha_states %}
Maison
diff --git a/dev/preview.py b/dev/preview.py index b3a13bd..0806a26 100644 --- a/dev/preview.py +++ b/dev/preview.py @@ -66,6 +66,11 @@ CTX = { "docker_running": 18, "docker_total": 19, "docker_unhealthy": 1, "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": [ {"label": "Salon", "display": "21°"}, {"label": "Chambre", "display": "19°"},