413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
/**
|
|
* Protocole partagé entre le serveur de contrôle, les agents et le dashboard.
|
|
*
|
|
* Transport : WebSocket, messages JSON, un champ `type` discriminant.
|
|
* - agent <-> serveur : /ws/agent (l'agent initie la connexion sortante)
|
|
* - browser <- serveur : /ws/dashboard (flux de statut temps réel)
|
|
*/
|
|
|
|
export const PROTOCOL_VERSION = 1;
|
|
|
|
export type Platform = 'windows' | 'linux' | 'darwin' | 'unknown';
|
|
|
|
/** Paramètres de connexion à obs-websocket (plugin intégré à OBS >= 28). */
|
|
export interface ObsSettings {
|
|
host: string;
|
|
port: number;
|
|
/** Mot de passe obs-websocket ; chaîne vide si l'authentification est désactivée. */
|
|
password: string;
|
|
}
|
|
|
|
export const DEFAULT_OBS_SETTINGS: ObsSettings = {
|
|
host: '127.0.0.1',
|
|
port: 4455,
|
|
password: '',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actions pilotables sur un agent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const AGENT_ACTIONS = [
|
|
'obs.connect',
|
|
'obs.disconnect',
|
|
'obs.refresh',
|
|
'record.start',
|
|
'record.stop',
|
|
'record.pause',
|
|
'record.resume',
|
|
'record.split',
|
|
'stream.start',
|
|
'stream.stop',
|
|
'scene.set',
|
|
'profile.set',
|
|
'collection.set',
|
|
'recordDirectory.set',
|
|
'watch.check',
|
|
'hotkey.fullscreen',
|
|
'agent.ping',
|
|
] as const;
|
|
|
|
export type AgentAction = (typeof AGENT_ACTIONS)[number];
|
|
|
|
export function isAgentAction(value: unknown): value is AgentAction {
|
|
return typeof value === 'string' && (AGENT_ACTIONS as readonly string[]).includes(value);
|
|
}
|
|
|
|
/** Paramètres attendus par action (les autres actions n'en prennent aucun). */
|
|
export interface AgentActionParams {
|
|
'scene.set': { scene: string };
|
|
'profile.set': { profile: string };
|
|
'collection.set': { collection: string };
|
|
'recordDirectory.set': { directory: string };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Surveillance du statut d'un stream (pause auto pendant les shows privés)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const WATCH_PROVIDERS = ['stripchat'] as const;
|
|
export type WatchProvider = (typeof WATCH_PROVIDERS)[number];
|
|
|
|
/** État normalisé du stream surveillé. */
|
|
export type StreamState = 'public' | 'private' | 'offline' | 'unknown';
|
|
|
|
export interface FullscreenSettings {
|
|
/** Rappeler le plein écran à la fin d'un show privé. */
|
|
enabled: boolean;
|
|
/** Touche à envoyer au lecteur (raccourci plein écran, « f » sur Stripchat). */
|
|
key: string;
|
|
/** Fragment de titre de fenêtre identifiant le lecteur (insensible à la casse). */
|
|
windowMatch: string;
|
|
/** Délai avant l'envoi, le temps que le flux public soit rechargé. */
|
|
delayMs: number;
|
|
}
|
|
|
|
export interface WatchSettings {
|
|
enabled: boolean;
|
|
provider: WatchProvider;
|
|
/** Pseudo du streamer, tel qu'il apparaît dans l'URL de sa page. */
|
|
username: string;
|
|
pollIntervalMs: number;
|
|
/**
|
|
* Statuts bruts de l'API considérés comme « pas de flux public ».
|
|
* Valeurs observées côté Stripchat : public, private, p2p, groupShow, idle.
|
|
*/
|
|
privateStatuses: string[];
|
|
/**
|
|
* Lectures « privé » consécutives exigées avant de mettre en pause.
|
|
* Asymétrique volontairement : une fausse pause coûte du contenu perdu,
|
|
* une fausse reprise ne coûte que quelques secondes d'écran d'attente.
|
|
*/
|
|
confirmations: number;
|
|
pauseOnPrivate: boolean;
|
|
resumeOnPublic: boolean;
|
|
fullscreen: FullscreenSettings;
|
|
}
|
|
|
|
export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
|
|
enabled: false,
|
|
provider: 'stripchat',
|
|
username: '',
|
|
pollIntervalMs: 10_000,
|
|
privateStatuses: ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow'],
|
|
confirmations: 2,
|
|
pauseOnPrivate: true,
|
|
resumeOnPublic: true,
|
|
fullscreen: {
|
|
enabled: true,
|
|
key: 'f',
|
|
windowMatch: 'Stripchat',
|
|
delayMs: 4000,
|
|
},
|
|
};
|
|
|
|
/** État courant de la surveillance, remonté avec le statut de l'agent. */
|
|
export interface WatchState {
|
|
enabled: boolean;
|
|
provider: WatchProvider;
|
|
username: string;
|
|
state: StreamState;
|
|
/** Statut brut renvoyé par l'API, utile pour diagnostiquer un mapping. */
|
|
rawStatus?: string;
|
|
/** Depuis quand l'état normalisé est stable. */
|
|
since: number;
|
|
lastCheckedAt: number;
|
|
lastError?: string;
|
|
/** Vrai si c'est la surveillance — et non l'opérateur — qui a mis en pause. */
|
|
autoPaused: boolean;
|
|
/** Lectures « privé » accumulées, en attente du seuil de confirmation. */
|
|
pendingConfirmations: number;
|
|
}
|
|
|
|
export function emptyWatchState(settings: WatchSettings): WatchState {
|
|
return {
|
|
enabled: settings.enabled,
|
|
provider: settings.provider,
|
|
username: settings.username,
|
|
state: 'unknown',
|
|
since: Date.now(),
|
|
lastCheckedAt: 0,
|
|
autoPaused: false,
|
|
pendingConfirmations: 0,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Statut remonté par un agent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AgentStatus {
|
|
/** L'agent a-t-il une session obs-websocket établie ? */
|
|
obsConnected: boolean;
|
|
obsVersion?: string;
|
|
obsError?: string;
|
|
|
|
recording: boolean;
|
|
recordPaused: boolean;
|
|
/** Durée d'enregistrement au format HH:MM:SS.mmm renvoyé par OBS. */
|
|
recordTimecode?: string;
|
|
recordBytes?: number;
|
|
/** Chemin du dernier fichier écrit (renseigné à l'arrêt de l'enregistrement). */
|
|
lastRecordingPath?: string;
|
|
recordDirectory?: string;
|
|
|
|
streaming: boolean;
|
|
streamTimecode?: string;
|
|
|
|
currentScene?: string;
|
|
scenes: string[];
|
|
currentProfile?: string;
|
|
profiles: string[];
|
|
currentCollection?: string;
|
|
collections: string[];
|
|
|
|
/** Statistiques OBS. */
|
|
cpuUsage?: number;
|
|
fps?: number;
|
|
droppedFrames?: number;
|
|
renderSkippedFrames?: number;
|
|
|
|
/** Statistiques machine (collectées par l'agent, pas par OBS). */
|
|
systemCpu?: number;
|
|
systemMemoryUsed?: number;
|
|
systemMemoryTotal?: number;
|
|
diskFreeBytes?: number;
|
|
diskTotalBytes?: number;
|
|
|
|
/** Surveillance du stream source, absente si elle n'est pas configurée. */
|
|
watch?: WatchState;
|
|
|
|
updatedAt: number;
|
|
}
|
|
|
|
export function emptyStatus(): AgentStatus {
|
|
return {
|
|
obsConnected: false,
|
|
recording: false,
|
|
recordPaused: false,
|
|
streaming: false,
|
|
scenes: [],
|
|
profiles: [],
|
|
collections: [],
|
|
updatedAt: 0,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Messages agent -> serveur
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
|
|
export interface HelloMessage {
|
|
type: 'hello';
|
|
protocol: number;
|
|
/** Absent lors du tout premier enrôlement : le serveur en attribue un. */
|
|
agentId?: string;
|
|
name: string;
|
|
hostname: string;
|
|
platform: Platform;
|
|
agentVersion: string;
|
|
}
|
|
|
|
export interface StatusMessage {
|
|
type: 'status';
|
|
status: AgentStatus;
|
|
}
|
|
|
|
export interface ResultMessage {
|
|
type: 'result';
|
|
requestId: string;
|
|
ok: boolean;
|
|
data?: unknown;
|
|
error?: string;
|
|
}
|
|
|
|
export interface LogMessage {
|
|
type: 'log';
|
|
level: LogLevel;
|
|
message: string;
|
|
ts: number;
|
|
}
|
|
|
|
export interface PongMessage {
|
|
type: 'pong';
|
|
ts: number;
|
|
}
|
|
|
|
export type AgentToServer =
|
|
| HelloMessage
|
|
| StatusMessage
|
|
| ResultMessage
|
|
| LogMessage
|
|
| PongMessage;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Messages serveur -> agent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface WelcomeMessage {
|
|
type: 'welcome';
|
|
agentId: string;
|
|
/** Fourni uniquement lors de l'enrôlement : l'agent doit le persister. */
|
|
token?: string;
|
|
obs: ObsSettings;
|
|
/** Fréquence de remontée de statut demandée. */
|
|
statusIntervalMs: number;
|
|
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
|
|
autoConnectObs: boolean;
|
|
watch: WatchSettings;
|
|
}
|
|
|
|
export interface CommandMessage {
|
|
type: 'command';
|
|
requestId: string;
|
|
action: AgentAction;
|
|
params?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface ConfigMessage {
|
|
type: 'config';
|
|
obs: ObsSettings;
|
|
autoConnectObs: boolean;
|
|
watch: WatchSettings;
|
|
}
|
|
|
|
export interface PingMessage {
|
|
type: 'ping';
|
|
ts: number;
|
|
}
|
|
|
|
export type ServerToAgent = WelcomeMessage | CommandMessage | ConfigMessage | PingMessage;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vue agrégée exposée au dashboard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AgentView {
|
|
id: string;
|
|
name: string;
|
|
hostname: string | null;
|
|
platform: Platform;
|
|
agentVersion: string | null;
|
|
online: boolean;
|
|
lastSeenAt: number | null;
|
|
createdAt: number;
|
|
obs: ObsSettings;
|
|
autoConnectObs: boolean;
|
|
watch: WatchSettings;
|
|
notes: string | null;
|
|
status: AgentStatus;
|
|
}
|
|
|
|
export interface LogEntry {
|
|
id: number;
|
|
agentId: string | null;
|
|
agentName: string | null;
|
|
level: LogLevel;
|
|
message: string;
|
|
ts: number;
|
|
}
|
|
|
|
export type ServerToDashboard =
|
|
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[] }
|
|
| { type: 'agent'; agent: AgentView }
|
|
| { type: 'agent.removed'; agentId: string }
|
|
| { type: 'log'; entry: LogEntry };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function detectPlatform(raw: string): Platform {
|
|
if (raw === 'win32') return 'windows';
|
|
if (raw === 'linux') return 'linux';
|
|
if (raw === 'darwin') return 'darwin';
|
|
return 'unknown';
|
|
}
|
|
|
|
export function safeJsonParse<T>(raw: string): T | null {
|
|
try {
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalise une configuration de surveillance venue du réseau ou de la base :
|
|
* champs manquants complétés, valeurs numériques bornées.
|
|
*/
|
|
export function normalizeWatchSettings(raw: unknown): WatchSettings {
|
|
const input = (raw ?? {}) as Partial<WatchSettings>;
|
|
const base = DEFAULT_WATCH_SETTINGS;
|
|
const fullscreen = (input.fullscreen ?? {}) as Partial<FullscreenSettings>;
|
|
|
|
const clamp = (value: unknown, fallback: number, min: number, max: number): number => {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return fallback;
|
|
return Math.min(Math.max(Math.round(parsed), min), max);
|
|
};
|
|
|
|
const statuses = Array.isArray(input.privateStatuses)
|
|
? input.privateStatuses.filter((s): s is string => typeof s === 'string' && s.trim() !== '')
|
|
: base.privateStatuses;
|
|
|
|
return {
|
|
enabled: input.enabled === true,
|
|
provider: WATCH_PROVIDERS.includes(input.provider as WatchProvider)
|
|
? (input.provider as WatchProvider)
|
|
: base.provider,
|
|
username: typeof input.username === 'string' ? input.username.trim() : base.username,
|
|
// Plancher à 3 s : inutile de marteler l'API, un show privé dure des minutes.
|
|
pollIntervalMs: clamp(input.pollIntervalMs, base.pollIntervalMs, 3000, 300_000),
|
|
privateStatuses: statuses.length > 0 ? statuses : base.privateStatuses,
|
|
confirmations: clamp(input.confirmations, base.confirmations, 1, 10),
|
|
pauseOnPrivate: input.pauseOnPrivate !== false,
|
|
resumeOnPublic: input.resumeOnPublic !== false,
|
|
fullscreen: {
|
|
enabled: fullscreen.enabled !== false,
|
|
key:
|
|
typeof fullscreen.key === 'string' && fullscreen.key.trim()
|
|
? fullscreen.key.trim()
|
|
: base.fullscreen.key,
|
|
windowMatch:
|
|
typeof fullscreen.windowMatch === 'string'
|
|
? fullscreen.windowMatch.trim()
|
|
: base.fullscreen.windowMatch,
|
|
delayMs: clamp(fullscreen.delayMs, base.fullscreen.delayMs, 0, 120_000),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Traduit un statut brut de l'API en état normalisé. */
|
|
export function mapStreamStatus(raw: string | undefined, privateStatuses: string[]): StreamState {
|
|
if (!raw) return 'unknown';
|
|
const value = raw.toLowerCase();
|
|
if (privateStatuses.some((status) => status.toLowerCase() === value)) return 'private';
|
|
if (value === 'public') return 'public';
|
|
// idle / off / offline / deleted : le modèle ne diffuse pas.
|
|
return 'offline';
|
|
}
|