FP
This commit is contained in:
79
packages/agent/src/config.ts
Normal file
79
packages/agent/src/config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { ObsSettings } from '@stream-control/shared';
|
||||
import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared';
|
||||
|
||||
export interface AgentConfig {
|
||||
/** URL WebSocket du serveur de contrôle, ex. wss://control.exemple.com/ws/agent */
|
||||
serverUrl: string;
|
||||
/** Jeton d'enrôlement au premier démarrage, puis jeton propre à l'agent. */
|
||||
token: string;
|
||||
/** Attribué par le serveur lors de l'enrôlement. */
|
||||
agentId?: string;
|
||||
/** Nom affiché dans le dashboard. */
|
||||
name: string;
|
||||
/** Repli local si le serveur n'a pas encore poussé de configuration OBS. */
|
||||
obs: ObsSettings;
|
||||
/** Ignorer les erreurs de certificat TLS (utile en auto-signé). */
|
||||
insecureTls?: boolean;
|
||||
}
|
||||
|
||||
const CONFIG_PATH = path.resolve(
|
||||
process.env.AGENT_CONFIG ?? path.join(process.cwd(), 'agent.config.json'),
|
||||
);
|
||||
|
||||
function readFile(): Partial<AgentConfig> {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) as Partial<AgentConfig>;
|
||||
} catch (err) {
|
||||
console.error(`Configuration illisible (${CONFIG_PATH}) :`, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Le fichier fournit les valeurs par défaut, l'environnement a priorité. */
|
||||
export function loadConfig(): AgentConfig {
|
||||
const file = readFile();
|
||||
|
||||
const config: AgentConfig = {
|
||||
serverUrl: process.env.SERVER_URL ?? file.serverUrl ?? 'ws://127.0.0.1:8080/ws/agent',
|
||||
token: process.env.AGENT_TOKEN ?? file.token ?? '',
|
||||
agentId: process.env.AGENT_ID ?? file.agentId,
|
||||
name: process.env.AGENT_NAME ?? file.name ?? os.hostname(),
|
||||
obs: {
|
||||
host: process.env.OBS_HOST ?? file.obs?.host ?? DEFAULT_OBS_SETTINGS.host,
|
||||
port: Number(process.env.OBS_PORT ?? file.obs?.port ?? DEFAULT_OBS_SETTINGS.port),
|
||||
password: process.env.OBS_PASSWORD ?? file.obs?.password ?? DEFAULT_OBS_SETTINGS.password,
|
||||
},
|
||||
insecureTls: process.env.INSECURE_TLS === '1' || file.insecureTls === true,
|
||||
};
|
||||
|
||||
if (!config.token) {
|
||||
throw new Error(
|
||||
`Aucun jeton. Renseigne "token" dans ${CONFIG_PATH} (jeton d'enrôlement ou jeton d'agent) ou la variable AGENT_TOKEN.`,
|
||||
);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste l'identité reçue du serveur pour que les redémarrages suivants
|
||||
* réutilisent le jeton permanent au lieu du jeton d'enrôlement.
|
||||
*/
|
||||
export function persistIdentity(agentId: string, token: string): void {
|
||||
const current = readFile();
|
||||
const next = { ...current, agentId, token };
|
||||
try {
|
||||
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
||||
console.log(`Identité d'agent enregistrée dans ${CONFIG_PATH}`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Impossible d'écrire ${CONFIG_PATH} — l'agent devra se ré-enrôler au prochain démarrage :`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { CONFIG_PATH };
|
||||
215
packages/agent/src/index.ts
Normal file
215
packages/agent/src/index.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env node
|
||||
import os from 'node:os';
|
||||
import { WebSocket } from 'ws';
|
||||
import type {
|
||||
AgentStatus,
|
||||
AgentToServer,
|
||||
LogLevel,
|
||||
ServerToAgent,
|
||||
} from '@stream-control/shared';
|
||||
import { PROTOCOL_VERSION, detectPlatform, emptyStatus, safeJsonParse } from '@stream-control/shared';
|
||||
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
|
||||
import { ObsController } from './obs.ts';
|
||||
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
||||
|
||||
const AGENT_VERSION = '0.1.0';
|
||||
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);
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let statusTimer: 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));
|
||||
}
|
||||
}
|
||||
|
||||
function report(level: LogLevel, message: string): void {
|
||||
const prefix = level === 'error' ? '✖' : level === 'warn' ? '!' : '·';
|
||||
console.log(`${prefix} ${message}`);
|
||||
send({ type: 'log', level, message, ts: Date.now() });
|
||||
}
|
||||
|
||||
obs.on('log', (level: LogLevel, message: string) => report(level, message));
|
||||
|
||||
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<ServerToAgent>(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);
|
||||
setTimeout(connect, delay).unref?.();
|
||||
}
|
||||
|
||||
// --- Traitement des messages serveur ----------------------------------------
|
||||
|
||||
async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
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);
|
||||
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);
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
obs.connect().catch((err: Error) => report('warn', err.message));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'command': {
|
||||
try {
|
||||
const data = await obs.execute(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}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ping': {
|
||||
send({ type: 'pong', ts: Date.now() });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Boucle de statut --------------------------------------------------------
|
||||
|
||||
async function buildStatus(): Promise<AgentStatus> {
|
||||
const snapshot = await obs.snapshot();
|
||||
const memory = memoryUsage();
|
||||
const disk = await diskUsage(snapshot.recordDirectory);
|
||||
|
||||
return {
|
||||
...emptyStatus(),
|
||||
...snapshot,
|
||||
lastRecordingPath: obs.recordingPath,
|
||||
systemCpu: cpuUsagePercent(),
|
||||
systemMemoryUsed: memory.used,
|
||||
systemMemoryTotal: memory.total,
|
||||
diskFreeBytes: disk?.freeBytes,
|
||||
diskTotalBytes: disk?.totalBytes,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function pushStatus(): Promise<void> {
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
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?.();
|
||||
}
|
||||
|
||||
function stopStatusLoop(): void {
|
||||
if (statusTimer) clearInterval(statusTimer);
|
||||
statusTimer = null;
|
||||
}
|
||||
|
||||
// --- Cycle de vie ------------------------------------------------------------
|
||||
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`\n${signal} reçu, arrêt de l'agent…`);
|
||||
stopStatusLoop();
|
||||
// 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})`);
|
||||
connect();
|
||||
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;
|
||||
}
|
||||
56
packages/agent/src/system.ts
Normal file
56
packages/agent/src/system.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
|
||||
interface CpuSample {
|
||||
idle: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function sampleCpu(): CpuSample {
|
||||
let idle = 0;
|
||||
let total = 0;
|
||||
for (const cpu of os.cpus()) {
|
||||
for (const [kind, value] of Object.entries(cpu.times)) {
|
||||
total += value;
|
||||
if (kind === 'idle') idle += value;
|
||||
}
|
||||
}
|
||||
return { idle, total };
|
||||
}
|
||||
|
||||
let previous = sampleCpu();
|
||||
|
||||
/** Charge CPU moyenne (0-100) depuis le dernier appel. */
|
||||
export function cpuUsagePercent(): number {
|
||||
const current = sampleCpu();
|
||||
const idleDelta = current.idle - previous.idle;
|
||||
const totalDelta = current.total - previous.total;
|
||||
previous = current;
|
||||
if (totalDelta <= 0) return 0;
|
||||
return Math.round((1 - idleDelta / totalDelta) * 1000) / 10;
|
||||
}
|
||||
|
||||
export interface DiskUsage {
|
||||
freeBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
/** Espace disque du volume contenant `directory` (répertoire d'enregistrement). */
|
||||
export async function diskUsage(directory: string | undefined): Promise<DiskUsage | null> {
|
||||
const target = directory || os.homedir();
|
||||
try {
|
||||
const stats = await fs.statfs(target);
|
||||
const blockSize = Number(stats.bsize);
|
||||
return {
|
||||
freeBytes: Number(stats.bavail) * blockSize,
|
||||
totalBytes: Number(stats.blocks) * blockSize,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function memoryUsage(): { used: number; total: number } {
|
||||
const total = os.totalmem();
|
||||
return { used: total - os.freemem(), total };
|
||||
}
|
||||
Reference in New Issue
Block a user