Ctrl K
ComunidadGuías…

deploy/vektor-stream-guard.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/bin/bash
# ════════════════════════════════════════════════════════════════
# COPIE VERSIONNÉE — source de vérité du stream-guard déployé sur PVE.
#
# Install :
#   scp deploy/vektor-stream-guard.sh pve:/usr/local/bin/vektor-stream-guard
#   ssh pve -- chmod +x /usr/local/bin/vektor-stream-guard
#
# Le shebang DOIT rester en ligne 1 (exec systemd) : cet en-tête est
# inséré APRÈS la ligne #!/bin/bash, jamais avant.
#
# Le fix infirmier qBit (bug v1.6.3, corrigé le 27/09 : curl SANS
# -w '%{http_code}' => sortie toujours vide => qBit déclaré muet à
# chaque cycle => restart qBit toutes les 10 min) est EMBARQUÉ dans ce
# fichier : qbit_api_alive() renvoie le code HTTP et les tests comparent
# = 200 / != 200. deploy/patch-nurse-qbit.py reste en historique
# (patch in-situ d'avant versioning) — inutile après une install depuis
# cette copie.
#
# Secrets : AUCUN dans ce fichier (creds lues dans /root/.vektor-*).
# ════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
# vektor-stream-guard — priorise le streaming Jellyfin sur qBittorrent
#
# Toutes les minutes (systemd timer) :
#   1. Lit les sessions Jellyfin (CT 102, API /Sessions, header
#      Authorization: MediaBrowser — format 12.x).
#   2. Si au moins une session Transcode / DirectStream / DirectPlay
#      est active (non en pause) : pause globale qBittorrent (CT 103)
#      et notification Telegram Vektor.
#   3. Si plus aucune lecture ET que la pause vient du guard :
#      reprise globale qBittorrent et notification Telegram.
#   4. Hysteresis : reprise seulement apres PAUSE_RELEASE_DELAY_S
#      sans lecture (evite les micro-coupures entre episodes).
#
# Etat : /var/lib/vektor/stream-guard.state (mode + timestamp)
# Creds : /root/.vektor-jf (cle Jellyfin), /root/.vektor-qb (qBit),
#         /root/.vektor-tg (token bot + chat id Telegram)
# Logs  : journalctl -u vektor-stream-guard.service
# ═══════════════════════════════════════════════════════════════
set -u

STATE_DIR=/var/lib/vektor
STATE_FILE=$STATE_DIR/stream-guard.state
EVENT_LOG=$STATE_DIR/stream-guard.log   # PAUSE|epoch|detail / RESUME|epoch|duree_s
PAUSE_RELEASE_DELAY_S=180      # 3 min sans lecture avant reprise
RECONCILE_EVERY_S=60          # vérification « toujours en pause » chaque tick (60 s)
CURL_TIMEOUT=12
SESS_TMP=/tmp/vektor-guard-sess.json
QB_TMP=/tmp/vektor-guard-qb.json

mkdir -p "$STATE_DIR"
touch "$STATE_FILE"

log() { echo "$(date '+%F %T') $*"; }

# ── Credentials ──
[ -r /root/.vektor-jf ] && JF_KEY=$(cat /root/.vektor-jf) || { log "ERREUR: cle Jellyfin absente"; exit 1; }
[ -r /root/.vektor-tg ] && read -r TG_TOKEN TG_CHAT < /root/.vektor-tg || TG_TOKEN=""

tg_send() {
  [ -n "$TG_TOKEN" ] || return 0
  curl -s --max-time "$CURL_TIMEOUT" -o /dev/null \
    "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
    --data-urlencode "chat_id=${TG_CHAT}" \
    --data-urlencode "text=$1" >/dev/null 2>&1 || true
}

# ── 1. Sessions Jellyfin (header MediaBrowser, format 12.x) ──
pct exec 102 -- curl -s --max-time "$CURL_TIMEOUT" \
  -H "Authorization: MediaBrowser Token=\"${JF_KEY}\"" \
  "http://127.0.0.1:8096/Sessions?activeWithinSeconds=120" > "$SESS_TMP" 2>/dev/null

