deploy/vektor-stream-guard.sh
Rama main · réplica de lectura del repositorio original.
#!/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