/** * 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; 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; } /** * 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; 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; 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; } 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(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; const base = DEFAULT_WATCH_SETTINGS; const fullscreen = (input.fullscreen ?? {}) as Partial; 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 { 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'; }