# Stream Control Plan de contrôle web pour piloter des enregistrements OBS répartis sur plusieurs VM/VPS (Windows et Ubuntu) depuis une seule interface. ``` ┌────────────┐ HTTPS + WS ┌──────────────────┐ WS sortant ┌──────────────────┐ │ Navigateur │ ───────────────►│ Serveur de │◄────────────────│ Agent (VM) │ │ dashboard │◄─── temps réel ─│ contrôle │──── commandes ─►│ └► OBS (4455) │ └────────────┘ │ Express + SQLite│ └──────────────────┘ └──────────────────┘ ┌──────────────────┐ ◄────────────────│ Agent (VM) … │ └──────────────────┘ ``` **Le sens des connexions est le point clé** : ce sont les agents qui se connectent au serveur, pas l'inverse. Aucun port entrant à ouvrir sur les VM d'enregistrement, aucune IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`. ## Fonctionnalités - Découverte automatique des agents par jeton d'enrôlement, ou provisionnement manuel. - État temps réel par VM : OBS connecté, enregistrement en cours, durée, taille du fichier, scène active, FPS, frames perdues, CPU, espace disque restant. - Commandes unitaires ou groupées : démarrer/arrêter/mettre en pause/découper un enregistrement, démarrer/arrêter un stream, changer de scène, de profil, de collection, changer le dossier d'enregistrement. - Journal d'évènements horodaté, persistant et diffusé en direct, plus un [historique par VM](#historique-par-vm) filtrable (🕘 sur la fiche de l'agent). - Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur). - [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés) (Stripchat), avec reprise et rappel du plein écran au retour du flux public. - [Clôture différée sur passage hors-ligne](#passage-hors-ligne) : une coupure brève ne découpe pas le fichier, une vraie fin de diffusion le termine. - [Enregistrement automatique](#enregistrement-automatique) d'un streamer sur une VM dédiée, dès qu'il est en direct depuis assez longtemps. - [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface, réglables VM par VM : de « Économe » à « Sans perte ». ## Prérequis - Node.js **22+** sur le serveur et sur chaque VM (le serveur utilise `node:sqlite`). - OBS **28+** sur chaque VM, avec *Outils → Paramètres du serveur WebSocket* activé. Note le port (4455 par défaut) et le mot de passe. - Pour le rappel du plein écran sous Ubuntu : `xdotool` et une session X11 (voir [Prérequis pour le rappel du plein écran](#prérequis-pour-le-rappel-du-plein-écran)). ## Démarrage rapide (développement) ```bash npm install cp .env.example .env # renseigne ADMIN_PASSWORD, SESSION_SECRET, ENROLLMENT_TOKEN npm run dev # serveur :8080 + dashboard Vite :5173 ``` Puis, dans un autre terminal, un agent local : ```bash cd packages/agent cp agent.config.example.json agent.config.json # colle le ENROLLMENT_TOKEN dans "token" npm run dev ``` L'agent s'enrôle, reçoit son jeton permanent, le réécrit dans `agent.config.json`, et apparaît dans le dashboard sur . ## Déploiement La procédure complète est dans **[DEPLOY.md](DEPLOY.md)** : interface en Docker Compose, agents en une commande, artefacts construits et publiés par Gitea Actions. En résumé : ```bash # Interface (machine du plan de contrôle) docker compose up -d # Agent Ubuntu curl -fsSL /api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.sh \ | sudo bash -s -- --registry --server ws://:8080/ws/agent --token # Agent Windows (PowerShell administrateur) & ([scriptblock]::Create((irm '/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.ps1'))) ` -Registry '' -Server 'ws://:8080/ws/agent' -Token '' ``` Les agents ne sont volontairement pas conteneurisés : ils doivent voir l'OBS local et la fenêtre du navigateur. Pour vérifier une installation à tout moment : ```bash node /opt/stream-control-agent/agent.cjs --check ``` ## Configuration ### Serveur — `.env` (voir [.env.example](.env.example)) | Variable | Rôle | | --- | --- | | `PORT`, `HOST` | Écoute HTTP/WebSocket | | `ADMIN_PASSWORD` | Mot de passe du dashboard | | `SESSION_SECRET` | Clé HMAC des sessions (32+ octets aléatoires) | | `ENROLLMENT_TOKEN` | Jeton d'auto-enregistrement ; vide = désactivé | | `DB_PATH` | Fichier SQLite | | `STATUS_INTERVAL_MS` | Fréquence de remontée d'état des agents | | `AGENT_TIMEOUT_MS` | Délai avant de déclarer un agent hors-ligne | ### Agent — `agent.config.json` (ou variables d'environnement) | Clé | Variable | Rôle | | --- | --- | --- | | `serverUrl` | `SERVER_URL` | `wss://control.exemple.com/ws/agent` | | `token` | `AGENT_TOKEN` | Jeton d'enrôlement puis jeton propre à l'agent | | `name` | `AGENT_NAME` | Nom affiché (défaut : hostname) | | `obs.host/port/password` | `OBS_HOST` / `OBS_PORT` / `OBS_PASSWORD` | Repli local ; le serveur pousse la config de référence | | `insecureTls` | `INSECURE_TLS=1` | Accepter un certificat auto-signé | Les paramètres OBS édités dans le dashboard sont poussés à chaud vers l'agent : pas besoin de se connecter à la VM pour changer un mot de passe obs-websocket. ## Pause automatique pendant les shows privés Quand un streamer bascule en show privé, le flux public est remplacé par un écran d'attente : l'enregistrement continue mais ne capte plus rien d'utile, et le lecteur sort du plein écran. L'agent peut surveiller le statut du streamer et réagir seul. Configuration par agent, dans *Configuration → Surveillance du stream* : | Réglage | Effet | | --- | --- | | Pseudo du streamer | Celui de l'URL de sa page | | Intervalle de sonde | Fréquence d'interrogation de l'API (10 s par défaut, plancher 3 s) | | Lectures avant pause | Lectures « privé » consécutives exigées avant d'agir (2 par défaut) | | Statuts « privé » | Statuts déclenchant la pause — retire `groupShow` pour continuer à enregistrer les shows de groupe | | Touche / fenêtre / délai | Raccourci plein écran à renvoyer au lecteur après le show | ### Ce que fait l'agent 1. Il interroge `GET /api/front/v2/models/username/{pseudo}/cam` et lit `user.user.status`. Valeurs relevées en production : `public`, `private`, `p2p`, `groupShow`, `idle`. 2. Statut privé confirmé → `PauseRecord`, en mémorisant que **c'est lui** qui a mis en pause. 3. Retour au public → `ResumeRecord`, puis envoi de la touche plein écran après le délai configuré, le temps que le lecteur ait rechargé le flux. Trois garde-fous, parce qu'une automatisation qui coupe un enregistrement au mauvais moment coûte plus cher que quelques secondes d'écran d'attente enregistrées : - **La pause exige plusieurs lectures consécutives, la reprise agit immédiatement.** Une fausse pause perd du contenu réel ; une fausse reprise ne coûte rien. - **Une sonde en échec ne déclenche jamais rien.** API injoignable ou réponse inattendue : l'agent conserve le dernier état connu et ne touche pas à l'enregistrement. - **Une pause manuelle n'est jamais reprise automatiquement.** L'agent ne reprend que ce qu'il a lui-même mis en pause. Un passage `hors-ligne` (`idle`) ne provoque ni pause ni reprise : seul le retour effectif du flux public relance l'enregistrement. ### Prérequis pour le rappel du plein écran L'envoi de touche se fait au niveau du système, pas via OBS. | OS | Mécanisme | À prévoir | | --- | --- | --- | | Ubuntu | `xdotool windowactivate` + XTEST | `apt install xdotool`, session **X11** (pas Wayland), `DISPLAY` accessible à l'agent | | | | Le cookie X est résolu à l'exécution : `$XAUTHORITY`, puis `/run/user//gdm/Xauthority`, les cookies Xwayland, puis `~/.Xauthority` | | Windows | `SetForegroundWindow` + `SendKeys` | L'agent doit tourner dans la session interactive — d'où la tâche planifiée plutôt qu'un service | | macOS | AppleScript System Events | Autorisation Accessibilité (prévu pour le développement) | Dans les deux cas la fenêtre du lecteur passe **au premier plan** : les navigateurs ignorent les évènements clavier synthétiques envoyés sans focus (`XSendEvent`). Sans conséquence sur une VM d'enregistrement dédiée, gênant si quelqu'un s'en sert en même temps. Le bouton ⛶ sur la fiche de l'agent renvoie la touche à la demande, et ⟳ force une sonde immédiate — les deux servent à valider le titre de fenêtre sans attendre un vrai show privé. Le **titre de fenêtre** à renseigner est celui de la fenêtre, pas le nom du processus. Sous Firefox il vaut ` — Mozilla Firefox`, et le titre d'une page Stripchat se termine par `| Stripchat` : `Stripchat` comme `Firefox` conviennent donc. En cas d'échec, l'agent liste les fenêtres qu'il voit réellement, et `agent.cjs --check` fait de même sans rien déclencher. Trois causes distinctes produisaient autrefois le même message « aucune fenêtre ne correspond » ; elles sont maintenant séparées : | Symptôme | Cause | | --- | --- | | `Authorization required` / `Invalid MIT-MAGIC-COOKIE-1 key` | cookie X introuvable ou périmé | | `Session Wayland : seule la fenêtre technique du compositeur est visible` | voir ci-dessous | | `Aucune fenêtre visible sur DISPLAY=:0` | navigateur lancé hors de la session de l'agent | | Liste des fenêtres ouvertes | titre mal renseigné — recopier un fragment de la liste | ### Wayland Ubuntu démarre en session Wayland par défaut, et Firefox y tourne en client Wayland natif. Une telle fenêtre est **invisible à xdotool** : ni activation, ni envoi de touche. Le symptôme est net — côté X11, seule `mutter guard window` apparaît, la fenêtre technique du compositeur. L'agent reconnaît cette signature et le dit explicitement. Deux issues : - **Rester en Wayland, passer Firefox sous XWayland.** L'agent pose `MOZ_ENABLE_WAYLAND=0` en lançant le navigateur, ce qui suffit — *à condition qu'aucune instance Firefox ne tourne déjà*. Firefox délègue l'URL à l'instance existante, qui garde ses propres variables d'environnement : ferme toutes ses fenêtres avant de lancer la capture. - **Ouvrir une session Xorg.** Sur l'écran de connexion GDM, roue dentée en bas à droite → « Ubuntu sur Xorg ». Tout redevient pilotable, y compris une fenêtre ouverte à la main. Si le rappel du plein écran s'avère fragile sur ta VM, l'alternative sans clavier est de lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein écran à restaurer. ## Presets d'enregistrement Chaque VM peut être réglée sur un compromis qualité / charge CPU différent, depuis la fiche de l'agent (⚙ → « Preset d'enregistrement »). Tant que la case n'est pas cochée, l'agent ne touche à rien et OBS garde sa configuration manuelle. | Preset | Sortie | Qualité | Conteneur | CPU | | --- | --- | --- | --- | --- | | Économe | 720p 30 fps | haute (CRF ~23) | MP4 | ● | | Équilibré | 1080p 30 fps | très haute (CRF ~16) | MP4 | ●● | | Qualité maximale | définition de la scène, 60 fps | très haute, encodage lent | MKV | ●●● | | Sans perte | définition de la scène, 60 fps | aucune perte | MKV | ●●●● | L'encodeur se choisit séparément : x264 (le seul disponible sur un VPS sans GPU), NVENC, Quick Sync, AMF ou VideoToolbox. Le preset fournit la valeur de vitesse adaptée à la famille retenue — `veryfast` pour x264, `p5` pour NVENC, etc. ### Ce qui se passe à l'application L'agent écrit dans le profil OBS courant (`SetProfileParameter`, section `SimpleOutput`) puis ajuste la sortie vidéo (`SetVideoSettings`). Trois comportements à connaître : - **La qualité prend effet au prochain démarrage d'enregistrement.** OBS relit ces paramètres à ce moment-là ; l'interface d'OBS, elle, ne les rafraîchit qu'au changement de profil. - **Changer d'encodeur exige un redémarrage d'OBS.** L'objet encodeur n'est instancié qu'au lancement. L'agent le détecte et le signale dans son compte rendu. - **Un preset n'est jamais appliqué pendant une capture** : cela la corromprait. La demande est refusée, ou différée jusqu'à l'arrêt de l'enregistrement. Le mode de sortie du profil est forcé sur « Simple » : c'est la section que ces réglages pilotent. Un paramètre refusé par la version d'OBS installée n'interrompt pas les autres — il apparaît dans le compte rendu affiché sous le bouton « Appliquer maintenant ». Un preset trop lourd pour la VM fait chuter les images par seconde : la qualité perçue baisse alors malgré un meilleur CRF. Après un changement, surveille « FPS » et « Frames perdues » sur la fiche de l'agent. ## Historique par VM Le journal en bas de page mélange toutes les machines. Le bouton 🕘 de la fiche d'un agent ouvre son historique à lui : mêmes entrées, filtrées sur cette VM, groupées par jour et du plus récent au plus ancien. Chaque entrée porte un **type d'évènement** — `record.paused`, `obs.disconnected`, `fullscreen.restored`, `preset.applied`… — qui lui donne son pictogramme et sa couleur, et qui alimente les filtres : Enregistrement, OBS, Surveillance, Problèmes. Ce champ est facultatif dans le protocole. Une entrée écrite avant cette version, ou envoyée par un agent qui n'a pas encore été mis à jour, s'affiche sans pictogramme plutôt que de disparaître. Les pauses détectées valent d'être soulignées : elles proviennent de l'évènement `RecordStateChanged` d'OBS, pas de la commande envoyée. L'historique montre donc aussi les pauses déclenchées depuis l'interface d'OBS sur la VM, que le dashboard n'aurait aucun autre moyen de connaître. La rétention est celle du journal global (`LOG_RETENTION`) : les entrées les plus anciennes sont purgées, toutes VM confondues. ## Statuts Stripchat Le champ autoritatif est `user.user.status`. Deux familles, deux traitements : | Statut brut | Signification | Traitement | | --- | --- | --- | | `public` | diffusion publique | enregistré | | `private`, `p2p`, `groupShow`, `virtualPrivate`, `ticketShow` | show payant | pause, sans clôture | | `idle` | **« revient bientôt »** — connecté mais ne diffuse pas | pause, sans clôture | | `off`, `notFound`, `deleted` | déconnecté | pause puis [clôture différée](#passage-hors-ligne) | `idle` se distingue de `off` par `isLive: false` mais `isOnline: true` : le streamer est toujours là, il s'est simplement absenté. Le flux revient, donc l'enregistrement se met en pause et attend, exactement comme pendant un show privé — il ne se clôt pas. Ne pas confondre avec `offlineStatus`, un champ voisin : c'est le message d'absence libre du modèle (« I'll be back soon »), qui reste renseigné pendant qu'il diffuse. Il ne dit rien de l'état courant et n'est pas utilisé. La liste des statuts mis en pause est modifiable par agent, dans « Surveillance du stream ». Retirer `groupShow` pour continuer à enregistrer les shows de groupe, par exemple. Les agents créés avant l'ajout d'`idle` sont migrés au démarrage du serveur, sauf si leur liste a été personnalisée. ## Passage hors-ligne Quand le statut brut passe à `off`, deux temps distincts : 1. **Pause immédiate.** L'écran d'attente n'a rien à faire dans le fichier. Aucun délai, aucune confirmation : reprendre ne coûte rien si la lecture était fausse. 2. **Clôture différée**, après le délai réglé sur la fiche de l'agent (1 h par défaut). Une coupure de quelques minutes est fréquente ; clore tout de suite découperait le fichier en deux. Le compte à rebours est annulé dès que le flux revient, public **ou** privé, et l'enregistrement reprend dans le même fichier. Le décompte s'affiche sur la fiche de l'agent. La clôture ferme aussi la fenêtre du navigateur si son pilotage est activé, contrairement à une simple pause. Régler le délai à `0` clôt dès la première lecture hors-ligne ; décocher « Clore l'enregistrement quand le flux passe hors-ligne » désactive les deux temps, y compris la pause. ## Enregistrement automatique Sur la fiche d'un streamer (onglet Streamers), assigne un agent puis coche **Enregistrer automatiquement**. Dès que le profil est en direct depuis `AUTO_RECORD_DELAY_MS` (60 s par défaut), le serveur lance la capture sur cet agent, exactement comme le bouton « Enregistrer » — surveillance des shows privés comprise. Trois règles cadrent l'automatisme : - **Un streamer par VM.** Activer l'automatisme sur un agent déjà pris est refusé, avec le nom du profil qui l'occupe. - **Aucune préemption.** Si la VM enregistre déjà quoi que ce soit, l'automatisme passe son tour plutôt que d'écraser la capture en cours. - **Trois tentatives par diffusion.** Les échecs restants une fois ces garde-fous passés (OBS injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme abandonne jusqu'à la diffusion suivante et le dit dans le journal. Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort d'un show privé repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la première lecture produirait des fichiers de dix secondes. ## Capture de fenêtre OBS et réutilisation La source « capture de fenêtre » d'OBS mémorise un identifiant de fenêtre X11. Fermer la fenêtre du navigateur, ou en ouvrir une nouvelle, invalide cet identifiant : la source devient noire et il faut la repointer à la main. L'agent évite donc les deux : - **aucun argument** par défaut (`--new-window` est proscrit) : Firefox confie l'URL à la fenêtre déjà ouverte ; - **à l'arrêt, page vide** plutôt que fermeture : la fenêtre survit, et le lecteur cesse de décoder la vidéo. Les agents configurés avant ce correctif sont migrés au démarrage du serveur, sauf si leurs arguments ou leur comportement d'arrêt ont été personnalisés. ### Éviter l'accumulation d'onglets Avec les réglages d'usine de Firefox, un lien venu de l'extérieur ouvre un **nouvel onglet** : chaque cycle d'enregistrement en laisse donc derrière lui, et les anciens continuent de décoder leur page. Sur une VM d'enregistrement, règle une fois pour toutes dans `about:config` : ``` browser.link.open_newwindow = 1 ``` L'URL remplace alors le contenu de l'onglet courant. Ouverture et page vide réutilisent le même onglet, dans la même fenêtre — rien ne s'accumule et OBS ne perd jamais sa cible. Dans OBS, règle aussi la **priorité de correspondance** de la source sur « Faire correspondre le titre, sinon trouver une fenêtre du même type » : le titre suit l'onglet actif et change à chaque streamer. ## API HTTP Toutes les routes hors `/api/login` exigent `Authorization: Bearer `. | Méthode | Route | Rôle | | --- | --- | --- | | `POST` | `/api/login` | Ouvre une session (`{ password }`) | | `GET` | `/api/agents` | Liste des agents et de leur état | | `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** | | `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset | | `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme | | `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) | | `DELETE` | `/api/agents/:id` | Supprime l'agent | | `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent | | `POST` | `/api/commands/bulk` | Même action sur plusieurs agents, résultat par agent | | `GET` | `/api/logs?limit=` | Journal récent, toutes VM confondues | | `GET` | `/api/agents/:id/logs?limit=` | Historique d'une VM, ordre chronologique | | `GET` | `/healthz` | Sonde de vie (non authentifiée) | Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.start`, `record.stop`, `record.pause`, `record.resume`, `record.split`, `stream.start`, `stream.stop`, `scene.set`, `profile.set`, `collection.set`, `recordDirectory.set`, `watch.check`, `hotkey.fullscreen`, `browser.open`, `browser.close`, `capture.start`, `capture.stop`, `preset.apply`, `agent.update`, `agent.ping`. ## Structure | Paquet | Rôle | | --- | --- | | [packages/shared/](packages/shared/) | Types du protocole, partagés par les trois autres | | [packages/server/](packages/server/) | API REST, passerelle agents, diffusion dashboard, SQLite | | [packages/agent/](packages/agent/) | Binaire à déployer sur chaque VM, pilote OBS | | [packages/web/](packages/web/) | Dashboard React/Vite | Points d'entrée utiles : [packages/shared/src/index.ts](packages/shared/src/index.ts) (le protocole), [packages/server/src/hub.ts](packages/server/src/hub.ts) (état central et dispatch), [packages/agent/src/obs.ts](packages/agent/src/obs.ts) (traduction action → obs-websocket), [packages/agent/src/watcher.ts](packages/agent/src/watcher.ts) (sonde de statut et machine à états pause/reprise), [packages/agent/src/hotkey.ts](packages/agent/src/hotkey.ts) (envoi de touche par OS). ## Sécurité - Les jetons d'agent ne sont stockés qu'en SHA-256 ; le clair n'est affiché qu'à la création. - Le mot de passe obs-websocket n'est jamais renvoyé au navigateur (masqué en `********`). - Les sessions dashboard sont des jetons HMAC à durée limitée, sans état serveur. - **Sers l'application en HTTPS/WSS** : jetons d'agent et de session circulent dans les en-têtes et les URL de WebSocket. - Après un `POST /api/agents/:id/token`, l'agent est déconnecté jusqu'à ce que son `agent.config.json` soit mis à jour. ## Pistes d'évolution Enregistrements programmés (cron par agent), rapatriement automatique des fichiers (rclone/S3 déclenché à `record.stop`), alertes sur seuil d'espace disque ou de frames perdues, comptes utilisateurs multiples, groupes d'agents.