PLAYING=$(python3 - "$SESS_TMP" <<'PYEOF'
import json, sys
try:
    sessions = json.load(open(sys.argv[1]))
except Exception:
    print("ERROR"); raise SystemExit
names = []
playing = 0
for s in sessions:
    ps = s.get("PlayState") or {}
    method = ps.get("PlayMethod")
    item = s.get("NowPlayingItem")
    if not item or not method:
        continue  # session sans lecture effective (web ouvert, API interne)
    if ps.get("IsPaused"):
        continue  # lecture en pause : pas de charge reelle
    if method in ("Transcode", "DirectStream", "DirectPlay"):
        playing += 1
        title = str(item.get("Name", "?"))[:40]
        names.append(f"{s.get('UserName', '?')} ({method}) : {title}")
print(playing)
for n in names:
    print(n)
PYEOF
)

if [ "$PLAYING" = "ERROR" ] || [ -z "$PLAYING" ]; then
  log "ERREUR: reponse Jellyfin illisible (pas d'action par securite)"
  exit 0
fi
N_PLAYING=$(printf '%s\n' "$PLAYING" | head -1)
DETAILS=$(printf '%s\n' "$PLAYING" | tail -n +2 | head -3)

# ── 2. Helper qBittorrent : auth + action globale ──
qb_all() {  # $1 = pause | resume ; affiche "stopped total"
  # bypass_auth_subnet_whitelist=192.168.1.0/24 : appel direct sans login
  # (creds /root/.vektor-qb mortes depuis la rotation qBit du 23/09)
  local VERB="$1"
  # qBit tourne en DOCKER BRIDGE sur le CT 107 : vu du CT, un appel a
  # localhost:8181 (port publishé) arrive depuis la gateway Docker
  # (172.17.0.1), hors whitelist LAN -> 403. Le bypass LAN ne marche
  # que depuis l INTERIEUR du conteneur (localhost = 127.0.0.1 y est
  # couvert par bypass_local_auth=true) : passer par docker exec.
  pct exec 107 -- docker exec qbittorrent sh -c \
    "curl -s --max-time $CURL_TIMEOUT -o /dev/null -b SID=bypass-lan-whitelist \
     -d hashes=all http://localhost:8080/api/v2/torrents/${VERB}" 2>/dev/null
  sleep 2
  pct exec 107 -- docker exec qbittorrent sh -c \
    "curl -s --max-time $CURL_TIMEOUT -b SID=bypass-lan-whitelist \
     http://localhost:8080/api/v2/torrents/info" 2>/dev/null > "$QB_TMP"
  python3 - "$QB_TMP" "$VERB" <<'PYEOF'
import json, sys
try:
    ts = json.load(open(sys.argv[1]))
except Exception:
    print("-1 -1 err"); raise SystemExit
stopped = sum(1 for t in ts if str(t.get("state", "")).startswith(("paused", "stopped")))
total = len(ts)
# effet = la commande a-t-elle produit l'état attendu ? (un torrent déjà
# stoppé volontairement ne fait pas d'un resume un échec)
if sys.argv[2] == "pause":
    effect = "no" if (total > 0 and stopped == 0) else "yes"
else:
    effect = "no" if (total > 0 and stopped == total) else "yes"
print(stopped, total, effect)
PYEOF
}

qb_check() {  # lecture seule : affiche "stopped total" (aucune commande)
  pct exec 107 -- docker exec qbittorrent sh -c \
    "curl -s --max-time $CURL_TIMEOUT -b SID=bypass-lan-whitelist \
     http://localhost:8080/api/v2/torrents/info" 2>/dev/null > "$QB_TMP"
  python3 - "$QB_TMP" <<'PYEOF'
import json, sys
try:
    ts = json.load(open(sys.argv[1]))
except Exception:
    print("-1 -1"); raise SystemExit
stopped = sum(1 for t in ts if str(t.get("state", "")).startswith(("paused", "stopped")))
print(stopped, len(ts))
PYEOF
}

# ── 2bis. Infirmier qBittorrent : si l'API ne répond plus (conteneur
# unhealthy / crash Docker), redémarrer le conteneur via le canal d'actions
# verrouillé. Toutes les 10 ticks (~10 min), JAMAIS pendant un stream.
QBIT_NURSE_EVERY_TICKS=10
QBIT_NURSE_COUNTER_FILE=/var/lib/vektor/stream-guard-nurse.counter

qbit_api_alive() {
  pct exec 107 -- docker exec qbittorrent sh -c \
    "curl -s -o /dev/null --max-time $CURL_TIMEOUT -w '%{http_code}' -b SID=bypass-lan-whitelist http://localhost:8080/api/v2/app/version" 2>/dev/null
}

