FP
This commit is contained in:
313
packages/agent/src/obs.ts
Normal file
313
packages/agent/src/obs.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user