Files
stream-control/packages/shared/src/index.ts
jeanotx32 242bbf1383
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 1m14s
Feat : Handling idle state
2026-08-11 23:31:26 +02:00

947 lines
30 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',
'preset.apply',
'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 };
/** Sans paramètre, l'agent applique le preset configuré côté serveur. */
'preset.apply': { presetId?: string; encoder?: RecordingEncoder };
}
// ---------------------------------------------------------------------------
// Presets d'enregistrement
// ---------------------------------------------------------------------------
/**
* Un preset décrit un compromis qualité / charge CPU. Il se traduit en écritures
* dans le profil OBS courant (`SetProfileParameter`, section `SimpleOutput`) et
* en un réglage vidéo (`SetVideoSettings`).
*
* Deux limites d'OBS qu'aucun contournement propre ne lève :
* - les paramètres de profil sont relus à chaque démarrage d'enregistrement,
* donc un changement de qualité prend effet au prochain enregistrement ;
* - l'objet encodeur, lui, n'est instancié qu'au lancement d'OBS : changer
* `RecEncoder` exige un redémarrage d'OBS pour être réellement pris en compte.
*/
export interface RecordingEncoderInfo {
/** Valeur écrite dans `SimpleOutput/RecEncoder`. */
obsValue: string;
label: string;
/** Clé de profil pilotant le compromis vitesse/qualité ; null si l'encodeur n'en expose pas. */
speedKey: string | null;
/** Jeu de valeurs accepté par cette clé. */
speedFamily: SpeedFamily | null;
hint: string;
}
export type SpeedFamily = 'x264' | 'nvenc' | 'qsv' | 'amd';
/**
* Pas d'auto-détection : obs-websocket ne publie pas la liste des encodeurs
* disponibles, et le matériel diffère d'une VM à l'autre. C'est l'opérateur qui
* choisit, VM par VM.
*/
export const RECORDING_ENCODERS = {
x264: {
obsValue: 'x264',
label: 'x264 — logiciel (CPU)',
speedKey: 'Preset',
speedFamily: 'x264',
hint: 'Toujours disponible. Le seul choix sur un VPS sans GPU.',
},
x264_lowcpu: {
obsValue: 'x264_lowcpu',
label: 'x264 économe — logiciel (CPU)',
speedKey: null,
speedFamily: null,
hint: "Force le réglage le plus rapide de x264 : CPU au plancher, fichiers plus gros. Ignore la vitesse du preset.",
},
nvenc: {
obsValue: 'nvenc',
label: 'NVENC H.264 — NVIDIA',
speedKey: 'NVENCPreset2',
speedFamily: 'nvenc',
hint: 'Encodage déporté sur le GPU : CPU quasi nul. Exige une carte NVIDIA.',
},
nvenc_hevc: {
obsValue: 'nvenc_hevc',
label: 'NVENC HEVC — NVIDIA',
speedKey: 'NVENCPreset2',
speedFamily: 'nvenc',
hint: 'Environ 30 % de poids en moins que H.264, moins universellement lisible.',
},
qsv: {
obsValue: 'qsv',
label: 'Quick Sync H.264 — Intel',
speedKey: 'QSVPreset',
speedFamily: 'qsv',
hint: 'iGPU Intel. Disponible sur beaucoup de VM bureautiques.',
},
amd: {
obsValue: 'amd',
label: 'AMF H.264 — AMD',
speedKey: 'AMDPreset',
speedFamily: 'amd',
hint: 'GPU AMD.',
},
apple_h264: {
obsValue: 'apple_h264',
label: 'VideoToolbox H.264 — macOS',
speedKey: null,
speedFamily: null,
hint: 'macOS uniquement.',
},
} as const satisfies Record<string, RecordingEncoderInfo>;
export type RecordingEncoder = keyof typeof RECORDING_ENCODERS;
export const DEFAULT_RECORDING_ENCODER: RecordingEncoder = 'x264';
export function isRecordingEncoder(value: unknown): value is RecordingEncoder {
return typeof value === 'string' && Object.hasOwn(RECORDING_ENCODERS, value);
}
export interface RecordingPreset {
id: string;
label: string;
summary: string;
/** Charge CPU relative, 1 = la plus légère. Sert à ordonner l'interface. */
cpuCost: 1 | 2 | 3 | 4;
/** Hauteur de sortie ; `null` conserve la résolution de la scène. Jamais d'agrandissement. */
height: number | null;
/**
* Images par seconde, toujours explicite. Un « ne pas toucher » rendrait le
* résultat dépendant du preset appliqué juste avant : passer d'Économe à
* Qualité maximale garderait les 30 fps du premier, sans que rien ne le dise.
*/
fps: number;
/**
* `SimpleOutput/RecQuality` : OBS en déduit un CRF (x264) ou un CQP (matériel).
* On évite volontairement le mode à débit fixe, qui gâche des bits sur les
* plans statiques et sature sur les plans animés.
*/
quality: 'Small' | 'HQ' | 'Lossless';
/** `mkv` survit à un plantage ; `hybrid_mp4` est lisible partout. */
format: 'mkv' | 'hybrid_mp4';
audioBitrateKbps: number;
/** Valeur de vitesse à écrire, selon la famille d'encodeur retenue. */
speed: Record<SpeedFamily, string>;
}
/**
* Catalogue ordonné du plus léger au plus lourd.
*
* Le levier de qualité est le CRF (`quality`), celui de charge CPU est la
* vitesse d'encodage (`speed`) puis la définition. Un preset lourd sur une VM
* sous-dimensionnée fait chuter les images par seconde : la qualité perçue
* baisse alors, malgré un CRF meilleur. Surveille « Frames perdues » sur la
* fiche de l'agent après un changement.
*/
export const RECORDING_PRESETS: RecordingPreset[] = [
{
id: 'light',
label: 'Économe',
summary:
'720p 30 fps, encodage rapide. Pour les VM à petit CPU ou plusieurs captures en parallèle.',
cpuCost: 1,
height: 720,
fps: 30,
quality: 'Small',
format: 'hybrid_mp4',
audioBitrateKbps: 128,
speed: { x264: 'superfast', nvenc: 'p3', qsv: 'speed', amd: 'speed' },
},
{
id: 'balanced',
label: 'Équilibré',
summary: "1080p 30 fps, qualité élevée. Le compromis par défaut : bonne image sans saturer un CPU modeste.",
cpuCost: 2,
height: 1080,
fps: 30,
quality: 'HQ',
format: 'hybrid_mp4',
audioBitrateKbps: 160,
speed: { x264: 'veryfast', nvenc: 'p5', qsv: 'balanced', amd: 'balanced' },
},
{
id: 'archive',
label: 'Qualité maximale',
summary:
"Définition de la scène, 60 fps, encodage lent. Exige un CPU confortable ou un encodeur matériel.",
cpuCost: 3,
height: null,
fps: 60,
quality: 'HQ',
format: 'mkv',
audioBitrateKbps: 192,
speed: { x264: 'medium', nvenc: 'p6', qsv: 'quality', amd: 'quality' },
},
{
id: 'lossless',
label: 'Sans perte',
summary:
'Aucune compression destructrice, 60 fps. Des dizaines de Go par heure : à réserver aux captures courtes.',
cpuCost: 4,
height: null,
fps: 60,
quality: 'Lossless',
format: 'mkv',
audioBitrateKbps: 320,
// OBS impose ultrafast en sans perte ; les autres valeurs sont là pour la forme.
speed: { x264: 'ultrafast', nvenc: 'p1', qsv: 'speed', amd: 'speed' },
},
];
export const DEFAULT_RECORDING_PRESET_ID = 'balanced';
export function findRecordingPreset(id: string): RecordingPreset | null {
return RECORDING_PRESETS.find((preset) => preset.id === id) ?? null;
}
/** Preset choisi pour un agent. Réglable indépendamment sur chaque VM. */
export interface RecordingSettings {
/** Tant que c'est faux, l'agent ne touche jamais aux réglages d'OBS. */
enabled: boolean;
presetId: string;
encoder: RecordingEncoder;
}
export const DEFAULT_RECORDING_SETTINGS: RecordingSettings = {
enabled: false,
presetId: DEFAULT_RECORDING_PRESET_ID,
encoder: DEFAULT_RECORDING_ENCODER,
};
export function normalizeRecordingSettings(raw: unknown): RecordingSettings {
const input = (raw ?? {}) as Partial<RecordingSettings>;
const base = DEFAULT_RECORDING_SETTINGS;
return {
enabled: input.enabled === true,
presetId:
typeof input.presetId === 'string' && findRecordingPreset(input.presetId)
? input.presetId
: base.presetId,
encoder: isRecordingEncoder(input.encoder) ? input.encoder : base.encoder,
};
}
/** Compte rendu détaillé d'une application de preset, remonté au dashboard. */
export interface PresetApplyResult {
presetId: string;
presetLabel: string;
encoder: RecordingEncoder;
/** Paramètres écrits, sous la forme `clé=valeur`. */
applied: string[];
/** Paramètres refusés par OBS, avec le motif. */
skipped: string[];
/** Résumé lisible du réglage vidéo obtenu (définition et fluidité de sortie). */
video: string;
/**
* Vrai si l'encodeur configuré diffère de celui qu'OBS a chargé au démarrage :
* la nouvelle valeur est écrite, mais OBS continuera d'utiliser l'ancienne
* jusqu'à son redémarrage.
*/
restartRequired: boolean;
}
// ---------------------------------------------------------------------------
// 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, mais le
* streamer est toujours là ». Ils mettent l'enregistrement en pause sans le
* clore : le flux est censé revenir.
*
* Énumération relevée sur l'API Stripchat : `public`, `private`, `p2p`,
* `groupShow`, `virtualPrivate`, `ticketShow`, `idle`, `off`.
*/
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;
/**
* Clore l'enregistrement quand le flux passe hors-ligne (statut brut « off »,
* « idle »…), une fois le délai ci-dessous écoulé.
*
* Le passage hors-ligne met de toute façon l'enregistrement en pause : l'écran
* d'attente n'a rien à faire dans le fichier. Ce réglage ne décide que de la
* clôture définitive.
*/
stopOnOffline: boolean;
/**
* Temps hors-ligne toléré avant de clore. Une coupure brève ne doit pas
* découper le fichier en deux ; une vraie fin de diffusion, si. Le compte à
* rebours est annulé dès que le flux revient, public ou privé.
*/
offlineStopDelayMs: number;
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,
// `idle` est le « revient bientôt » : le modèle est connecté (isOnline) mais ne
// diffuse pas (isLive faux). Il relève de la pause, pas de la clôture — à la
// différence de `off`, qui est une déconnexion franche.
privateStatuses: ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow', 'idle'],
confirmations: 2,
pauseOnPrivate: true,
resumeOnPublic: true,
stopOnOffline: true,
offlineStopDelayMs: 3_600_000,
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;
/** Depuis quand le flux est hors-ligne, absent s'il ne l'est pas. */
offlineSince?: number;
/** Échéance de la clôture automatique, absente si aucun compte à rebours ne court. */
stopScheduledAt?: number;
/** 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';
/**
* Nature d'un évènement journalisé.
*
* Le message reste du texte libre, destiné à être lu ; ce champ le classe, pour
* que l'historique d'une VM puisse être filtré et illustré sans avoir à faire
* de l'analyse de chaîne. Il est facultatif : un agent d'une version antérieure
* n'en envoie pas, et son entrée s'affiche simplement sans pictogramme.
*
* Le préfixe porte la catégorie — l'interface s'en sert pour ses filtres, donc
* un nouvel évènement doit réutiliser un préfixe existant quand c'est possible.
*/
export const AGENT_EVENTS = [
'agent.connected',
'agent.disconnected',
'agent.enrolled',
'agent.updated',
'obs.connected',
'obs.disconnected',
'record.started',
'record.stopped',
'record.paused',
'record.resumed',
'record.split',
'stream.started',
'stream.stopped',
'capture.started',
'capture.stopped',
'watch.started',
'watch.private',
'watch.public',
'watch.offline',
'watch.failed',
'fullscreen.restored',
'fullscreen.failed',
'browser.opened',
'browser.closed',
'preset.applied',
'preset.failed',
'config.changed',
'command.failed',
] as const;
export type AgentEvent = (typeof AGENT_EVENTS)[number];
export function isAgentEvent(value: unknown): value is AgentEvent {
return typeof value === 'string' && (AGENT_EVENTS as readonly string[]).includes(value);
}
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;
event?: AgentEvent;
}
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;
recording: RecordingSettings;
}
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;
recording: RecordingSettings;
}
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;
recording: RecordingSettings;
notes: string | null;
status: AgentStatus;
}
export interface LogEntry {
id: number;
agentId: string | null;
agentName: string | null;
level: LogLevel;
message: string;
ts: number;
/** Absent sur les entrées anciennes et sur celles des agents non mis à jour. */
event: AgentEvent | null;
}
/**
* 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;
/**
* Lancer l'enregistrement sans intervention dès que ce profil est en direct
* depuis assez longtemps. Exige un agent assigné, et un seul profil en
* automatisme par agent : une VM n'enregistre qu'un flux à la fois.
*/
autoRecord: 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,
stopOnOffline: input.stopOnOffline !== false,
// Plafond à 24 h : au-delà, l'enregistrement mobiliserait une VM pour rien.
offlineStopDelayMs: clamp(input.offlineStopDelayMs, base.offlineStopDelayMs, 0, 86_400_000),
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`, `virtualPrivate`, `idle`, `off`.
*
* Deux champs voisins prêtent à confusion et ne doivent pas servir ici :
* - `offlineStatus` est le message d'absence libre du modèle. Il reste
* renseigné pendant qu'il diffuse : il ne dit rien de l'état courant.
* - `isLive` / `isOnline` distinguent `idle` (connecté, ne diffuse pas) de
* `off` (déconnecté), ce que `status` exprime déjà.
*
* 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';
// off / offline / deleted / notFound : le modèle n'est plus là du tout.
return 'offline';
}