N=$(cat "$QBIT_NURSE_COUNTER_FILE" 2>/dev/null || echo 0)
N=$((N + 1))
if [ "$N" -ge "$QBIT_NURSE_EVERY_TICKS" ]; then
  N=0
  if [ "$(qbit_api_alive)" != "200" ]; then
    # re-test 10 s plus tard pour éviter un redémarrage sur un hoquet réseau
    sleep 10
    if [ "$(qbit_api_alive)" != "200" ]; then
      log "INFIRMIER : API qBittorrent muette 2x — restart du conteneur"
      if pct status 107 2>/dev/null | grep -q "running"; then
        pct exec 107 -- docker restart qbittorrent >/dev/null 2>&1
        sleep 8
        if [ "$(qbit_api_alive)" = "200" ]; then
          log "INFIRMIER : qBittorrent relancé et répond"
          tg_send "🔧 Vektor — qBittorrent ne répondait plus : conteneur redémarré automatiquement, il répond à nouveau."
        else
          log "INFIRMIER : restart effectué mais l'API reste muette (escalade manuelle)"
          tg_send "⚠️ Vektor — qBittorrent redémarré mais toujours muet : investigation manuelle requise."
        fi
      else
        log "INFIRMIER : CT 107 arrêté — pas d'action du guard (CT level géré ailleurs)"
      fi
    fi
  fi
fi
echo "$N" > "$QBIT_NURSE_COUNTER_FILE"

# ── 3. Machine a etats ──
NOW=$(date +%s)
read -r MODE SINCE < "$STATE_FILE" 2>/dev/null || { MODE=idle; SINCE=$NOW; }
[ -n "${MODE:-}" ] || MODE=idle

if [ "$N_PLAYING" -gt 0 ]; then
  if [ "$MODE" != "paused" ]; then
    log "Lecture detectee ($N_PLAYING) : pause qBittorrent"
    read -r STOPPED TOTAL EFFECT < <(qb_all pause)
    if [ "$TOTAL" -ge 0 ] 2>/dev/null && [ "$EFFECT" != "no" ]; then
      echo "paused $NOW" > "$STATE_FILE"
      MSG="🎬 Vektor — mode streaming
Jellyfin diffuse :
${DETAILS:-($N_PLAYING session(s))}

⏸️ qBittorrent PAUSE (${STOPPED}/${TOTAL} torrents) pour prioriser ta bande passante.
(seuls les torrents sont mis en pause : le LAN, les services et Jellyfin ne sont pas touchés)
Je reprendrai les téléchargements 3 min après la fin de la lecture."
      tg_send "$MSG"
      printf 'PAUSE|%s|%s\n' "$NOW" "$(printf '%s' "$DETAILS" | head -1)" >> "$EVENT_LOG"
      log "PAUSE appliquee : ${STOPPED}/${TOTAL} — notif envoyee"
    elif [ "$EFFECT" = "no" ]; then
      log "ECHEC D'EFFET : pause commandée mais 0 torrent stopped (refus qBit ?)"
      tg_send "⚠️ Vektor — stream-guard : pause qBit commandée mais SANS EFFET (0 torrent en pause). Vérifier l'accès API qBittorrent."
    else
      log "ERREUR: authentification qBittorrent echouee (pause non appliquee)"
    fi
  else
    ELAPSED_ANCHOR=$(( NOW - SINCE ))
    REFRESH_ANCHOR=1
    if [ "$ELAPSED_ANCHOR" -gt "$PAUSE_RELEASE_DELAY_S" ]; then
      # Pause ancienne (jamais reprise : session precedente interrompue, arret
      # du guard, etc.) et nouvelle lecture detectee : informer comme une
      # vraie PAUSE (et re-pauser au cas ou les torrents auraient repris).
      log "Lecture sur pause ancienne (${ELAPSED_ANCHOR}s) : pause + notification"
      read -r STOPPED TOTAL EFFECT < <(qb_all pause)
      if [ "$TOTAL" -ge 0 ] 2>/dev/null && [ "$EFFECT" != "no" ]; then
        MSG="🎬 Vektor — mode streaming
Jellyfin diffuse :
${DETAILS:-($N_PLAYING session(s))}

