Feat : Log recorder
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { BrowserSettings } from '@stream-control/shared';
|
||||
import type { AgentEvent, BrowserSettings } from '@stream-control/shared';
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
export interface BrowserLog {
|
||||
(level: 'info' | 'warn' | 'error', message: string): void;
|
||||
(level: 'info' | 'warn' | 'error', message: string, event?: AgentEvent): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export async function openUrl(
|
||||
const target = assertWebUrl(url);
|
||||
const args = [...settings.args, target];
|
||||
|
||||
log('info', `Ouverture de ${target} (${settings.command})`);
|
||||
log('info', `Ouverture de ${target} (${settings.command})`, 'browser.opened');
|
||||
|
||||
const child = spawn(settings.command, args, {
|
||||
detached: true,
|
||||
@@ -103,7 +103,7 @@ export async function closeWindow(
|
||||
await run('xdotool', ['windowclose', id], { env, timeout: 5000 }).catch(() => undefined);
|
||||
}
|
||||
|
||||
log('info', `${ids.length} fenêtre(s) « ${match} » fermée(s)`);
|
||||
log('info', `${ids.length} fenêtre(s) « ${match} » fermée(s)`, 'browser.closed');
|
||||
return { closed: ids.length };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from 'node:os';
|
||||
import { WebSocket } from 'ws';
|
||||
import type {
|
||||
AgentAction,
|
||||
AgentEvent,
|
||||
AgentStatus,
|
||||
AgentToServer,
|
||||
LogLevel,
|
||||
@@ -80,14 +81,23 @@ function send(message: AgentToServer): void {
|
||||
}
|
||||
}
|
||||
|
||||
function report(level: LogLevel, message: string): void {
|
||||
/**
|
||||
* Journalise localement et vers le serveur. `event` classe l'entrée dans
|
||||
* l'historique de la VM ; l'omettre reste correct pour ce qui n'est que du
|
||||
* bavardage de progression.
|
||||
*/
|
||||
function report(level: LogLevel, message: string, event?: AgentEvent): void {
|
||||
const prefix = level === 'error' ? '✖' : level === 'warn' ? '!' : '·';
|
||||
console.log(`${prefix} ${message}`);
|
||||
send({ type: 'log', level, message, ts: Date.now() });
|
||||
send({ type: 'log', level, message, ts: Date.now(), event });
|
||||
}
|
||||
|
||||
obs.on('log', (level: LogLevel, message: string) => report(level, message));
|
||||
watcher.on('log', (level: LogLevel, message: string) => report(level, message));
|
||||
obs.on('log', (level: LogLevel, message: string, event?: AgentEvent) =>
|
||||
report(level, message, event),
|
||||
);
|
||||
watcher.on('log', (level: LogLevel, message: string, event?: AgentEvent) =>
|
||||
report(level, message, event),
|
||||
);
|
||||
|
||||
function connect(): void {
|
||||
if (shuttingDown) return;
|
||||
@@ -194,7 +204,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
} 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}`);
|
||||
report('error', `Échec de « ${message.action} » : ${text}`, 'command.failed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -238,13 +248,13 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
|
||||
key: fullscreen.key,
|
||||
windowMatch: fullscreen.windowMatch,
|
||||
}).catch((err: Error) => {
|
||||
report('warn', `Plein écran impossible : ${err.message}`);
|
||||
report('warn', `Plein écran impossible : ${err.message}`, 'fullscreen.failed');
|
||||
return `échec : ${err.message}`;
|
||||
});
|
||||
}
|
||||
|
||||
await obs.execute('record.start');
|
||||
report('info', `Capture démarrée pour ${url}`);
|
||||
report('info', `Capture démarrée pour ${url}`, 'capture.started');
|
||||
|
||||
return { opened, fullscreen: fullscreenResult, recording: true };
|
||||
}
|
||||
@@ -256,12 +266,13 @@ async function stopCapture(): Promise<unknown> {
|
||||
if (browserSettings.enabled && browserSettings.closeOnStop) {
|
||||
closed = await closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report).catch(
|
||||
(err: Error) => {
|
||||
report('warn', `Fermeture de la fenêtre impossible : ${err.message}`);
|
||||
report('warn', `Fermeture de la fenêtre impossible : ${err.message}`, 'command.failed');
|
||||
return `échec : ${err.message}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
report('info', 'Capture arrêtée', 'capture.stopped');
|
||||
return { ...(result as object), window: closed };
|
||||
}
|
||||
|
||||
@@ -290,7 +301,7 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
|
||||
typeof params.url === 'string' && params.url ? params.url : config.packageUrl,
|
||||
{
|
||||
recordState: () => obs.recordState(),
|
||||
log: (level, message) => report(level, message),
|
||||
log: (level, message, event) => report(level, message, event),
|
||||
},
|
||||
);
|
||||
default:
|
||||
@@ -338,9 +349,17 @@ async function drainPresetApply(): Promise<void> {
|
||||
|
||||
presetPending = false;
|
||||
try {
|
||||
report('info', describePreset(await obs.applyRecordingPreset(recordingSettings)));
|
||||
report(
|
||||
'info',
|
||||
describePreset(await obs.applyRecordingPreset(recordingSettings)),
|
||||
'preset.applied',
|
||||
);
|
||||
} catch (err) {
|
||||
report('warn', `Preset non appliqué : ${err instanceof Error ? err.message : String(err)}`);
|
||||
report(
|
||||
'warn',
|
||||
`Preset non appliqué : ${err instanceof Error ? err.message : String(err)}`,
|
||||
'preset.failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +374,7 @@ async function applyPresetNow(params: Record<string, unknown>): Promise<PresetAp
|
||||
encoder: isRecordingEncoder(params.encoder) ? params.encoder : recordingSettings.encoder,
|
||||
});
|
||||
presetPending = false;
|
||||
report('info', describePreset(result));
|
||||
report('info', describePreset(result), 'preset.applied');
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
|
||||
import OBSWebSocket from 'obs-websocket-js';
|
||||
import type {
|
||||
AgentAction,
|
||||
AgentEvent,
|
||||
AgentStatus,
|
||||
ObsSettings,
|
||||
PresetApplyResult,
|
||||
@@ -10,6 +11,19 @@ import type {
|
||||
} from '@stream-control/shared';
|
||||
import { RECORDING_ENCODERS, findRecordingPreset } from '@stream-control/shared';
|
||||
|
||||
/** Traduit l'état d'une sortie OBS en évènement d'historique et en libellé. */
|
||||
const RECORD_STATES: Record<string, { event: AgentEvent; label: string }> = {
|
||||
STARTED: { event: 'record.started', label: 'Enregistrement démarré' },
|
||||
STOPPED: { event: 'record.stopped', label: 'Enregistrement arrêté' },
|
||||
PAUSED: { event: 'record.paused', label: 'Enregistrement mis en pause' },
|
||||
RESUMED: { event: 'record.resumed', label: 'Enregistrement repris' },
|
||||
};
|
||||
|
||||
const STREAM_STATES: Record<string, { event: AgentEvent; label: string }> = {
|
||||
STARTED: { event: 'stream.started', label: 'Diffusion démarrée' },
|
||||
STOPPED: { event: 'stream.stopped', label: 'Diffusion arrêtée' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Actions relevant d'OBS. `watch.*`, `hotkey.*`, `preset.apply` et
|
||||
* `agent.update` sont traitées en amont par l'agent : elles ne se réduisent pas
|
||||
@@ -115,7 +129,12 @@ export class ObsController extends EventEmitter {
|
||||
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.emit(
|
||||
'log',
|
||||
'warn',
|
||||
`Session OBS fermée${this.lastError ? ` : ${this.lastError}` : ''}`,
|
||||
'obs.disconnected',
|
||||
);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
@@ -124,11 +143,17 @@ export class ObsController extends EventEmitter {
|
||||
if (typeof event.outputPath === 'string' && event.outputPath) {
|
||||
this.lastRecordingPath = event.outputPath;
|
||||
}
|
||||
this.emit('log', 'info', `Enregistrement : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`);
|
||||
// La source fait autorité : elle capte aussi les pauses déclenchées depuis
|
||||
// l'interface d'OBS, que le dashboard ne verrait pas autrement.
|
||||
const raw = String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '');
|
||||
const known = RECORD_STATES[raw];
|
||||
this.emit('log', 'info', known?.label ?? `Enregistrement : ${raw}`, known?.event);
|
||||
});
|
||||
|
||||
this.obs.on('StreamStateChanged', (event) => {
|
||||
this.emit('log', 'info', `Diffusion : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`);
|
||||
const raw = String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '');
|
||||
const known = STREAM_STATES[raw];
|
||||
this.emit('log', 'info', known?.label ?? `Diffusion : ${raw}`, known?.event);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,7 +168,7 @@ export class ObsController extends EventEmitter {
|
||||
settings.password !== this.settings.password;
|
||||
this.settings = settings;
|
||||
if (changed && this.connected) {
|
||||
this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion');
|
||||
this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion', 'config.changed');
|
||||
void this.reconnect();
|
||||
}
|
||||
}
|
||||
@@ -169,7 +194,12 @@ export class ObsController extends EventEmitter {
|
||||
this.connected = true;
|
||||
this.lastError = undefined;
|
||||
this.cachedVersion = info.obsWebSocketVersion;
|
||||
this.emit('log', 'info', `Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`);
|
||||
this.emit(
|
||||
'log',
|
||||
'info',
|
||||
`Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`,
|
||||
'obs.connected',
|
||||
);
|
||||
} catch (err) {
|
||||
this.connected = false;
|
||||
this.lastError = describeConnectionError(err);
|
||||
@@ -189,7 +219,7 @@ export class ObsController extends EventEmitter {
|
||||
this.clearReconnect();
|
||||
if (this.connected) await this.obs.disconnect();
|
||||
this.connected = false;
|
||||
this.emit('log', 'info', 'Déconnecté d\'OBS');
|
||||
this.emit('log', 'info', 'Déconnecté d\'OBS', 'obs.disconnected');
|
||||
}
|
||||
|
||||
private async reconnect(): Promise<void> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { AgentEvent } from '@stream-control/shared';
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -40,7 +41,7 @@ export function currentBuildId(): string | undefined {
|
||||
export interface UpdateDeps {
|
||||
/** État d'enregistrement : on ne coupe jamais une capture en cours. */
|
||||
recordState(): Promise<{ active: boolean; paused: boolean }>;
|
||||
log(level: 'info' | 'warn' | 'error', message: string): void;
|
||||
log(level: 'info' | 'warn' | 'error', message: string, event?: AgentEvent): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +128,7 @@ export async function selfUpdate(
|
||||
deps.log(
|
||||
'info',
|
||||
`Binaire remplacé : ${previousBuildId} → ${newBuildId}. Redémarrage par le superviseur…`,
|
||||
'agent.updated',
|
||||
);
|
||||
|
||||
// 6. Sortie différée : laisse le temps au résultat de partir vers le serveur.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { LogLevel, StreamState, WatchSettings, WatchState } from '@stream-control/shared';
|
||||
import type {
|
||||
AgentEvent,
|
||||
LogLevel,
|
||||
StreamState,
|
||||
WatchSettings,
|
||||
WatchState,
|
||||
} from '@stream-control/shared';
|
||||
import { emptyWatchState, fetchStripchatStatus } from '@stream-control/shared';
|
||||
import { sendHotkey } from './hotkey.ts';
|
||||
|
||||
@@ -96,6 +102,7 @@ export class StreamWatcher extends EventEmitter {
|
||||
`Surveillance de « ${this.settings.username} » (${this.settings.provider}) toutes les ${
|
||||
this.settings.pollIntervalMs / 1000
|
||||
} s`,
|
||||
'watch.started',
|
||||
);
|
||||
void this.tick();
|
||||
this.timer = setInterval(() => void this.tick(), this.settings.pollIntervalMs);
|
||||
@@ -131,7 +138,12 @@ export class StreamWatcher extends EventEmitter {
|
||||
this.state.lastCheckedAt = Date.now();
|
||||
// Sonde en échec : on ne met surtout pas l'enregistrement en pause.
|
||||
if (this.state.lastError !== message) {
|
||||
this.emit('log', 'warn', `Sonde « ${this.settings.username} » en échec : ${message}`);
|
||||
this.emit(
|
||||
'log',
|
||||
'warn',
|
||||
`Sonde « ${this.settings.username} » en échec : ${message}`,
|
||||
'watch.failed',
|
||||
);
|
||||
}
|
||||
this.state.lastError = message;
|
||||
} finally {
|
||||
@@ -149,6 +161,7 @@ export class StreamWatcher extends EventEmitter {
|
||||
'log',
|
||||
'info',
|
||||
`Stream « ${this.settings.username} » : ${label(previous)} → ${label(next)} (${this.state.rawStatus})`,
|
||||
TRANSITIONS[next],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,12 +187,18 @@ export class StreamWatcher extends EventEmitter {
|
||||
if (!record.active || record.paused) return;
|
||||
await this.actions.pauseRecording();
|
||||
this.state.autoPaused = true;
|
||||
this.emit('log', 'info', 'Show privé détecté : enregistrement mis en pause');
|
||||
this.emit(
|
||||
'log',
|
||||
'info',
|
||||
'Show privé détecté : enregistrement mis en pause',
|
||||
'record.paused',
|
||||
);
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
'log',
|
||||
'error',
|
||||
`Mise en pause automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
|
||||
'command.failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -190,13 +209,14 @@ export class StreamWatcher extends EventEmitter {
|
||||
const record = await this.actions.recordState();
|
||||
if (record.active && record.paused) {
|
||||
await this.actions.resumeRecording();
|
||||
this.emit('log', 'info', 'Flux public rétabli : enregistrement repris');
|
||||
this.emit('log', 'info', 'Flux public rétabli : enregistrement repris', 'record.resumed');
|
||||
}
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
'log',
|
||||
'error',
|
||||
`Reprise automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
|
||||
'command.failed',
|
||||
);
|
||||
} finally {
|
||||
this.state.autoPaused = false;
|
||||
@@ -229,16 +249,25 @@ export class StreamWatcher extends EventEmitter {
|
||||
'log',
|
||||
'info',
|
||||
`Plein écran rappelé : touche « ${key} » envoyée à « ${result.window ?? windowMatch} »`,
|
||||
'fullscreen.restored',
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.emit('log', 'warn', `Rappel du plein écran impossible : ${message}`);
|
||||
this.emit('log', 'warn', `Rappel du plein écran impossible : ${message}`, 'fullscreen.failed');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Évènement correspondant à l'état atteint ; `unknown` n'en produit aucun. */
|
||||
const TRANSITIONS: Record<StreamState, AgentEvent | undefined> = {
|
||||
public: 'watch.public',
|
||||
private: 'watch.private',
|
||||
offline: 'watch.offline',
|
||||
unknown: undefined,
|
||||
};
|
||||
|
||||
function label(state: StreamState): string {
|
||||
switch (state) {
|
||||
case 'public':
|
||||
|
||||
Reference in New Issue
Block a user