381 lines
12 KiB
TypeScript
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;
|
|
}
|