972 lines
57 KiB
Markdown
972 lines
57 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.
|
|
- [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.
|
|
|
|
**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.
|
|
|
|
**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, et **les portions réellement capturées se
|
|
superposent en rouge**, 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 réellement capturé |
|
|
|
|
Deux 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é) ne coupe pas l'intervalle — elle n'écrit rien mais ne ferme pas le fichier, et
|
|
la diffusion publique s'est de toute façon interrompue pendant ce temps.
|
|
|
|
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 et captures ; 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.
|