This commit is contained in:
jeanotx32
2026-08-11 00:26:56 +02:00
commit 6f11b72cbb
43 changed files with 7175 additions and 0 deletions

313
packages/agent/src/obs.ts Normal file
View File

@@ -0,0 +1,313 @@
import { EventEmitter } from 'node:events';
import OBSWebSocket from 'obs-websocket-js';
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
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;
/**
* 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 {
const info = await this.obs.connect(url, this.settings.password || undefined, {
rpcVersion: 1,
});
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 = err instanceof Error ? err.message : String(err);
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;
}
// --- Exécution des actions du protocole ---------------------------------
async execute(action: AgentAction, 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;
}