Files
stream-control/packages/shared/src/index.ts
jeanotx32 37e0c73ab5
Some checks failed
release / build (push) Successful in 27s
release / verify-windows (push) Failing after 56s
Feat : Control streamer
2026-08-11 21:11:04 +02:00

611 lines
18 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',
'browser.open',
'browser.close',
'capture.start',
'capture.stop',
'agent.update',
'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;
}
/**
* Pilotage du navigateur de la VM.
*
* On lance le navigateur déjà installé, avec son profil et sa session : c'est ce
* qui donne accès au flux comme si l'opérateur l'ouvrait lui-même. Aucune
* instance dédiée, aucun profil de test.
*/
export interface BrowserSettings {
enabled: boolean;
/** Exécutable du navigateur. */
command: string;
/** Arguments placés avant l'URL. */
args: string[];
/** Délai avant l'envoi du plein écran, le temps que le lecteur démarre. */
readyDelayMs: number;
/** Fermer la fenêtre quand l'enregistrement s'arrête. */
closeOnStop: boolean;
}
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
enabled: false,
command: 'firefox',
args: ['--new-window'],
readyDelayMs: 8000,
closeOnStop: true,
};
export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
const input = (raw ?? {}) as Partial<BrowserSettings>;
const base = DEFAULT_BROWSER_SETTINGS;
const delay = Number(input.readyDelayMs);
return {
enabled: input.enabled === true,
command:
typeof input.command === 'string' && input.command.trim()
? input.command.trim()
: base.command,
args: Array.isArray(input.args)
? input.args.filter((arg): arg is string => typeof arg === 'string' && arg.trim() !== '')
: base.args,
readyDelayMs: Number.isFinite(delay)
? Math.min(Math.max(Math.round(delay), 0), 120_000)
: base.readyDelayMs,
closeOnStop: input.closeOnStop !== false,
};
}
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;
/**
* Empreinte courte du binaire en cours d'exécution. Deux agents partageant
* cette valeur tournent sur le même build — c'est ce qui rend visible un
* agent resté en arrière après une mise à jour.
*/
buildId?: string;
/** Vrai si l'agent sait se mettre à jour seul (URL de paquet connue). */
canSelfUpdate?: boolean;
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;
browser: BrowserSettings;
}
export interface CommandMessage {
type: 'command';
requestId: string;
action: AgentAction;
params?: Record<string, unknown>;
}
export interface ConfigMessage {
type: 'config';
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
}
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;
browser: BrowserSettings;
notes: string | null;
status: AgentStatus;
}
export interface LogEntry {
id: number;
agentId: string | null;
agentName: string | null;
level: LogLevel;
message: string;
ts: number;
}
/**
* Profil surveillé par le serveur : on colle l'URL d'un streamer, le serveur
* sonde son statut et signale les passages en direct.
*/
export interface WatchTarget {
id: string;
provider: WatchProvider;
username: string;
/** Nom lisible, à défaut le pseudo. */
label: string | null;
url: string;
/** Agent qui enregistrera ce streamer, s'il est assigné. */
agentId: string | null;
notify: boolean;
state: StreamState;
rawStatus: string | null;
/** Depuis quand l'état est stable, selon nos propres observations. */
stateSince: number;
lastCheckedAt: number | null;
lastError: string | null;
createdAt: number;
/** Photo de profil. */
avatarUrl: string | null;
/**
* Début du statut courant d'après la plateforme. Quand `state` vaut `public`,
* c'est l'heure de début du stream en cours.
*/
statusChangedAt: number | null;
/** Dernier stream terminé : début et fin observés. */
lastLiveStartedAt: number | null;
lastLiveEndedAt: number | null;
}
export type ServerToDashboard =
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] }
| { type: 'agent'; agent: AgentView }
| { type: 'agent.removed'; agentId: string }
| { type: 'log'; entry: LogEntry }
| { type: 'target'; target: WatchTarget }
| { type: 'target.removed'; targetId: string }
/** Transition vers le direct : c'est ce qui déclenche la notification. */
| { type: 'target.live'; target: WatchTarget };
// ---------------------------------------------------------------------------
// 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),
},
};
}
/**
* Extrait le pseudo d'un profil Stripchat à partir d'une URL complète, d'une URL
* sans schéma, ou d'un pseudo saisi seul.
*/
export function parseStripchatUsername(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
const looksLikeUrl = trimmed.includes('/') || trimmed.includes('.');
if (looksLikeUrl) {
try {
const url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
if (!/(^|\.)stripchat\.com$/i.test(url.hostname)) return null;
const first = url.pathname.split('/').filter(Boolean)[0];
return first && USERNAME_PATTERN.test(first) ? first : null;
} catch {
return null;
}
}
return USERNAME_PATTERN.test(trimmed) ? trimmed : null;
}
const USERNAME_PATTERN = /^[A-Za-z0-9_.-]{2,64}$/;
export function stripchatProfileUrl(username: string): string {
return `https://fr.stripchat.com/${username}`;
}
/**
* Interroge le statut d'un modèle Stripchat.
*
* Le champ autoritatif est `user.user.status` sur
* `/api/front/v2/models/username/{pseudo}/cam`. Valeurs relevées en production :
* `public`, `private`, `p2p`, `groupShow`, `idle`.
*
* Mutualisé entre l'agent (pause automatique) et le serveur (veille) : une seule
* définition de l'endpoint et du chemin du champ à maintenir.
*/
export interface StripchatStatus {
raw: string;
state: StreamState;
/** Photo de profil, absente si le modèle n'en a pas. */
avatarUrl: string | null;
/**
* Début du statut courant. Quand le modèle est public, c'est l'heure de début
* du stream en cours — bien plus précis que notre propre première observation.
*/
statusChangedAt: number | null;
}
export async function fetchStripchatStatus(
username: string,
privateStatuses: string[],
timeoutMs = 8000,
): Promise<StripchatStatus> {
const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent(
username,
)}/cam`;
const response = await fetch(url, {
headers: {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
accept: 'application/json',
},
signal: AbortSignal.timeout(timeoutMs),
});
if (response.status === 404) {
return { raw: 'notFound', state: 'offline', avatarUrl: null, statusChangedAt: null };
}
if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`);
const payload = (await response.json()) as {
user?: { user?: { status?: string; avatarUrl?: string; statusChangedAt?: string } };
};
const profile = payload?.user?.user;
const raw = profile?.status;
if (typeof raw !== 'string') {
throw new Error('Réponse Stripchat inattendue : user.user.status absent');
}
const changedAt = profile?.statusChangedAt
? Date.parse(profile.statusChangedAt)
: Number.NaN;
return {
raw,
state: mapStreamStatus(raw, privateStatuses),
avatarUrl: typeof profile?.avatarUrl === 'string' && profile.avatarUrl ? profile.avatarUrl : null,
statusChangedAt: Number.isFinite(changedAt) ? changedAt : null,
};
}
/** 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';
}