Files
stream-control/packages/agent/src/obs.ts
jeanotx32 37e0c73ab5
Some checks failed
release / build (push) Successful in 27s
release / verify-windows (push) Failing after 56s
Feat : Control streamer
2026-08-11 21:11:04 +02:00

381 lines
12 KiB
TypeScript

import { EventEmitter } from 'node:events';
import OBSWebSocket from 'obs-websocket-js';
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
/**
* Actions relevant d'OBS. `watch.*`, `hotkey.*` et `agent.update` sont traitées
* en amont par l'agent : elles ne concernent pas la session obs-websocket.
*/
export type ObsAction = Exclude<
AgentAction,
| 'watch.check'
| 'hotkey.fullscreen'
| 'agent.update'
| 'browser.open'
| 'browser.close'
| 'capture.start'
| 'capture.stop'
>;
type ObsSnapshot = Pick<
AgentStatus,
| 'obsConnected'
| 'obsVersion'
| 'obsError'
| 'recording'
| 'recordPaused'
| 'recordTimecode'
| 'recordBytes'
| 'recordDirectory'
| 'streaming'
| 'streamTimecode'
| 'currentScene'
| 'scenes'
| 'currentProfile'
| 'profiles'
| 'currentCollection'
| 'collections'
| 'cpuUsage'
| 'fps'
| 'droppedFrames'
| 'renderSkippedFrames'
>;
const RECONNECT_DELAY_MS = 5000;
const CONNECT_TIMEOUT_MS = 10_000;
/** Borne une promesse qui pourrait ne jamais se résoudre. */
async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Traduit une erreur de connexion en message actionnable. Le brut
* (« ECONNREFUSED ») dit ce qui a échoué, jamais quoi faire ensuite.
*/
function describeConnectionError(err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
const sysCode = (err as NodeJS.ErrnoException).code;
const obsCode = (err as { code?: unknown }).code;
if (sysCode === 'ECONNREFUSED' || message.includes('ECONNREFUSED')) {
return `${message} — rien n'écoute sur ce port. Lance OBS, puis Outils → Paramètres du serveur WebSocket → coche « Activer le serveur WebSocket ».`;
}
if (obsCode === 4009 || /authent/i.test(message)) {
return `${message} — mot de passe obs-websocket incorrect ou manquant. Dans OBS : Outils → Paramètres du serveur WebSocket → Afficher les informations de connexion.`;
}
if (sysCode === 'ENOTFOUND' || sysCode === 'EHOSTUNREACH' || sysCode === 'ETIMEDOUT') {
return `${message} — hôte injoignable. Vérifie l'adresse configurée pour cet agent.`;
}
return message;
}
/**
* Enveloppe obs-websocket : maintient la session, expose les actions du
* protocole et produit un instantané d'état à chaque cycle de statut.
*/
export class ObsController extends EventEmitter {
private readonly obs = new OBSWebSocket();
private settings: ObsSettings;
private connected = false;
private connecting: Promise<void> | null = null;
private autoReconnect = false;
private reconnectTimer: NodeJS.Timeout | null = null;
private lastError: string | undefined;
private lastRecordingPath: string | undefined;
private cachedVersion: string | undefined;
constructor(settings: ObsSettings) {
super();
this.settings = settings;
this.obs.on('ConnectionClosed', (err: unknown) => {
const wasConnected = this.connected;
this.connected = false;
this.cachedVersion = undefined;
if (wasConnected) {
this.lastError = err instanceof Error ? err.message : undefined;
this.emit('log', 'warn', `Session OBS fermée${this.lastError ? ` : ${this.lastError}` : ''}`);
}
this.scheduleReconnect();
});
this.obs.on('RecordStateChanged', (event) => {
if (typeof event.outputPath === 'string' && event.outputPath) {
this.lastRecordingPath = event.outputPath;
}
this.emit('log', 'info', `Enregistrement : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`);
});
this.obs.on('StreamStateChanged', (event) => {
this.emit('log', 'info', `Diffusion : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`);
});
}
get isConnected(): boolean {
return this.connected;
}
applySettings(settings: ObsSettings): void {
const changed =
settings.host !== this.settings.host ||
settings.port !== this.settings.port ||
settings.password !== this.settings.password;
this.settings = settings;
if (changed && this.connected) {
this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion');
void this.reconnect();
}
}
async connect(): Promise<void> {
if (this.connected) return;
if (this.connecting) return this.connecting;
this.autoReconnect = true;
const url = `ws://${this.settings.host}:${this.settings.port}`;
this.connecting = (async () => {
try {
// Un port ouvert par autre chose qu'obs-websocket ne renvoie jamais de
// poignée de main : sans ce délai, la promesse resterait éternellement
// en attente et, comme elle est réutilisée ci-dessus, toute tentative
// ultérieure serait bloquée jusqu'au redémarrage de l'agent.
const info = await withTimeout(
this.obs.connect(url, this.settings.password || undefined, { rpcVersion: 1 }),
CONNECT_TIMEOUT_MS,
`aucune réponse après ${CONNECT_TIMEOUT_MS / 1000} s — le port répond mais ne parle pas obs-websocket ?`,
);
this.connected = true;
this.lastError = undefined;
this.cachedVersion = info.obsWebSocketVersion;
this.emit('log', 'info', `Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`);
} catch (err) {
this.connected = false;
this.lastError = describeConnectionError(err);
// Après un délai dépassé, le socket peut rester à demi ouvert.
await this.obs.disconnect().catch(() => undefined);
throw new Error(`Connexion à OBS impossible (${url}) : ${this.lastError}`);
} finally {
this.connecting = null;
}
})();
return this.connecting;
}
async disconnect(): Promise<void> {
this.autoReconnect = false;
this.clearReconnect();
if (this.connected) await this.obs.disconnect();
this.connected = false;
this.emit('log', 'info', 'Déconnecté d\'OBS');
}
private async reconnect(): Promise<void> {
try {
await this.obs.disconnect();
} catch {
/* ignore */
}
this.connected = false;
await this.connect().catch((err: Error) => this.emit('log', 'warn', err.message));
}
private scheduleReconnect(): void {
if (!this.autoReconnect || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.autoReconnect || this.connected) return;
this.connect().catch(() => {
/* la prochaine fermeture reprogrammera un essai */
});
}, RECONNECT_DELAY_MS);
this.reconnectTimer.unref?.();
}
private clearReconnect(): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
/** État d'enregistrement à la demande, sans passer par l'instantané complet. */
async recordState(): Promise<{ active: boolean; paused: boolean }> {
if (!this.connected) return { active: false, paused: false };
const status = await this.obs.call('GetRecordStatus');
return { active: status.outputActive, paused: status.outputPaused };
}
// --- Exécution des actions du protocole ---------------------------------
async execute(action: ObsAction, params: Record<string, unknown> = {}): Promise<unknown> {
switch (action) {
case 'obs.connect':
await this.connect();
return { connected: true };
case 'obs.disconnect':
await this.disconnect();
return { connected: false };
case 'obs.refresh':
this.requireConnection();
return this.snapshot();
case 'record.start': {
this.requireConnection();
await this.obs.call('StartRecord');
return { recording: true };
}
case 'record.stop': {
this.requireConnection();
const result = await this.obs.call('StopRecord');
this.lastRecordingPath = result.outputPath;
return { recording: false, outputPath: result.outputPath };
}
case 'record.pause':
this.requireConnection();
await this.obs.call('PauseRecord');
return { paused: true };
case 'record.resume':
this.requireConnection();
await this.obs.call('ResumeRecord');
return { paused: false };
case 'record.split':
this.requireConnection();
await this.obs.call('SplitRecordFile');
return { split: true };
case 'stream.start':
this.requireConnection();
await this.obs.call('StartStream');
return { streaming: true };
case 'stream.stop':
this.requireConnection();
await this.obs.call('StopStream');
return { streaming: false };
case 'scene.set': {
this.requireConnection();
const sceneName = requireString(params.scene, 'scene');
await this.obs.call('SetCurrentProgramScene', { sceneName });
return { scene: sceneName };
}
case 'profile.set': {
this.requireConnection();
const profileName = requireString(params.profile, 'profile');
await this.obs.call('SetCurrentProfile', { profileName });
return { profile: profileName };
}
case 'collection.set': {
this.requireConnection();
const sceneCollectionName = requireString(params.collection, 'collection');
await this.obs.call('SetCurrentSceneCollection', { sceneCollectionName });
return { collection: sceneCollectionName };
}
case 'recordDirectory.set': {
this.requireConnection();
const recordDirectory = requireString(params.directory, 'directory');
await this.obs.call('SetRecordDirectory', { recordDirectory });
return { recordDirectory };
}
case 'agent.ping':
return { pong: Date.now() };
default: {
const exhaustive: never = action;
throw new Error(`Action non gérée : ${String(exhaustive)}`);
}
}
}
/** Interroge OBS pour construire l'état courant ; ne lève jamais. */
async snapshot(): Promise<ObsSnapshot> {
const base: ObsSnapshot = {
obsConnected: this.connected,
obsVersion: this.cachedVersion,
obsError: this.connected ? undefined : this.lastError,
recording: false,
recordPaused: false,
streaming: false,
scenes: [],
profiles: [],
collections: [],
};
if (!this.connected) return base;
try {
const [record, stream, sceneList, profileList, collectionList, stats, directory] =
await Promise.all([
this.obs.call('GetRecordStatus'),
this.obs.call('GetStreamStatus'),
this.obs.call('GetSceneList'),
this.obs.call('GetProfileList'),
this.obs.call('GetSceneCollectionList'),
this.obs.call('GetStats'),
this.obs.call('GetRecordDirectory').catch(() => null),
]);
return {
...base,
recording: record.outputActive,
recordPaused: record.outputPaused,
recordTimecode: record.outputTimecode,
recordBytes: record.outputBytes,
recordDirectory: directory?.recordDirectory,
streaming: stream.outputActive,
streamTimecode: stream.outputTimecode,
currentScene: sceneList.currentProgramSceneName,
scenes: (sceneList.scenes as Array<{ sceneName?: string }>)
.map((scene) => scene.sceneName)
.filter((name): name is string => typeof name === 'string'),
currentProfile: profileList.currentProfileName,
profiles: profileList.profiles,
currentCollection: collectionList.currentSceneCollectionName,
collections: collectionList.sceneCollections,
cpuUsage: stats.cpuUsage,
fps: stats.activeFps,
droppedFrames: stats.outputSkippedFrames,
renderSkippedFrames: stats.renderSkippedFrames,
};
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
return { ...base, obsError: this.lastError };
}
}
get recordingPath(): string | undefined {
return this.lastRecordingPath;
}
private requireConnection(): void {
if (!this.connected) throw new Error('OBS n\'est pas connecté sur cet agent');
}
}
function requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`Paramètre « ${field} » manquant`);
}
return value;
}