⏸️ qBittorrent PAUSE (${STOPPED}/${TOTAL} torrents) pour prioriser ta bande passante.
(seuls les torrents sont mis en pause : le LAN, les services et Jellyfin ne sont pas touchés)
Je reprendrai les téléchargements 3 min après la fin de la lecture."
        tg_send "$MSG"
        printf 'PAUSE|%s|%s\n' "$NOW" "$(printf '%s' "$DETAILS" | head -1)" >> "$EVENT_LOG"
        log "PAUSE appliquee : ${STOPPED}/${TOTAL} — notif envoyee"
      else
        # Pas de rafraichissement d'ancre : nouvelle tentative a la prochaine passe.
        log "ERREUR: authentification qBittorrent echouee (pause non appliquee, retry a la prochaine passe)"
        REFRESH_ANCHOR=0
      fi
    fi
    # Réconciliation : une action EXTERNE (humain, autre script, reboot
    # avec resume-all) peut avoir repris les torrents pendant la lecture.
    # L'ancre étant rafraîchie à chaque tick, la branche « pause ancienne »
    # ne se déclenche jamais en séance continue : sans cette vérification
    # le guard ne s'apercevrait JAMAIS d'une reprise externe (vécu le
    # 26/09 : resume all externe à 16:09, séance sans pause 16:09→17:39).
    LAST_RECON=$(cat "$STATE_DIR/stream-guard.reconcile" 2>/dev/null || echo 0)
    if [ $(( NOW - LAST_RECON )) -ge "$RECONCILE_EVERY_S" ]; then
      echo "$NOW" > "$STATE_DIR/stream-guard.reconcile"
      read -r RSTOPPED RTOTAL < <(qb_check)
      if [ "$RTOTAL" -gt 0 ] 2>/dev/null && [ "$RSTOPPED" -eq 0 ]; then
        log "RECONCILIATION : 0/${RTOTAL} stopped pendant une lecture — re-pause"
        read -r STOPPED TOTAL EFFECT < <(qb_all pause)
        if [ "$TOTAL" -ge 0 ] 2>/dev/null && [ "$EFFECT" != "no" ]; then
          tg_send "🔁 Vektor — stream-guard : les torrents avaient été repris par une action externe pendant la lecture. Je re-pause (${STOPPED}/${TOTAL})."
          printf 'PAUSE|%s|%s\n' "$NOW" "reconciliation (reprise externe détectée)" >> "$EVENT_LOG"
          log "PAUSE appliquée (réconciliation) : ${STOPPED}/${TOTAL}"
        fi
      else
        log "Réconciliation : ${RSTOPPED}/${RTOTAL} stopped, pause toujours effective"
      fi
    fi
    # Ancre rafraîchie tant que la lecture dure : l'hysteresis de reprise
    # comptera depuis la fin REELLE de la lecture, pas du debut de la pause.
    if [ "$REFRESH_ANCHOR" = "1" ]; then
      echo "paused $NOW" > "$STATE_FILE"
    fi
    log "Lecture en cours ($N_PLAYING) : deja en pause"
  fi
else
  if [ "$MODE" = "paused" ]; then
    ELAPSED=$(( NOW - SINCE ))
    if [ "$ELAPSED" -ge "$PAUSE_RELEASE_DELAY_S" ]; then
      log "Aucune lecture depuis ${ELAPSED}s : reprise qBittorrent"
      read -r STOPPED TOTAL EFFECT < <(qb_all resume)
      if [ "$TOTAL" -ge 0 ] 2>/dev/null && [ "$EFFECT" != "no" ]; then
        echo "idle $NOW" > "$STATE_FILE"
        ACTIVE=$(( TOTAL - STOPPED ))
        MSG="▶️ Vektor — fin de streaming
Plus aucune lecture Jellyfin depuis 3 min.

✅ qBittorrent RELANCE (${ACTIVE}/${TOTAL} torrents actifs)."
        tg_send "$MSG"
        # ts = SINCE = fin REELLE de la lecture (l'ancre est rafraichie a chaque
        # tick de lecture) ; le digest calcule la duree RESUME.ts - PAUSE.ts.
        printf 'RESUME|%s|%s\n' "$SINCE" "$(( ELAPSED - PAUSE_RELEASE_DELAY_S ))" >> "$EVENT_LOG"
        log "RESUME applique : ${ACTIVE}/${TOTAL} actifs (fin de lecture a $(date -d @"$SINCE" '+%T'), hysteresis ${ELAPSED}s) — notif envoyee"
      else
        log "ERREUR: authentification qBittorrent echouee (reprise non appliquee)"
      fi
    else
      log "Plus de lecture mais delai de securite non ecoule (${ELAPSED}s/${PAUSE_RELEASE_DELAY_S}s)"
    fi
  else
    log "Aucune lecture, mode $MODE : rien a faire"
  fi
fi