Files
stream-control/README.md
jeanotx32 da9952d756
All checks were successful
release / build (push) Successful in 29s
release / verify-windows (push) Successful in 2m57s
Fix : stopped stream bug
2026-08-14 07:33:09 -04:00

1051 lines
62 KiB
Markdown

# 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.
- Le plein écran est [mesuré, pas supposé](#mesurer-plutôt-que-demander), relu pendant la
capture et rétabli s'il se perd ; un
[lecteur figé](#lecteur-figé--rechargement-automatique) est détecté et la page rechargée.
- [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, avec
[priorité et interruption](#priorité-et-interruption) quand plusieurs profils se disputent
la même machine.
- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface,
réglables VM par VM : de « Économe » à « Sans perte ».
- [Vignette du flux en direct](#vignette-du-flux-en-direct) sur la fiche compacte de chaque
profil en direct, agrandie au survol.
- [Étiquettes](#étiquettes) libres sur les streamers, créées à la volée, avec filtrage de la
liste par étiquette.
- [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil
suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous
ensemble dans l'onglet « Timeline ». Une
[vignette est conservée par diffusion](#une-vignette-par-diffusion-conservée), visible au
survol des mois après.
## 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 : Firefox, en mode de pilotage « WebDriver BiDi »
(voir [Deux façons de piloter le navigateur](#deux-façons-de-piloter-le-navigateur)).
Le mode historique exige en plus `xdotool` et une session X11.
## 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 <http://localhost:5173>.
## 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 <gitea>/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.sh \
| sudo bash -s -- --registry <gitea> --server ws://<IP_DU_CONTROLE>:8080/ws/agent --token <JETON>
# Agent Windows (PowerShell administrateur)
& ([scriptblock]::Create((irm '<gitea>/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.ps1'))) `
-Registry '<gitea>' -Server 'ws://<IP_DU_CONTROLE>:8080/ws/agent' -Token '<JETON>'
```
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 |
| `PREVIEWS_DIR` | Vignettes de diffusion conservées, une par stream |
| `PREVIEW_DELAY_MS` | Attente avant de copier la vignette d'un stream qui démarre |
| `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.
### Deux façons de piloter le navigateur
Le rappel du plein écran dépend entièrement de ce choix, qui se règle par agent dans
« Pilotage du navigateur ».
| | **WebDriver BiDi** (recommandé) | **Lancement simple** (historique) |
| --- | --- | --- |
| Ouverture d'une page | commande dans l'instance pilotée | un processus lancé par page |
| Plein écran | commande WebDriver adressée à Firefox | touche envoyée au serveur d'affichage |
| Effet vérifié ? | oui — la place occupée par le lecteur est mesurée | non |
| Session Wayland | **fonctionne** | impossible (voir plus bas) |
| Dépendances | Firefox | xdotool + session X11 |
#### WebDriver BiDi
Firefox expose ce protocole dès qu'on le lance avec `--remote-debugging-port` : ni
geckodriver, ni Selenium, une simple WebSocket JSON sur la boucle locale. L'agent
implémente le strict nécessaire (`bidi.ts`, `firefox.ts`).
Le renversement est là : **l'agent possède le navigateur** au lieu de lui envoyer des URL
en espérant. Il parle à Firefox, pas au serveur d'affichage — d'où le fonctionnement
identique sous Wayland, où xdotool ne voit rien.
Séquence du rappel de plein écran :
1. `browsingContext.activate` — sans quoi la touche partirait vers un onglet d'arrière-plan ;
2. `input.performActions` — la touche configurée (`f` par défaut), délivrée à la page comme
un vrai évènement (`isTrusted: true`), donc traitée par le lecteur du site ;
3. **mesure** de la place réellement occupée par le lecteur, et verdict rapporté dans
l'historique de la VM.
Une seule voie, volontairement, sans repli. Une version antérieure appelait
`requestFullscreen()` sur l'élément `<video>` brut quand `document.fullscreenElement` restait
vide après la touche — mais ce champ ne dit rien du mode « théâtre » de Stripchat, qui garde
la barre du haut visible et n'a le plus souvent rien à voir avec l'API plein écran native du
navigateur. Le repli déclenchait alors un vrai plein écran natif — vidéo seule, tout le reste
disparu — l'exact inverse de ce que la touche du lecteur, elle, obtient.
##### Mesurer plutôt que demander
`document.fullscreenElement` ne connaît que l'API native : il ignore le mode théâtre, donc il
ne renseignait presque jamais rien. L'agent **mesure** désormais ce qui compte vraiment — la
part de la fenêtre que le lecteur occupe, relevée par `getBoundingClientRect()` sur l'élément
`<video>` et ramenée au cadre visible (ce qui déborde n'est pas capturé par OBS).
**C'est la hauteur qui tranche, pas la surface.** Un lecteur passé en théâtre remplit toujours
la fenêtre verticalement, alors que sa largeur dépend de la forme du flux. Un flux vertical —
courant sur cette plateforme — reste cerné de bandes noires même en plein écran : mesuré en
surface, il passerait pour un échec permanent. Vérifié en conditions réelles contre un Firefox
piloté : un lecteur vertical en théâtre ne couvre que **27 %** de la fenêtre (contre 12 %
fenêtré), mais en occupe bien toute la hauteur.
**Un repère plutôt qu'un seuil absolu.** La hauteur du lecteur *fenêtré* est relevée une fois
par page, juste avant le premier envoi de touche ; le plein écran est reconnu quand la hauteur
dépasse ce repère d'un quart. Rien ne garantit qu'un site laisse au lecteur la même part
d'écran d'une mise en page à l'autre, et un seuil fixe se serait trompé sur la première
refonte venue. Le repère n'est jamais réécrit ensuite — un second relevé, pris alors que le
plein écran est déjà en place, en ferait la nouvelle référence et rendrait toute mesure
ultérieure aveugle. Il est en revanche oublié à chaque changement de page.
Il en découle **trois verdicts et non deux**. `false` n'est rendu que là où l'échec est
réellement établi ; sans repère fenêtré auquel se comparer (agent redémarré au milieu d'une
capture), on retombe sur un substitut — hauteur ≥ 80 % — dont le négatif reste *indéterminé*
plutôt qu'annoncé comme un échec. L'état apparaît sur la fiche de la VM (⛶ plein écran / hors
plein écran), et rien n'est affiché tant qu'il est indéterminé : faire passer un doute pour un
constat serait pire que se taire.
**Une seconde tentative, jamais plus, et seulement sur un échec établi.** La touche *bascule*
l'affichage : réappuyer sur une simple présomption ferait *sortir* du plein écran une page qui
y était déjà. C'est précisément la mesure fiable qui rend ce second essai possible.
**Le plein écran est relu toutes les 30 s pendant une capture.** Il peut se perdre sans que le
statut du stream bouge — un clic malheureux, un ré-affichage du lecteur, une publicité qui
reprend la main — et rien ne le signalait jusqu'ici : la capture en sortait sans qu'on sache
ni quand ni pourquoi. La relecture est espacée à dessein (c'est une évaluation dans la page,
pas au rythme du statut), et n'a lieu que pendant un enregistrement **actif et non suspendu** :
hors capture il n'y a rien à cadrer, et pendant une pause de show privé l'overlay du site
occupe le lecteur. Le constat part au journal **avec les mesures qui l'ont motivé**
(« hauteur 55 %, surface 27 %, repère fenêtré 55 % »), pour que la perte soit diagnosticable
après coup. Après trois rappels sans effet, la surveillance se suspend jusqu'au prochain
enregistrement plutôt que d'insister toutes les 30 s.
##### Lecteur figé : rechargement automatique
Le lecteur reste parfois bloqué sur son indicateur de chargement sans jamais repartir. Rien ne
le signale : la plateforme donne toujours le stream public, OBS continue d'enregistrer un
écran d'attente, et seul un rechargement de la page débloque la situation. L'agent le fait
maintenant lui-même, après **30 s sans la moindre avancée de la lecture** (réglable par agent
dans « Pilotage du navigateur », `0` désactive, plancher à 10 s).
**Le blocage se mesure à la position de lecture, pas à l'apparition d'un indicateur.**
`video.currentTime` est une propriété standard de HTML : elle survivra à n'importe quelle
refonte de l'habillage du lecteur, là où guetter une classe CSS de *throbber* se casserait à
la première. Une mise en mémoire tampon de quelques secondes est normale sur un direct — c'est
l'absence prolongée d'avancée qui distingue la panne.
**`paused` ne court-circuite rien, et c'est délibéré.** Vérifié en conditions réelles contre un
Firefox piloté : selon la façon dont le flux se rompt, le lecteur reste tantôt « en lecture »
avec son indicateur (`paused: false`, `readyState` retombé à 2), tantôt suspendu de lui-même
(`paused: true`). Les deux se soldent par une position qui n'avance plus et appellent le même
remède ; les distinguer aurait laissé passer la moitié des cas.
La reprise rejoue **exactement la séquence d'ouverture** — rechargement, attente que le lecteur
démarre, plein écran, puis qualité — le code étant partagé avec le démarrage de capture plutôt
que dupliqué. Pendant ce temps les deux surveillances s'abstiennent : un rechargement fait
forcément perdre plein écran et lecture, et les laisser réagir déclencherait la reprise en
cours d'exécution.
Trois garde-fous, parce que recharger pendant un enregistrement n'est pas anodin :
- **uniquement pendant une capture active et non suspendue** — hors capture il n'y a rien à
sauver, et une pause de show privé arrête légitimement la lecture ;
- **uniquement si la veille donne le stream public**, quand elle est activée. Sans ce second
garde-fou, un opérateur ayant désactivé la mise en pause automatique verrait la page se
recharger en boucle à chaque fin de diffusion — le flux s'arrête alors légitimement sans
qu'OBS se mette en pause ;
- **trois rechargements enchaînés au maximum.** Si recharger n'y change rien, la cause est
ailleurs, et boucler ne ferait que hacher le fichier toutes les 30 s. Le compteur repart dès
que la lecture avance à nouveau, et à chaque nouvelle capture.
Le constat part au journal avec les relevés qui l'ont motivé (« position 412,3 s, readyState 2 »),
pour distinguer après coup un flux qui manque de données d'un lecteur qui s'est suspendu.
**Un seul envoi à la fois, et pas plus d'un toutes les 10 s.** Deux mécanismes indépendants
peuvent réclamer ce rappel : la séquence d'ouverture (une fois, à l'arrivée sur la page) et
la surveillance de l'agent, qui le redemande de lui-même après un show privé. Sans
coordination, un flux qui vacille juste après le démarrage (bref retour en privé pendant que
la séquence d'ouverture patiente encore) fait partir les deux presque en même temps — et la
touche du lecteur *basculant* l'affichage plutôt que le forçant, le second envoi annule le
premier au lieu de le confirmer : la fenêtre, au lieu de s'agrandir, finit par rétrécir. Les
deux mécanismes convergent vers un même point de passage côté agent, qui partage le résultat
d'un envoi déjà en cours plutôt que d'en déclencher un second, et ignore toute nouvelle
demande dans les 10 s suivant la précédente.
**La qualité vidéo se règle après le plein écran, jamais avant.** Le rappel de plein écran
envoie une touche au lecteur, et la plupart des lecteurs vidéo traitent n'importe quelle
touche — pas seulement la souris — comme une activité qui réaffiche leurs contrôles. Réglée
avant, la sélection de qualité (qui termine en écartant le curseur pour laisser les
contrôles disparaître — voir plus bas) se ferait aussitôt annuler par cette touche.
**Un profil Firefox dédié est obligatoire**, pas cosmétique : le port de pilotage ne
s'ouvre qu'au démarrage du processus, et deux instances ne peuvent pas partager un profil.
L'agent en gère un et y réécrit un `user.js` à chaque lancement — chaque préférence y supprime quelque chose qui finirait dans le fichier
enregistré, ou qui empêcherait un démarrage sans surveillance :
| Préférence | Pourquoi |
| --- | --- |
| `full-screen-api.warning.timeout = 0` | le bandeau « … est maintenant en plein écran » se retrouvait dans les premières secondes de chaque fichier |
| `media.autoplay.default = 0` | sans lecture automatique, l'agent enregistre une image fixe sans que rien ne le signale |
| `browser.aboutwelcome.enabled = false`, `browser.startup.page = 0`, … | tout onglet d'accueil passerait devant la page du streamer |
| `browser.sessionstore.resume_from_crash = false` | un dialogue modal bloquerait toute commande |
| `app.update.auto = false` | une mise à jour fermerait la fenêtre que capture OBS, en plein enregistrement |
#### Firefox en snap (Ubuntu)
Sur Ubuntu, `firefox` est un snap, et cela impose deux choses.
L'interface `home` d'un snap **exclut délibérément les fichiers et répertoires cachés**
ceux commençant par un point, réputés contenir des données sensibles. Un profil sous
`~/.stream-control/` y est donc inaccessible, et Firefox meurt sans jamais ouvrir son port —
sans message d'erreur exploitable : la confinement AppArmor bloque au niveau du noyau, pas
dans la sortie du processus.
L'agent détecte le paquet snap de deux façons, l'une ne suffisant pas partout : en suivant
la chaîne de liens de la commande (`/usr/bin/firefox``/snap/bin/firefox`
`/usr/bin/snap`, la forme la plus courante), et en lisant le contenu d'un éventuel script
d'aiguillage — certaines images Ubuntu placent en `/usr/bin/firefox` un petit script qui
exécute le snap en son sein, sans aucun lien symbolique dans la chaîne. Dans les deux cas,
le profil est placé sous `~/snap/firefox/common/stream-control-profile`. Un profil explicitement configuré dans un
répertoire caché est refusé **avant** le lancement, avec l'explication.
Par ailleurs, `firefox` n'est ici qu'un lanceur : il passe la main au snap et **sort
aussitôt en code 0** pendant que le vrai navigateur démarre derrière. L'agent ne traite donc
qu'une sortie non nulle comme un échec ; sinon il abandonnerait le cas nominal au bout de
quelques centaines de millisecondes.
La sortie de Firefox est redirigée vers `<profil>/firefox.log`, et ses dernières lignes
utiles accompagnent tout échec de démarrage — c'est le seul endroit où il explique son
refus. Les lignes `update.go:193: cannot change mount namespace …` sont du bavardage
systématique de snapd, sans rapport avec Firefox : elles sont comptées puis écartées de
l'extrait, sans quoi elles chasseraient la vraie erreur.
Le démarrage laisse **150 s** au navigateur. Ce n'est pas de la prudence : un snap lancé à
froid sur une VM, avec un profil neuf à construire, dépasse couramment la minute. L'agent
signale sa patience toutes les 20 s dans l'historique de la VM.
#### Verrou de profil bloqué
Le profil est exclusif à l'agent : personne n'y travaille à la main en temps normal. Un
lancement qui échoue sans que le processus Firefox s'arrête (l'ancien comportement, avant
correction) y laisse un verrou — et chaque tentative suivante se heurte alors au dialogue
« Firefox est déjà ouvert », qui n'ouvre jamais le port de pilotage et bloque
indéfiniment, y compris un lancement manuel de diagnostic sur ce même profil.
Deux garde-fous, l'un préventif et l'un correctif :
- un lancement qui échoue termine désormais le processus qu'il venait de créer, plutôt que
de le laisser tourner comme verrou pour la prochaine tentative ;
- avant chaque lancement, l'agent identifie (sous Linux, via `/proc/<pid>/cmdline`) tout
processus dont la ligne de commande référence exactement ce profil, le termine, puis
efface le verrou. La correspondance porte sur le chemin exact du profil : rien d'autre
ne peut être atteint par erreur.
**Au premier passage en BiDi, une nouvelle fenêtre Firefox s'ouvre** : pointe la source
« capture de fenêtre » d'OBS dessus une fois. Ensuite elle survit aux enregistrements
comme aux redémarrages de l'agent — le processus est lancé détaché, et l'agent se
rattache au port plutôt que de relancer.
#### Session déconnectée (cookies)
Le profil piloté part vierge, par choix — c'est ce qui le rend indépendant du reste de la
session graphique, insensible à ce que l'opérateur ouvre ou ferme par ailleurs. Mais un site
comme Stripchat exige une session ouverte, et rejouer un formulaire de connexion à chaque
redémarrage n'a rien d'automatisable proprement.
Le bouton **« Importer les cookies du Firefox personnel »**, dans la configuration de
l'agent, copie `cookies.sqlite` (et `permissions.sqlite`) depuis le profil Firefox normal de
l'utilisateur vers le profil piloté. Toujours en lecture seule côté source : rien n'y est
jamais écrit. Connecte-toi d'abord dans ce Firefox-là, celui que tu utilises au quotidien sur
la VM ; l'agent retrouve son profil par défaut via `profiles.ini`, comme le ferait Firefox
lui-même au démarrage.
L'instance pilotée en cours, s'il y en a une, est fermée par l'opération : la capture
suivante la relance et repart avec les cookies fraîchement copiés. Un import se refait à
volonté — chaque copie remplace la précédente, rien ne s'accumule.
Limite assumée : seuls les cookies sont copiés, pas le `localStorage` ni l'`IndexedDB`. La
quasi-totalité des sites, Stripchat compris, portent la session dans un cookie ; si un site
s'appuyait uniquement sur un jeton en stockage local, cet import ne suffirait pas.
#### Qualité du flux Stripchat
À ne pas confondre avec les presets d'enregistrement ci-dessus : ceux-là règlent ce qu'**OBS**
écrit sur disque ; ce réglage-ci règle ce que le **lecteur Stripchat** affiche à l'écran, donc
ce qu'OBS capture en premier lieu. Un preset au maximum ne sert à rien si le lecteur diffuse
en 720p — OBS ne peut pas inventer des pixels absents du flux source.
Stripchat ne démarre pas toujours sur sa meilleure qualité disponible, et chaque nouvelle page
(donc chaque nouveau stream ouvert) repart de ce choix du site, pas du précédent réglage
manuel. En mode BiDi, l'agent clique le bouton de qualité du lecteur puis la meilleure option
du menu :
1. repère le bouton (`.player-resolution`) et clique dessus,
2. attend le menu, puis clique l'option marquée « (Source) », ou à défaut celle dont la
valeur numérique (extraite de « 1080p », « 960p »…) est la plus élevée.
Le clic part par un vrai évènement souris WebDriver (`input.performActions`), pas par un
`.click()` scripté dans la page : un essai au `.click()` DOM laissait le menu fermé — le site
ignore apparemment les clics non fiables (`isTrusted: false`) sur ce bouton.
La comparaison porte sur la valeur numérique, pas sur la position dans la liste : une échelle
tronquée (rien au-dessus de 480p) ou une valeur atypique — souvent le cas des flux
verticaux, dont la hauteur source ne suit pas l'échelle standard 240/480/720/1080 — ne
trompent donc pas le choix.
Couplé à la structure actuelle du lecteur Stripchat — deux classes CSS
(`.player-resolution`, `.player-resolution-tooltip__button--resolution`) relevées en
inspectant la page en direct. Sans effet, mais sans erreur non plus (juste un avertissement
dans l'historique), si Stripchat change ce balisage — l'avertissement inclut alors un extrait
du menu tel qu'ouvert, pour diagnostiquer sans repasser par les DevTools.
**Refermer le menu ne passe jamais par la touche Échap.** Quand la qualité active est déjà la
meilleure, il n'y a rien à cliquer — le menu se referme en recliquant le bouton qui l'a
ouvert, pas avec Échap comme un premier essai le faisait. Le rappel de plein écran a lieu
avant ce réglage (voir plus haut), et si la touche du lecteur a fait entrer la page dans l'API
plein écran native du navigateur (certains lecteurs mappent leur propre touche de plein écran
dessus), Échap en fait sortir aussitôt — c'est le navigateur qui l'intercepte pour ça, avant
même que la page n'ait son mot à dire, et rien côté site ne peut s'y opposer. Le symptôme :
la fenêtre semblait rétrécir juste après le réglage de qualité, comme un second rappel de
plein écran qui aurait annulé le premier — alors qu'il ne s'agissait que d'un Échap qui
sortait du plein écran natif que la touche du lecteur venait d'activer.
**Le curseur est écarté du lecteur une fois la qualité réglée**, sans quoi la barre de
contrôles du lecteur — qui se garde affichée tant qu'une souris réelle la survole — resterait
visible en permanence, faute de mouvement ultérieur. Le point de sortie est **calculé, pas
deviné** : une marge est cherchée autour de la position réelle de l'élément vidéo (en
dessous en priorité, sinon au-dessus, à droite ou à gauche), confirmée par
`elementFromPoint` plutôt que supposée à un endroit fixe de la page. Si le lecteur couvre
tout le viewport sans la moindre marge, un évènement `mouseleave` non fiable est émis en
dernier recours sur l'élément vidéo et ses parents proches — sans garantie si le site vérifie
`isTrusted` dessus comme il le fait sur le clic du bouton de qualité, mais sans risque non
plus à tenter.
#### Lancement simple
Conservé comme défaut pour ne pas changer le comportement d'un agent existant à la mise à
jour. 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/<uid>/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) |
La fenêtre du lecteur passe **au premier plan** : les navigateurs ignorent les évènements
clavier synthétiques envoyés sans focus (`XSendEvent`).
Le **titre de fenêtre** à renseigner est celui de la fenêtre, pas le nom du processus. Sous
Firefox il vaut `<titre de la page> — 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.
| 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 |
Le bouton ⛶ sur la fiche de l'agent renvoie la touche à la demande, et ⟳ force une sonde
immédiate.
### 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.
**La réponse est le mode WebDriver BiDi**, qui ne passe pas par le serveur d'affichage.
Les contournements d'avant sont conservés ici pour mémoire, mais ils coûtent tous quelque
chose et aucun n'est à préférer :
- forcer Firefox sous XWayland (`MOZ_ENABLE_WAYLAND=0`) ajoute une copie d'image par trame ;
- basculer la session en Xorg fait chuter les performances de capture (voir
« Xorg, Wayland et le coût de la capture »).
## 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.
- **Un preset en attente est toujours écrit avant le `StartRecord` suivant.** Changer de
preset juste avant de lancer une capture (la sienne, ou celle d'un autre streamer sur la
même VM) ne dépend pas d'un minuteur en tâche de fond : le démarrage attend explicitement
que l'écriture soit passée. Sans cette garantie, une capture pouvait démarrer sur l'ancien
réglage et y rester verrouillée jusqu'à son propre arrêt — le symptôme perçu était « la
qualité ne se met jamais au maximum, et repart au défaut à chaque nouveau stream ».
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 ».
### Diagnostiquer une chute de performance
La fiche de l'agent sépare les deux goulots qu'OBS mesure distinctement. Les confondre mène
à régler le mauvais paramètre.
| Métrique | Ce qu'elle dit | Leviers |
| --- | --- | --- |
| **Perdu · rendu** | La scène n'a pas été composée à temps | Méthode de capture, définition de la scène, accélération 3D du bureau |
| **Perdu · encodage** | La scène était prête, l'encodeur n'a pas suivi | Preset plus léger, encodeur matériel |
| **Rendu** (ms) | Durée moyenne de composition d'une image | Doit rester bien sous `1000 / fps` (33 ms à 30 fps) |
Les compteurs cumulent depuis le lancement d'OBS, d'où l'affichage en pourcentage : sous
1 %, il n'y a rien à faire ; au-delà de quelques pour cent, le poste indiqué est saturé.
Changer de preset ou d'encodeur ne corrige **que** la colonne encodage. Si c'est le rendu
qui décroche, le problème est en amont d'OBS.
### Xorg, Wayland et le coût de la capture
Sur une VM sans accélération 3D, la méthode de capture pèse lourd :
- **Wayland** : OBS passe par PipeWire, le compositeur lui remet les images déjà composées.
- **Xorg** : la capture d'écran XSHM recopie tout le tampon à chaque image. La capture de
fenêtre XComposite est nettement plus économe — à préférer systématiquement.
Xorg n'était nécessaire que pour le rappel du plein écran par xdotool. Le mode de pilotage
« WebDriver BiDi » supprime cette contrainte : **Wayland est le bon choix**, en performance
comme en simplicité.
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 les deux
colonnes de 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.
**Le plein écran est rappelé au retour, comme après un show privé.** Le lecteur en sort aussi
bien sur l'écran d'attente d'un flux coupé que sur l'overlay d'un show privé. Le cas manquait
initialement — seul le show privé armait le rappel — et il ne se limite pourtant pas à une fin
de diffusion : une coupure plus courte que le délai de clôture reprend le **même**
enregistrement, dans la **même** fenêtre, qui a entre-temps perdu son plein écran sans que
rien ne le lui rende.
Le rappel ne part que si une capture tourne réellement. Le surveillant, lui, tourne en
permanence : sans ce garde-fou, n'importe quel profil surveillé qui passe hors-ligne puis en
direct enverrait une touche à une fenêtre que personne ne filme — et en mode BiDi, irait
jusqu'à lancer Firefox pour l'occasion.
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.
## Notifications Pushover
Onglet **Notifications** du dashboard. Complète — sans le remplacer — le mécanisme déjà en
place (notification navigateur sur l'onglet Streamers, cloche 🔔/🔕 par profil) : celui-ci
exige un onglet du dashboard ouvert et l'autorisation du navigateur ; Pushover atteint
l'opérateur même dashboard fermé, sur son téléphone.
Réglage global, pas par agent : crée une application sur
[pushover.net/apps/build](https://pushover.net/apps/build) (une seule suffit pour tous les
usages de Stream Control), renseigne son jeton et ta clé utilisateur, coche « Activer », puis
« Envoyer un test » avant d'enregistrer — une notification de test part directement sur les
identifiants du formulaire, sans attendre un vrai passage en direct pour découvrir une faute
de frappe.
Déclenché sur le même évènement que la notification navigateur : un profil dont `notify` est
coché (la cloche 🔔) passant à l'état public. **Un profil nouvellement suivi est silencieux**
suivre quelqu'un sert souvent à l'observer, et une liste qui grandit ne doit pas transformer
le téléphone en sonnette. La cloche s'active profil par profil, depuis sa fiche. Un échec d'envoi (identifiants invalides,
Pushover injoignable) finit dans le journal, jamais en erreur qui interromprait la veille —
c'est un canal secondaire, pas une condition de son fonctionnement.
Les identifiants ne repartent jamais en clair vers le navigateur : `GET /api/settings/pushover`
les renvoie masqués (`********`), comme le mot de passe obs-websocket ailleurs dans
l'interface. Les renvoyer tels quels au `PUT` laisse la valeur enregistrée inchangée.
## 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.
Plusieurs profils peuvent être en automatisme sur la même VM ; c'est la
[priorité](#priorité-et-interruption) qui les départage. **Par défaut la capture en place
l'emporte** : un profil qui trouve sa VM occupée passe son tour sans rien écraser.
**Une capture déjà ouverte n'est jamais remplacée par une seconde.** Avant de démarrer, le
serveur demande son état réel à l'agent (`obs.refresh`) au lieu de lire le dernier statut reçu.
La nuance est décisive : ce statut retombe à « n'enregistre pas » dès qu'une connexion se coupe,
et repart vide au redémarrage du serveur. Dans cette fenêtre, une VM occupée passait pour libre
— et l'automatisme rouvrait une capture par-dessus une autre, bien vivante, **du même
streamer**. Le symptôme : un passage hors-ligne de quelques minutes, la capture mise en pause,
puis le retour en direct qui la coupait pour en recommencer une.
Selon ce que l'agent répond :
- **capture du même profil en pause** → elle est **reprise**, pas relancée. Le fichier reste le
même ; en ouvrir un second aurait perdu le premier ;
- **capture du même profil en cours** → rien à faire ;
- **capture d'un autre profil** → l'arbitrage de [priorité](#priorité-et-interruption) s'applique ;
- **aucune capture** → l'enregistrement démarre, comme attendu.
Second garde-fou en amont : tant qu'un agent n'a rien rapporté sur sa connexion courante,
l'automatisme s'abstient. « On ne sait pas encore » n'est pas « la VM est libre », et attendre
le cycle de veille suivant ne coûte rien.
**Trois tentatives par diffusion.** Les échecs restants une fois les 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. Passer son tour faute de VM libre
n'en consomme aucune — sinon un profil bloqué une heure aurait épuisé ses essais avant même
que la machine ne se libère.
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.
## Priorité et interruption
Une VM ne capture qu'un flux à la fois, mais rien n'empêche d'y assigner plusieurs profils.
Deux réglages, sur la fiche d'un streamer, disent qui l'emporte.
**Priorité***Basse, Normale (défaut), Haute, Critique*. L'échelle est globale pour rester
comparable partout, mais **elle ne tranche qu'entre profils assignés au même agent** : deux
VM distinctes ne se disputent rien, et un classement global mêlerait des profils qui ne se
croiseront jamais. La fiche affiche donc l'ordre qui compte — celui de la VM :
« Ordre sur vm-rec-01 : 1. alice (Haute) ⚡ · 2. bob (Normale) ».
**Peut interrompre un enregistrement en cours** — décoché par défaut. C'est l'autorisation
de couper une capture de rang inférieur pour prendre sa place.
Ce droit est délibérément **séparé de la priorité, et non déduit d'elle** : interrompre
détruit la fin d'un fichier. Cela ne doit pas découler d'un simple changement de rang, mais
d'un choix explicite, profil par profil.
L'interruption n'a lieu que si les trois conditions sont réunies :
1. le profil candidat a le droit d'interrompre ;
2. sa priorité est **strictement** supérieure — à rang égal, la capture en place reste, ce
qui rend les égalités inoffensives ;
3. la capture en cours est **identifiable**, c'est-à-dire rattachée à un profil suivi. Une
capture lancée à la main depuis l'onglet Agents n'est jamais coupée : on ignore ce
qu'elle vaut, et il n'y a aucune priorité à lui comparer.
La capture évincée est **close proprement** — commande d'arrêt, puis attente de la
confirmation de l'agent avant de relancer — et non écrasée : son fichier reste. Le journal
en garde une trace en avertissement : « Priorité : « bob » interrompu au profit de
« alice » sur vm-rec-01 ».
La règle vaut aussi pour le bouton **Enregistrer** : quand elle s'applique, il annonce ce
qu'il fera (« Enregistrer (interrompt bob) ») plutôt que de le faire découvrir par un échec ;
sinon il reste désactivé, avec le motif du refus en infobulle.
Enfin, la veille sonde les profils **du plus prioritaire au moins prioritaire**. C'est
l'ordre de sondage qui décide qui tente sa chance en premier sur une VM libre : sonder au
hasard laisserait un profil mineur s'en emparer, pour se faire couper dans la foulée par son
aîné — même résultat, au prix d'un fichier de quelques secondes.
## Étiquettes
Des libellés libres posés sur les profils, pour trier une liste qui grandit. Ils se créent
**en les saisissant** sur la fiche d'un streamer (onglet Streamers → clic sur une vignette →
section « Étiquettes ») : rien ne distingue à la frappe un nom neuf d'un nom déjà connu, donc
un nom inconnu crée l'étiquette au passage, et un nom déjà pris la réutilise. Les étiquettes
existantes sont proposées juste en dessous du champ, pour éviter d'en créer une seconde à une
lettre près.
**La casse ne crée pas de doublon** : « Blonde » et « blonde » sont la même étiquette, garanti
par un index unique `COLLATE NOCASE`. Les espaces multiples sont réduits et les extrémités
coupées pour la même raison — deux entrées indiscernables à l'œil dans la liste des filtres
seraient pires qu'un refus.
La barre de filtres de l'onglet Streamers les reprend toutes. **Entre étiquettes le filtre est
une union** : en cocher une seconde élargit la sélection, ce qui est le geste attendu quand on
empile des critères de même nature. Le filtre de statut (Tous / Favoris / En direct), lui, se
**cumule** avec — « en direct » ET portant l'une des étiquettes retenues.
**La couleur d'une étiquette est calculée depuis son nom**, jamais choisie : demander une
couleur à chaque création alourdirait un geste qui doit rester une frappe au clavier. Le même
nom donnant toujours la même teinte, une étiquette se reconnaît d'une fiche à l'autre sans que
rien ne soit stocké pour ça.
Le bouton **⚙ Étiquettes** de la barre de filtres ouvre le seul écran qui les gère globalement :
renommer (répercuté partout) et supprimer (retirée de tous les profils qui la portaient, sans
en supprimer aucun). Sans lui, une étiquette mal nommée serait définitive. Le renommage
**refuse une collision** plutôt que de fusionner en silence : corriger une faute de frappe
n'est pas demander à faire disparaître l'autre étiquette des profils qui la portent.
Une étiquette survit à la suppression du dernier profil qui la portait — elle reste disponible
pour la suite. Inversement, ne plus suivre un profil n'emporte que ses rattachements
(`ON DELETE CASCADE` des deux côtés de la table de liaison).
## Vignette du flux en direct
Chaque fiche compacte d'un profil **en direct** (onglet Streamers) affiche une image du
flux — pas une vidéo : le serveur n'a que l'API REST publique de la plateforme, ce n'est
pas l'agent qui pilote un navigateur ici. Au survol, l'image grandit et passe au premier
plan, quitte à déborder sur les cartes voisines.
Source : le même appel que le statut (`/api/front/v2/models/username/{pseudo}/cam`), sans
requête supplémentaire — mais pas son champ `previewUrlThumbBig` / `previewUrlThumbSmall` :
malgré leur nom, ce sont ceux de la photo de couverture du profil, fixe, choisie par le
modèle, pas une image de la diffusion en cours (repéré à l'usage : l'aperçu ne changeait
jamais). La vraie vignette caméra se reconstruit à partir de deux autres champs de la même
réponse, `id` et `snapshotTimestamp`, en `https://img.doppiocdn.net/thumbs/{snapshotTimestamp}/{id}`
— confirmé en comparant les deux : contenu différent, cache CDN de 30 minutes au lieu de
30 jours, `Last-Modified` à quelques secondes de la requête. Cet horodatage étant dans le
chemin de l'URL et non dans un paramètre, chaque nouvelle vignette porte déjà sa propre URL —
inutile de forcer l'invalidation du cache navigateur en plus.
Se rafraîchit donc au rythme de la veille (`WATCHLIST_INTERVAL_MS`, 30 s par défaut) — un
aperçu, pas un flux temps réel.
**Sa taille se règle** depuis la barre de filtres de l'onglet Streamers : *Aucun*, *Petit*,
*Moyen* (par défaut) ou *Grand*. Selon la largeur de l'écran et le nombre de profils suivis,
un aperçu pleine largeur rend la liste agréable ou la rend illisible à force de faire défiler
— d'où un réglage plutôt qu'un choix imposé. C'est la **largeur** qui est bridée, pas la
hauteur : la boîte 16/9 entière rétrécit, là où une hauteur maximale l'aurait recadrée en
bandeau et rogné l'image.
*Aucun* ne se contente pas de masquer : la vignette n'est alors pas rendue du tout, donc pas
téléchargée — ce sont des images distantes, autant ne pas les demander quand personne ne les
regarde. Le choix est mémorisé dans le navigateur : c'est un confort propre à l'écran qu'on a
sous les yeux, pas un réglage à imposer aux autres postes.
## Frise des diffusions
Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, onglet
**Streamers**) montre la sienne ; l'onglet **Timeline** les empile toutes, une voie par
profil.
Les diffusions publiques s'y lisent en bleu, **les portions capturées se superposent en
rouge**, et **les passages en pause en jaune** par-dessus le rouge — la capture tournait, mais
n'écrivait rien. Le tout sur la même piste. C'est le point de la vue : ce qui intéresse n'est
pas « quand ai-je enregistré » dans l'absolu mais quelle part de chaque diffusion a été
gardée — deux pistes séparées obligeraient l'œil à faire l'alignement lui-même. La vue
globale rend visibles les chevauchements : deux streamers en direct en même temps sur une
seule VM, et ce qui, faute de machine libre, est passé à côté.
### Une vignette par diffusion, conservée
**Survoler une barre bleue montre une image de ce stream-là**, même des mois après. C'est ce
qui permet de reconnaître une diffusion d'un coup d'œil dans une frise qui, sans ça, n'aligne
que des rectangles interchangeables.
L'image est **copiée sur le serveur**, et non pointée vers la plateforme : la vignette servie
en direct sur l'onglet **Streamers** vient du CDN de Stripchat, qui la remplace toutes les
demi-heures environ. Passé la diffusion, son URL ne renvoie plus rien d'exploitable — rien de
ce qui s'affiche en direct ne survit au stream. D'où la copie, prise pendant qu'elle existe
encore.
Une seule par diffusion, jamais remplacée ensuite : c'est une illustration destinée à
reconnaître un stream, pas un suivi de son déroulé. Elle est prise **une minute après le début
du stream** (`PREVIEW_DELAY_MS`), pour la même raison que le délai d'amorçage de
l'enregistrement automatique : les premières secondes d'un flux montrent souvent un écran de
démarrage ou une pièce vide, et un passage éclair en public au sortir d'un show privé ne
mérite pas d'illustration. Une diffusion plus courte que ce délai n'en aura donc pas.
Les fichiers vivent dans `data/previews/` (`PREVIEWS_DIR`), à côté de la base et non dedans —
ce sont des dizaines de kilo-octets chacun, que SQLite n'a aucune raison de porter. Ne plus
suivre un profil efface ses diffusions en cascade côté SQLite, mais SQLite ne connaît rien du
disque : un **ménage quotidien** (et au démarrage) supprime les images que plus aucune
diffusion ne réclame.
La route qui les sert accepte le jeton de session en paramètre d'URL — une balise `<img>` ne
permet pas d'ajouter un en-tête, comme pour le flux temps réel. Le fichier ne changeant jamais
une fois écrit, il est servi avec un cache immuable.
### Ce qui est enregistré, et comment
| Table | Écrite par | Une ligne = |
| --- | --- | --- |
| `stream_sessions` | la veille, sur transition d'état | une diffusion publique observée |
| `recording_spans` | le hub, sur changement de statut d'un agent | un intervalle de capture ouvert |
| `recording_pauses` | le hub, sur changement de statut d'un agent | une pause à l'intérieur d'un intervalle |
Plusieurs tables et non une : un enregistrement démarre en général après le début du stream,
peut s'arrêter avant sa fin, et une même diffusion peut en compter plusieurs.
**Une pause OBS (show privé, flux hors-ligne) ne coupe pas l'intervalle** — elle n'écrit rien
mais ne ferme pas le fichier. La découper en deux intervalles ferait passer les morceaux pour
deux captures distinctes, alors qu'il n'y en a qu'une, et qu'un seul fichier. Elle est donc
relevée à part, **en surimpression**, et apparaît en jaune par-dessus le rouge de la capture :
la barre se lit d'un coup d'œil comme « la capture tournait ici, mais n'écrivait rien ». Les
totaux annoncés déduisent ces pauses — les y inclure surestimerait ce qui est réellement
dans le fichier.
Deux invariants tiennent ce relevé : les ouvertures et fermetures de pause sont **idempotentes**
(le hub les rejoue à chaque cycle de statut, toutes les 2 s, et elles n'agissent qu'aux
transitions), et **aucune pause ne survit à l'intervalle qui la porte**. Ce second point est
réparé après coup plutôt que supposé : une VM disparue en pleine pause voit son intervalle
refermé par le balayage périodique, sans passer par le chemin qui referme les pauses — sans
cette réparation, la frise resterait jaune jusqu'à maintenant.
Le rattachement d'une capture à un profil passe par le pseudo sur lequel la veille de l'agent
est calée — c'est `startTargetRecording()` qui l'y pose. Une capture lancée à la main depuis
l'onglet Agents, sans ce calage, reste sans profil et n'apparaît sur aucune voie.
**Une capture dont on n'a jamais vu la fin** (VM éteinte en plein enregistrement, serveur
redémarré) est refermée par un balayage périodique — à sa dernière preuve de vie, pas à
l'heure du balayage. Sans cela une VM disparue une semaine passerait pour avoir enregistré
une semaine. La colonne `last_seen_at`, réécrite à chaque statut reçu, sert exactement à ça,
et la tolérance de 60 s laisse un agent qui redémarre retrouver son intervalle plutôt que
d'en ouvrir un second.
Supprimer un profil suivi efface son historique (`ON DELETE CASCADE`) : ne plus suivre
quelqu'un, c'est aussi l'oublier.
### « Cette VM enregistre » n'est pas « ce profil est enregistré »
Plusieurs profils peuvent être assignés à la même VM, mais elle n'en capture qu'un à la
fois. L'interface distingue donc les deux, partout où un état d'enregistrement paraît :
- le profil réellement capturé porte « ⏺ enregistrement en cours » et le bouton **Arrêter** ;
- les autres profils de la même VM affichent « VM occupée : *nom du profil capturé* », avec
le bouton **Enregistrer** désactivé — ils ne sont pas enregistrés, et la fiche détaillée
l'explique en toutes lettres.
## 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.
En mode **WebDriver BiDi** le problème disparaît : l'agent ne relance jamais de processus,
il navigue dans l'onglet qu'il pilote. La fenêtre est ouverte une fois et survit à tout,
y compris à un redémarrage de l'agent — celui-ci se rattache au port au lieu de relancer.
La seule fois où il faut repointer la source OBS est le passage initial en BiDi.
En mode **lancement simple**, l'agent évite les deux pièges :
- **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.
### « Firefox est déjà ouvert »
Propre au mode **lancement simple** : ce dialogue signifie que le processus lancé par
l'agent n'a pas trouvé l'instance déjà en cours, et bute sur le verrou de profil au lieu de
lui confier l'URL. Le mode BiDi ne peut pas le rencontrer — il n'y a qu'une instance, sur
un profil qui n'appartient qu'à l'agent.
Le passage de relais se fait par le **bus de session D-Bus** — le seul mécanisme disponible
sous Wayland, le protocole X de remoting n'y existant pas. Or un service systemd « system »
n'hérite pas de ce bus, pas plus qu'il n'hérite du cookie X. L'agent résout donc lui-même,
à chaque lancement :
| Variable | Origine |
| --- | --- |
| `DISPLAY` | valeur héritée, sinon la socket X présente dans `/tmp/.X11-unix` |
| `XAUTHORITY` | `$XAUTHORITY` s'il existe, puis GDM, Xwayland, `~/.Xauthority` |
| `XDG_RUNTIME_DIR` | valeur héritée, sinon `/run/user/<uid>` |
| `DBUS_SESSION_BUS_ADDRESS` | valeur héritée, sinon la socket `$XDG_RUNTIME_DIR/bus` |
| `WAYLAND_DISPLAY` | valeur héritée, sinon la socket `wayland-<n>` du répertoire d'exécution |
`agent.cjs --check` les affiche toutes. Si le bus ressort en avertissement, vérifie que
l'agent tourne bien sous **le même compte** que la session graphique : l'utilisateur est
choisi par `--user` à l'installation.
### Éviter l'accumulation d'onglets
Propre lui aussi au mode **lancement simple** ; en BiDi l'agent navigue toujours dans le
même onglet.
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 <jeton de session>`.
| 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, favori, priorité, interruption, étiquettes (`tagIds`, remplacement en bloc) |
| `GET` | `/api/tags` | Étiquettes existantes |
| `POST` | `/api/tags` | Crée l'étiquette, ou renvoie celle qui porte déjà ce nom |
| `PATCH` | `/api/tags/:id` | Renomme (409 si le nom est déjà pris) |
| `DELETE` | `/api/tags/:id` | Supprime, et la retire de tous les profils |
| `GET` | `/api/timeline?days=&targetId=` | Diffusions, captures et pauses ; sans `targetId`, tous les profils |
| `GET` | `/api/timeline/sessions/:id/preview` | Vignette conservée d'une diffusion (jeton accepté en query) |
| `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.