#!/usr/bin/env node import os from 'node:os'; import { WebSocket } from 'ws'; import type { AgentAction, AgentEvent, AgentStatus, AgentToServer, FullscreenSettings, LogLevel, BrowserSettings, PresetApplyResult, RecordingSettings, ServerToAgent, WatchSettings, } from '@stream-control/shared'; import { DEFAULT_BROWSER_SETTINGS, DEFAULT_RECORDING_SETTINGS, DEFAULT_WATCH_SETTINGS, PROTOCOL_VERSION, detectPlatform, emptyStatus, isRecordingEncoder, normalizeBrowserSettings, normalizeRecordingSettings, normalizeWatchSettings, safeJsonParse, } from '@stream-control/shared'; import { loadConfig, persistIdentity, type AgentConfig } from './config.ts'; import { runDiagnostics } from './doctor.ts'; import { ObsController } from './obs.ts'; import { StreamWatcher } from './watcher.ts'; import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts'; import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts'; import { FirefoxController, describeMetrics, type PlaybackSample, type StreamQualityOutcome, } from './firefox.ts'; import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts'; import { sendHotkey } from './hotkey.ts'; import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts'; const AGENT_VERSION = '0.1.0'; /** Empreinte du bundle courant, calculée une fois au démarrage. */ const BUILD_ID = currentBuildId(); const RECONNECT_MIN_MS = 1000; const RECONNECT_MAX_MS = 30_000; let config: AgentConfig; try { config = loadConfig(); } catch (err) { console.error(err instanceof Error ? err.message : err); process.exit(1); } const obs = new ObsController(config.obs); const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, { recordState: () => obs.recordState(), pauseRecording: async () => { await obs.execute('record.pause'); }, resumeRecording: async () => { await obs.execute('record.resume'); }, // Clôture, et non simple `record.stop` : la fenêtre du navigateur doit se // fermer aussi, sans quoi la VM resterait sur une page morte. stopRecording: async () => { await stopCapture(); }, restoreFullscreen: (settings) => restoreFullscreen(settings), }); let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS; /** * Instance Firefox pilotée, créée à la volée. * * Elle n'existe que dans le mode `bidi` : la construire d'office lancerait un * navigateur sur toutes les VM, y compris celles où l'opérateur ouvre les pages * lui-même. */ let firefox: FirefoxController | null = null; let recordingSettings: RecordingSettings = DEFAULT_RECORDING_SETTINGS; /** Un preset attend d'être appliqué : OBS était injoignable ou occupé. */ let presetPending = false; let socket: WebSocket | null = null; let statusTimer: NodeJS.Timeout | null = null; let reconnectTimer: NodeJS.Timeout | null = null; let reconnectDelay = RECONNECT_MIN_MS; let statusIntervalMs = 2000; let shuttingDown = false; // --- Transport vers le serveur de contrôle ---------------------------------- function send(message: AgentToServer): void { if (socket?.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message)); } } /** * Journalise localement et vers le serveur. `event` classe l'entrée dans * l'historique de la VM ; l'omettre reste correct pour ce qui n'est que du * bavardage de progression. */ function report(level: LogLevel, message: string, event?: AgentEvent): void { const prefix = level === 'error' ? '✖' : level === 'warn' ? '!' : '·'; console.log(`${prefix} ${message}`); send({ type: 'log', level, message, ts: Date.now(), event }); } obs.on('log', (level: LogLevel, message: string, event?: AgentEvent) => report(level, message, event), ); watcher.on('log', (level: LogLevel, message: string, event?: AgentEvent) => report(level, message, event), ); function connect(): void { if (shuttingDown) return; const url = new URL(config.serverUrl); console.log(`Connexion au serveur ${url.origin}${url.pathname}…`); socket = new WebSocket(url, { headers: { authorization: `Bearer ${config.token}` }, rejectUnauthorized: !config.insecureTls, handshakeTimeout: 10_000, }); socket.on('open', () => { reconnectDelay = RECONNECT_MIN_MS; send({ type: 'hello', protocol: PROTOCOL_VERSION, agentId: config.agentId, name: config.name, hostname: os.hostname(), platform: detectPlatform(process.platform), agentVersion: AGENT_VERSION, }); }); socket.on('message', (raw) => { const message = safeJsonParse(raw.toString()); if (message) void handleServerMessage(message); }); socket.on('close', (code, reason) => { stopStatusLoop(); socket = null; if (shuttingDown) return; const why = reason.toString() || `code ${code}`; console.warn(`Session serveur fermée (${why}), nouvelle tentative dans ${reconnectDelay / 1000}s`); scheduleReconnect(); }); socket.on('error', (err: Error) => { console.error(`Erreur de connexion : ${err.message}`); }); } function scheduleReconnect(): void { const delay = reconnectDelay; reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); // Surtout pas de .unref() ici : pendant une coupure, ce minuteur est la seule // chose qui maintienne la boucle d'évènements en vie. Déréférencé, le process // sortirait avec le code 0 au lieu de retenter — la reconnexion ne servirait // jamais. On garde plutôt la référence pour l'annuler à l'arrêt. reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); } // --- Traitement des messages serveur ---------------------------------------- async function handleServerMessage(message: ServerToAgent): Promise { switch (message.type) { case 'welcome': { statusIntervalMs = message.statusIntervalMs || statusIntervalMs; if (message.token && message.token !== config.token) { config.token = message.token; config.agentId = message.agentId; persistIdentity(message.agentId, message.token); } else if (!config.agentId) { config.agentId = message.agentId; } obs.applySettings(message.obs); applyWatchSettings(message.watch); applyBrowserSettings(message.browser); applyRecordingSettings(message.recording); report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`); if (message.autoConnectObs && !obs.isConnected) { obs.connect().catch((err: Error) => report('warn', err.message)); } startStatusLoop(); break; } case 'config': { obs.applySettings(message.obs); applyWatchSettings(message.watch); applyBrowserSettings(message.browser); applyRecordingSettings(message.recording); if (message.autoConnectObs && !obs.isConnected) { obs.connect().catch((err: Error) => report('warn', err.message)); } break; } case 'command': { try { const data = await runAction(message.action, message.params ?? {}); send({ type: 'result', requestId: message.requestId, ok: true, data }); void pushStatus(); // état rafraîchi immédiatement après l'action } catch (err) { const text = err instanceof Error ? err.message : String(err); send({ type: 'result', requestId: message.requestId, ok: false, error: text }); report('error', `Échec de « ${message.action} » : ${text}`, 'command.failed'); } break; } case 'ping': { send({ type: 'pong', ts: Date.now() }); break; } } } function requireUrl(params: Record): string { const url = params.url; if (typeof url !== 'string' || !url.trim()) throw new Error('Paramètre « url » manquant'); return url.trim(); } /** * Séquence complète : ouvrir la page, laisser le lecteur démarrer, passer en * plein écran, régler la qualité, lancer l'enregistrement. Chaque étape est * journalisée séparément pour qu'un échec désigne son maillon. L'ordre entre * plein écran et qualité n'est pas arbitraire — voir le commentaire sur * l'appel à {@link selectMaxStreamQuality}. */ async function startCapture(params: Record): Promise { if (!browserSettings.enabled) { throw new Error('Pilotage du navigateur désactivé sur cet agent'); } const url = requireUrl(params); // Une nouvelle capture repart avec ses propres chances : un abandon décidé sur // la précédente ne doit pas laisser celle-ci sans surveillance. fullscreenMisses = 0; stallRecoveries = 0; resetPlaybackTracking(); const opened = await openPage(url); const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs); const fullscreenResult = await preparePlayer(wait); // Dernière chance d'écrire un preset en attente : une fois l'enregistrement // commencé, OBS refuse toute modification jusqu'à l'arrêt. Sans cet appel // explicite, l'écriture dépendait du hasard d'un cycle de statut (toutes les // 2 s) tombant avant le démarrage — perdu, le preset restait en attente // jusqu'au prochain arrêt, et cet enregistrement tournait sur l'ancien réglage. await drainPresetApply(); await obs.execute('record.start'); report('info', `Capture démarrée pour ${url}`, 'capture.started'); return { opened, fullscreen: fullscreenResult, recording: true }; } /** * Laisse le lecteur démarrer, puis le met en état : plein écran, puis qualité. * * Extrait de {@link startCapture} parce que la reprise après blocage * ({@link recoverStalledPlayer}) doit rejouer exactement la même séquence : un * rechargement de page repart d'un lecteur nu, tout comme une ouverture. * Dupliquer l'enchaînement aurait laissé les deux copies diverger. */ async function preparePlayer(waitMs: number): Promise { report('info', `Attente de ${Math.round(waitMs / 1000)} s avant le plein écran`); await sleep(waitMs); const fullscreen = watcher.snapshotSettings.fullscreen; let fullscreenResult: unknown = 'ignoré'; if (fullscreen.enabled) { // Un échec ici ne doit pas empêcher l'enregistrement : mieux vaut capturer // une fenêtre non maximisée que ne rien capturer du tout. fullscreenResult = await restoreFullscreen(fullscreen) .then((outcome) => { // `skipped` prime sur `confirmed` : un envoi non renvoyé n'est jamais // un échec de ce côté-ci, quel que soit le sort de celui qu'il a // réutilisé. const failed = !outcome.skipped && outcome.confirmed === false; report( failed ? 'warn' : 'info', describeFullscreen(outcome, fullscreen.key), failed ? 'fullscreen.failed' : 'fullscreen.restored', ); return outcome; }) .catch((err: Error) => { report('warn', `Plein écran impossible : ${err.message}`, 'fullscreen.failed'); return `échec : ${err.message}`; }); } // Après, et non avant, le plein écran : celui-ci envoie une touche au // lecteur, et la plupart des lecteurs vidéo traitent n'importe quelle touche // comme une activité qui réaffiche les contrôles — y compris une touche sans // rapport avec la qualité. Dans l'ordre inverse, le curseur écarté en fin de // sélection de qualité (pour laisser la surbrillance des contrôles // s'estomper) se faisait donc aussitôt annuler par cette touche. Ici, // l'éloignement du curseur reste la dernière chose à toucher la page avant // l'enregistrement. await selectMaxStreamQuality(); return fullscreenResult; } /** Ouvre une page par la voie correspondant au mode de pilotage. */ async function openPage(url: string): Promise { const bidi = driver(); if (bidi) return bidi.navigate(url); return openUrl(browserSettings, url, report, { forceXWayland: watcher.snapshotSettings.fullscreen.enabled, }); } /** Décharge ou ferme la fenêtre, selon le réglage et le mode de pilotage. */ async function releasePage(): Promise { const bidi = driver(); if (bidi) return browserSettings.onStop === 'close' ? bidi.quit() : bidi.blank(); return browserSettings.onStop === 'close' ? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report) : blankPage(browserSettings, report, { forceXWayland: watcher.snapshotSettings.fullscreen.enabled, }); } async function stopCapture(): Promise { const result = await obs.execute('record.stop'); // Fermer la fenêtre détruit la source « capture de fenêtre » d'OBS, qu'il faut // ensuite repointer à la main. D'où le défaut « page vide », qui décharge le // lecteur sans faire disparaître la fenêtre. let closed: unknown = 'conservée'; if (browserSettings.enabled && browserSettings.onStop !== 'keep') { closed = await releasePage().catch((err: Error) => { report('warn', `Libération de la fenêtre impossible : ${err.message}`, 'command.failed'); return `échec : ${err.message}`; }); } report('info', 'Capture arrêtée', 'capture.stopped'); return { ...(result as object), window: closed }; } /** * Aiguille une action : navigateur, surveillance et clavier sont gérés par * l'agent lui-même ; tout le reste part vers obs-websocket. */ async function runAction(action: AgentAction, params: Record): Promise { switch (action) { case 'watch.check': return watcher.checkNow(); case 'hotkey.fullscreen': return watcher.restoreFullscreen(); case 'browser.open': return openPage(requireUrl(params)); case 'browser.close': return driver()?.quit() ?? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report); case 'browser.importSession': { const bidi = driver(); if (!bidi) { throw new Error( "L'import de session exige le mode de pilotage « WebDriver BiDi » sur cet agent", ); } return bidi.importSession(); } case 'capture.start': return startCapture(params); case 'capture.stop': return stopCapture(); case 'record.start': // Même raison que dans startCapture() : sans ce passage explicite, un // preset changé juste avant restait en attente jusqu'au prochain arrêt, // et cette capture démarrait sur l'ancien réglage. await drainPresetApply(); return obs.execute('record.start'); case 'preset.apply': return applyPresetNow(params); case 'agent.update': return selfUpdate( typeof params.url === 'string' && params.url ? params.url : config.packageUrl, { recordState: () => obs.recordState(), log: (level, message, event) => report(level, message, event), }, ); default: return obs.execute(action, params); } } function applyWatchSettings(raw: WatchSettings | undefined): void { watcher.applySettings(normalizeWatchSettings(raw ?? DEFAULT_WATCH_SETTINGS)); } function applyBrowserSettings(raw: BrowserSettings | undefined): void { browserSettings = normalizeBrowserSettings(raw ?? DEFAULT_BROWSER_SETTINGS); if (browserSettings.mode !== 'bidi') { // On repasse en mode `launch` : le canal n'a plus de sens, mais on laisse // volontairement la fenêtre ouverte — OBS la capture peut-être encore. firefox?.dispose(); firefox = null; return; } if (firefox) firefox.applySettings(browserSettings); else firefox = new FirefoxController(browserSettings, report); } /** Contrôleur BiDi, ou `null` si l'agent n'est pas dans ce mode. */ function driver(): FirefoxController | null { return browserSettings.mode === 'bidi' ? firefox : null; } /** * Au-delà de ce délai depuis le dernier envoi, un nouveau rappel est à * nouveau autorisé. Choisi généreux face au 1200 ms que `restoreFullscreen` * attend en interne avant de relire l'état : le but n'est pas de couvrir * l'animation, mais la fenêtre où le flux public vient tout juste de démarrer * et peut encore vaciller (bref retour en privé, requalification du statut). */ const FULLSCREEN_COOLDOWN_MS = 10_000; /** Aucun rappel encore envoyé sur cette instance d'agent. */ let lastFullscreenAt = 0; /** Deux appels concurrents doivent partager le même envoi, pas en déclencher deux. */ let fullscreenInFlight: Promise | null = null; /** * Rappelle le plein écran par la voie correspondant au mode de pilotage. * * En BiDi, la demande part dans Firefox et son effet est relu dans la page. En * `launch`, on en reste à une touche envoyée au serveur d'affichage, sans * moyen de savoir ce qu'elle a produit — d'où le `confirmed` absent. * * Point de passage unique, et c'est voulu : deux mécanismes indépendants * peuvent réclamer ce rappel — la séquence d'ouverture ({@link startCapture}) * et {@link StreamWatcher}, qui le redemande de lui-même après un show privé. * Sans coordination, un flux qui vacille juste après le démarrage (un retour * bref en privé pendant que la séquence d'ouverture patiente encore) fait * partir les deux envois à quelques secondes d'écart. La touche du lecteur * basculant l'affichage plutôt que le forçant, le second envoi annule le * premier au lieu de le confirmer — d'où une fenêtre qui, au lieu de * s'agrandir, finit par rétrécir. Le garde-fou vit ici et non chez l'un des * deux appelants : c'est le seul endroit que les deux traversent forcément. */ async function restoreFullscreen(settings: FullscreenSettings): Promise { // Un envoi est déjà en cours : partager son résultat, pas en déclencher un // second. Marqué `skipped` malgré le succès qu'il rapporte — c'est cette // marque, et non la réussite, qui doit gouverner le message affiché à // l'appelant (voir describeFullscreen) : sans elle, les deux appelants // journalisent chacun « touche envoyée » pour un seul envoi réel, ce qui // se lit comme deux rappels distincts alors qu'il n'y en a eu qu'un. if (fullscreenInFlight) { const outcome = await fullscreenInFlight; return { ...outcome, skipped: true }; } const sinceLast = Date.now() - lastFullscreenAt; if (lastFullscreenAt > 0 && sinceLast < FULLSCREEN_COOLDOWN_MS) { return { method: 'ignoré (rappel trop rapproché)', skipped: true }; } const bidi = driver(); fullscreenInFlight = ( bidi ? bidi.restoreFullscreen(settings) : sendHotkey({ key: settings.key, windowMatch: settings.windowMatch }) ).finally(() => { fullscreenInFlight = null; lastFullscreenAt = Date.now(); }); return fullscreenInFlight; } // --- Surveillance du plein écran --------------------------------------------- /** * Fréquence de relecture du plein écran pendant une capture. * * Espacée : la mesure est une évaluation dans la page, et rien ne justifie de * la refaire au rythme du statut (2 s). Une perte de plein écran gâche au pire * cette demi-minute de vidéo, là où une relecture serrée pèserait en continu. */ const FULLSCREEN_WATCH_MS = 30_000; /** * Rappels de surveillance restés sans effet, à la suite. Au-delà, on cesse * d'insister : réappuyer toutes les 30 s sur une touche qui ne produit rien * noierait l'historique sans rien corriger. Le compteur repart à chaque * nouvelle capture, et à chaque rappel qui aboutit. */ let fullscreenMisses = 0; const MAX_FULLSCREEN_MISSES = 3; let fullscreenTimer: NodeJS.Timeout | null = null; /** * Fréquence d'échantillonnage de la lecture. * * Plus serrée que la relecture du plein écran : c'est l'écart entre deux * relevés qui borne la précision du seuil de blocage. À 10 s, un blocage * configuré à 30 s est constaté entre 30 et 40 s après coup. */ const PLAYBACK_SAMPLE_MS = 10_000; let playbackTimer: NodeJS.Timeout | null = null; /** Dernière position de lecture observée, et l'instant où elle a bougé. */ let lastPlaybackTime: number | null = null; let lastProgressAt = 0; /** * Rechargements enchaînés sans que la lecture reparte. Au-delà, on cesse : si * recharger n'y fait rien, le problème est ailleurs, et boucler ne ferait que * hacher l'enregistrement toutes les 30 s. Le compteur repart dès que la * lecture avance à nouveau, et à chaque nouvelle capture. */ let stallRecoveries = 0; const MAX_STALL_RECOVERIES = 3; /** * Une reprise est en cours : le lecteur est délibérément en train d'être * rechargé. Les deux surveillances s'abstiennent le temps que ça se fasse — * sans quoi le rechargement, qui fait forcément perdre plein écran et lecture, * déclencherait la reprise qu'il est justement en train d'exécuter. */ let recovering = false; /** Repart de zéro : plus rien à comparer sur ce lecteur. */ function resetPlaybackTracking(): void { lastPlaybackTime = null; lastProgressAt = 0; } /** * Détecte un lecteur figé et le remet en route. * * Le lecteur reste parfois coincé sur son indicateur de chargement sans jamais * repartir. Rien ne le signale : la plateforme donne toujours le stream public, * OBS continue d'enregistrer, et seul un rechargement de la page débloque la * situation. C'était jusqu'ici une intervention manuelle. * * Le blocage se mesure à l'absence d'avancée de la position de lecture, et non * à l'apparition d'un indicateur : c'est le symptôme réel, il ne dépend * d'aucune classe CSS, et il survivra à n'importe quelle refonte du lecteur. */ async function watchPlayback(): Promise { const bidi = driver(); const timeout = browserSettings.stallTimeoutMs; if (!bidi || timeout <= 0 || recovering) return; if (stallRecoveries >= MAX_STALL_RECOVERIES) return; // Même garde que la surveillance du plein écran : hors capture il n'y a rien // à sauver, et une pause de show privé arrête légitimement la lecture — la // prendre pour un blocage rechargerait la page à chaque passage en privé. const record = await obs.recordState().catch(() => null); if (!record?.active || record.paused) { resetPlaybackTracking(); return; } // Second garde-fou, indispensable quand la mise en pause automatique est // désactivée : le flux s'arrête alors légitimement (show privé, fin de // diffusion) sans qu'OBS se mette en pause, et un lecteur à l'arrêt n'a rien // d'anormal. Recharger n'y changerait rien, et la boucle hacherait le fichier. if (watcher.snapshotSettings.enabled && watcher.snapshot.state !== 'public') { resetPlaybackTracking(); return; } const sample = await bidi.samplePlayback(); if (!sample) { resetPlaybackTracking(); return; } const now = Date.now(); // Une position qui recule ou qui saute désigne un lecteur remplacé ou un // repositionnement du direct : c'est du mouvement, donc le contraire d'un // blocage. if (lastPlaybackTime === null || sample.currentTime !== lastPlaybackTime) { lastPlaybackTime = sample.currentTime; lastProgressAt = now; // La lecture repart : les rechargements précédents ont porté leurs fruits, // ou le blocage s'est résorbé seul. stallRecoveries = 0; return; } // `paused` ne court-circuite rien, et c'est délibéré. Vérifié en conditions // réelles : selon la façon dont le flux se rompt, le lecteur reste tantôt // « en lecture » avec un indicateur de chargement, tantôt suspendu de // lui-même. Les deux se soldent par une position qui n'avance plus, et les // deux appellent le même remède — le distinguer ne ferait que laisser passer // la moitié des cas. const stalledFor = now - lastProgressAt; if (stalledFor < timeout) return; await recoverStalledPlayer(stalledFor, sample); } /** Recharge la page, puis remet le lecteur en état — plein écran et qualité. */ async function recoverStalledPlayer(stalledFor: number, sample: PlaybackSample): Promise { const bidi = driver(); const url = bidi?.state.url; if (!bidi || !url || url === 'about:blank') { resetPlaybackTracking(); return; } recovering = true; stallRecoveries += 1; // Les relevés partent avec le constat : c'est ce qui permet de distinguer // après coup un flux qui manque de données d'un lecteur qui s'est suspendu. report( 'warn', `Lecteur figé depuis ${Math.round(stalledFor / 1000)} s ` + `(position ${sample.currentTime.toFixed(1)} s, readyState ${sample.readyState}` + `${sample.paused ? ', suspendu' : ''}) — rechargement de la page ` + `(${stallRecoveries}/${MAX_STALL_RECOVERIES})`, 'browser.opened', ); try { await bidi.navigate(url); // Même séquence qu'à l'ouverture d'une capture : la page revient nue, il // faut lui rendre le plein écran et la qualité. Le compteur d'échecs du // plein écran repart aussi — c'est une page neuve, pas la précédente. fullscreenMisses = 0; await preparePlayer(browserSettings.readyDelayMs); report('info', 'Lecteur rechargé et remis en état', 'browser.opened'); if (stallRecoveries >= MAX_STALL_RECOVERIES) { report( 'warn', `Le lecteur s'est figé ${MAX_STALL_RECOVERIES} fois de suite : surveillance suspendue ` + "jusqu'au prochain enregistrement. Recharger n'y change rien, la cause est ailleurs.", 'command.failed', ); } } catch (err) { report( 'error', `Reprise du lecteur figé impossible : ${err instanceof Error ? err.message : String(err)}`, 'command.failed', ); } finally { // Après coup, pas avant : le temps passé à recharger ne doit pas compter // comme une absence d'avancée, sans quoi la reprise suivante partirait // immédiatement. resetPlaybackTracking(); recovering = false; } } /** * Vérifie que le lecteur occupe toujours la fenêtre, et le rétablit sinon. * * C'est le pendant du rappel programmé par {@link StreamWatcher} : celui-ci ne * réagit qu'aux interruptions du flux, alors que le plein écran peut se perdre * sans que le statut bouge — un clic malheureux, un ré-affichage du lecteur, une * publicité qui reprend la main. Rien ne le signalait jusqu'ici, d'où des * captures qui en sortaient sans qu'on sache ni quand ni pourquoi ; la mesure * est maintenant relue régulièrement, et l'écart journalisé. */ async function watchFullscreen(): Promise { const bidi = driver(); const settings = watcher.snapshotSettings.fullscreen; if (!bidi || !settings.enabled || fullscreenMisses >= MAX_FULLSCREEN_MISSES) return; // Un rechargement en cours fait forcément perdre le plein écran : le constater // ici enverrait une touche au milieu de la reprise, qui le rétablit déjà. if (recovering) return; // Uniquement pendant une capture réellement en cours. Hors capture il n'y a // rien à cadrer ; en pause (show privé), l'overlay du site occupe le lecteur // et insister entrerait en conflit avec le rappel que la surveillance // programme déjà pour le retour du flux public. const record = await obs.recordState().catch(() => null); if (!record?.active || record.paused) { fullscreenMisses = 0; return; } // `undefined` couvre aussi bien « pas de lecteur dans la page » que « rien ne // permet de trancher » : dans les deux cas, agir se ferait à l'aveugle. const { fullscreen, metrics, baseline } = await bidi.checkFullscreen(); if (fullscreen !== false) { fullscreenMisses = 0; return; } fullscreenMisses += 1; // La mesure part avec le constat : c'est elle qui dit ce que le lecteur occupe // réellement au moment où on le déclare sorti du plein écran. report( 'warn', `Plein écran perdu en cours de capture (${describeMetrics(metrics, baseline)}) — rappel envoyé`, 'fullscreen.failed', ); const outcome = await restoreFullscreen(settings).catch((err: Error) => { report('warn', `Rappel du plein écran impossible : ${err.message}`, 'fullscreen.failed'); return null; }); if (!outcome) return; // Un envoi ignoré (rappel trop rapproché) ne prouve rien : il ne compte ni // comme réussite ni comme échec, et la relecture suivante tranchera. if (outcome.skipped) return; if (outcome.confirmed === true) { fullscreenMisses = 0; report('info', describeFullscreen(outcome, settings.key), 'fullscreen.restored'); return; } if (fullscreenMisses >= MAX_FULLSCREEN_MISSES) { report( 'warn', `Plein écran non rétabli après ${MAX_FULLSCREEN_MISSES} tentatives : surveillance ` + 'suspendue jusqu\'au prochain enregistrement.', 'fullscreen.failed', ); } } /** * Sélectionne la meilleure qualité offerte par le lecteur Stripchat. * * Sans effet hors mode BiDi : il n'existe aucun moyen d'interagir avec le * contenu de la page sans lui. Un échec ne bloque jamais l'enregistrement — * mieux vaut capturer une qualité non maximale que ne rien capturer du tout. */ async function selectMaxStreamQuality(): Promise { const bidi = driver(); const settings = watcher.snapshotSettings.streamQuality; if (!bidi || !settings.enabled) return; await sleep(settings.delayMs); const result: StreamQualityOutcome = await bidi .selectMaxStreamQuality() .catch((err: Error) => ({ ok: false, reason: err.message })); if (!result.ok) { // Le diagnostic (menu ouvert mais balisage inattendu) part dans le même // message : plus vite exploitable que de redemander une capture DevTools. const suffix = result.debug ? ` — ${result.debug}` : ''; report('warn', `Qualité du flux non réglée : ${result.reason}${suffix}`, 'quality.failed'); return; } report( 'info', result.already ? `Qualité du flux déjà au maximum (${result.label})` : `Qualité du flux réglée sur ${result.label}`, 'quality.selected', ); } // --- Presets d'enregistrement ------------------------------------------------ /** * Enregistre le preset voulu. L'application effective est différée : OBS peut * être déconnecté ou en pleine capture au moment où la configuration arrive. */ function applyRecordingSettings(raw: RecordingSettings | undefined): void { const next = normalizeRecordingSettings(raw ?? DEFAULT_RECORDING_SETTINGS); const changed = next.enabled !== recordingSettings.enabled || next.presetId !== recordingSettings.presetId || next.encoder !== recordingSettings.encoder; recordingSettings = next; if (next.enabled && changed) presetPending = true; } /** * Applique le preset en attente dès que les conditions le permettent. * * Un échec ne relance pas la tentative : une clé refusée par cette version d'OBS * le sera à chaque cycle, et marteler l'API n'y changerait rien. Le prochain * enregistrement de la configuration, ou le bouton du dashboard, réessaiera. */ async function drainPresetApply(): Promise { if (!presetPending || !recordingSettings.enabled || !obs.isConnected) return; const state = await obs.recordState().catch(() => null); if (!state || state.active) return; // jamais pendant une capture presetPending = false; try { report( 'info', describePreset(await obs.applyRecordingPreset(recordingSettings)), 'preset.applied', ); } catch (err) { report( 'warn', `Preset non appliqué : ${err instanceof Error ? err.message : String(err)}`, 'preset.failed', ); } } /** Application à la demande : passe outre l'interrupteur, l'opérateur a tranché. */ async function applyPresetNow(params: Record): Promise { const result = await obs.applyRecordingPreset({ enabled: true, presetId: typeof params.presetId === 'string' && params.presetId ? params.presetId : recordingSettings.presetId, encoder: isRecordingEncoder(params.encoder) ? params.encoder : recordingSettings.encoder, }); presetPending = false; report('info', describePreset(result), 'preset.applied'); return result; } function describePreset(result: PresetApplyResult): string { const parts = [ `Preset « ${result.presetLabel} » appliqué — ${result.encoder}, ${result.video}`, ]; if (result.skipped.length > 0) parts.push(`refusé par OBS : ${result.skipped.join(', ')}`); if (result.restartRequired) { parts.push("l'encodeur a changé : redémarre OBS pour qu'il le charge"); } return parts.join(' — '); } // --- Boucle de statut -------------------------------------------------------- async function buildStatus(): Promise { const snapshot = await obs.snapshot(); const memory = memoryUsage(); const disk = await diskUsage(snapshot.recordDirectory); return { ...emptyStatus(), ...snapshot, watch: watcher.snapshot, browser: driver()?.state, buildId: BUILD_ID, canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl), lastRecordingPath: obs.recordingPath, systemCpu: cpuUsagePercent(), systemMemoryUsed: memory.used, systemMemoryTotal: memory.total, diskFreeBytes: disk?.freeBytes, diskTotalBytes: disk?.totalBytes, updatedAt: Date.now(), }; } async function pushStatus(): Promise { if (socket?.readyState !== WebSocket.OPEN) return; try { // Le cycle de statut sert aussi d'horloge au preset en attente : il tourne // déjà, et il s'exécute justement quand OBS vient de redevenir joignable. await drainPresetApply(); send({ type: 'status', status: await buildStatus() }); } catch (err) { console.error('Collecte de statut en échec :', err); } } function startStatusLoop(): void { stopStatusLoop(); void pushStatus(); statusTimer = setInterval(() => void pushStatus(), statusIntervalMs); statusTimer.unref?.(); // Horloge distincte de celle du statut : la relecture du plein écran est bien // plus espacée, et la caler sur un cycle de 2 s reviendrait à évaluer du code // dans la page en permanence. fullscreenTimer = setInterval(() => { void watchFullscreen().catch((err: unknown) => { console.error('Surveillance du plein écran en échec :', err); }); }, FULLSCREEN_WATCH_MS); fullscreenTimer.unref?.(); playbackTimer = setInterval(() => { void watchPlayback().catch((err: unknown) => { console.error('Surveillance de la lecture en échec :', err); }); }, PLAYBACK_SAMPLE_MS); playbackTimer.unref?.(); } function stopStatusLoop(): void { if (statusTimer) clearInterval(statusTimer); statusTimer = null; if (fullscreenTimer) clearInterval(fullscreenTimer); fullscreenTimer = null; if (playbackTimer) clearInterval(playbackTimer); playbackTimer = null; } // --- Cycle de vie ------------------------------------------------------------ async function shutdown(signal: string): Promise { if (shuttingDown) return; shuttingDown = true; console.log(`\n${signal} reçu, arrêt de l'agent…`); stopStatusLoop(); if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = null; watcher.stop(); // Le canal BiDi se ferme, mais pas Firefox : sa fenêtre est la source de // capture d'OBS, et l'enregistrement en cours doit lui survivre. firefox?.dispose(); // L'enregistrement OBS en cours n'est volontairement pas interrompu. await obs.disconnect().catch(() => undefined); socket?.close(1000, 'Arrêt de l\'agent'); setTimeout(() => process.exit(0), 500).unref(); } process.on('SIGINT', () => void shutdown('SIGINT')); process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('unhandledRejection', (reason) => { console.error('Rejet non géré :', reason); }); console.log(`stream-control agent v${AGENT_VERSION} — ${config.name} (${process.platform})`); if (process.argv.includes('--check')) { // Diagnostic seul : rien n'est démarré, aucune connexion n'est maintenue. runDiagnostics(config).then((code) => process.exit(code)); } else { connect(); }