import { EventEmitter } from 'node:events'; import type { AgentEvent, FullscreenSettings, LogLevel, StreamState, WatchSettings, WatchState, } from '@stream-control/shared'; import { emptyWatchState, fetchStripchatStatus } from '@stream-control/shared'; import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts'; export interface ProbeResult { /** Statut brut renvoyé par la plateforme. */ raw: string; state: StreamState; } /** * Sonde Stripchat. L'implémentation vit dans le paquet partagé : le serveur s'en * sert aussi pour la veille, et l'endpoint ne doit être défini qu'à un endroit. */ export const probeStripchat = fetchStripchatStatus; /** Ce que le surveillant doit pouvoir demander à OBS. */ export interface WatchActions { recordState(): Promise<{ active: boolean; paused: boolean }>; pauseRecording(): Promise; resumeRecording(): Promise; /** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */ stopRecording(): Promise; /** * Rappelle le plein écran du lecteur. * * Injecté plutôt qu'appelé en direct : selon le mode de pilotage, c'est une * touche envoyée au serveur d'affichage ou une commande WebDriver adressée à * Firefox. Le surveillant n'a pas à connaître cette différence — il sait * seulement que le flux public est revenu. */ restoreFullscreen(settings: FullscreenSettings): Promise; } /** * Surveille le statut du stream source et met l'enregistrement en pause pendant * les shows privés, puis le reprend et rappelle le plein écran à la reprise du * flux public. * * Deux garde-fous délibérés : * - la mise en pause exige N lectures « privé » consécutives, la reprise agit * immédiatement (une fausse pause perd du contenu, une fausse reprise ne * coûte que quelques secondes d'écran d'attente) ; * - une erreur de sonde ne déclenche jamais d'action : on conserve l'état connu. */ export class StreamWatcher extends EventEmitter { private settings: WatchSettings; private state: WatchState; private timer: NodeJS.Timeout | null = null; private fullscreenTimer: NodeJS.Timeout | null = null; /** Compte à rebours de clôture, armé tant que le flux reste hors-ligne. */ private offlineTimer: NodeJS.Timeout | null = null; private ticking = false; /** Le flux public a été interrompu : il faudra rappeler le plein écran. */ private fullscreenPending = false; constructor( settings: WatchSettings, private readonly actions: WatchActions, ) { super(); this.settings = settings; this.state = emptyWatchState(settings); } get snapshot(): WatchState { return { ...this.state }; } /** Réglages courants — la séquence de capture réutilise ceux du plein écran. */ get snapshotSettings(): WatchSettings { return this.settings; } applySettings(settings: WatchSettings): void { const restart = settings.enabled !== this.settings.enabled || settings.username !== this.settings.username || settings.provider !== this.settings.provider || settings.pollIntervalMs !== this.settings.pollIntervalMs; const identityChanged = settings.username !== this.settings.username || settings.provider !== this.settings.provider; this.settings = settings; this.state.enabled = settings.enabled; this.state.provider = settings.provider; this.state.username = settings.username; if (identityChanged) { this.state.state = 'unknown'; this.state.rawStatus = undefined; this.state.since = Date.now(); this.state.pendingConfirmations = 0; this.state.autoPaused = false; this.fullscreenPending = false; this.cancelOfflineStop(); } if (restart) this.start(); } start(): void { this.stop(); if (!this.settings.enabled || !this.settings.username) return; this.emit( 'log', 'info', `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); this.timer.unref?.(); } stop(): void { if (this.timer) clearInterval(this.timer); this.timer = null; if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer); this.fullscreenTimer = null; this.cancelOfflineStop(); } /** Sonde immédiate, utilisée par l'action `watch.check`. */ async checkNow(): Promise { await this.tick(); return this.snapshot; } private async tick(): Promise { if (this.ticking) return; // une sonde lente ne doit pas s'empiler if (!this.settings.enabled || !this.settings.username) return; this.ticking = true; try { const result = await probeStripchat(this.settings.username, this.settings.privateStatuses); this.state.lastCheckedAt = Date.now(); this.state.lastError = undefined; this.state.rawStatus = result.raw; await this.transition(result.state); } catch (err) { const message = err instanceof Error ? err.message : String(err); 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}`, 'watch.failed', ); } this.state.lastError = message; } finally { this.ticking = false; } } private async transition(next: StreamState): Promise { const previous = this.state.state; if (next !== previous) { this.state.state = next; this.state.since = Date.now(); this.emit( 'log', 'info', `Stream « ${this.settings.username} » : ${label(previous)} → ${label(next)} (${this.state.rawStatus})`, TRANSITIONS[next], ); } this.state.pendingConfirmations = next === 'private' ? this.state.pendingConfirmations + 1 : 0; // Le retour du flux, public ou privé, annule toute clôture programmée. if (next !== 'offline') this.cancelOfflineStop(); // Toute interruption du flux public appelle un rappel du plein écran au // retour : le lecteur en sort aussi bien sur l'overlay de show privé que sur // l'écran d'attente d'un flux coupé. Le hors-ligne était initialement // absent d'ici — le cas ne se limite pourtant pas à une fin de diffusion, // puisqu'une coupure plus courte que `offlineStopDelayMs` reprend le même // enregistrement, dans la même fenêtre, qui a entre-temps perdu son plein // écran sans que rien ne le lui rende. if (next === 'private' || next === 'offline') { this.fullscreenPending = true; } if (next === 'private') { await this.handlePrivate(); return; } if (next === 'public') { await this.handlePublic(); return; } if (next === 'offline') { await this.handleOffline(); return; } // 'unknown' : sonde sans verdict exploitable, on ne touche à rien. } /** * Flux hors-ligne : pause immédiate, clôture différée. * * Les deux temps répondent à deux risques distincts. Enregistrer l'écran * d'attente ne sert à rien, d'où la pause sans délai. Mais une coupure de * quelques minutes est fréquente, et clore tout de suite découperait le * fichier en deux — d'où le compte à rebours avant l'arrêt réel. */ private async handleOffline(): Promise { if (!this.settings.stopOnOffline) return; this.state.offlineSince ??= Date.now(); try { const record = await this.actions.recordState(); if (!record.active) { // Rien à clore : inutile d'armer quoi que ce soit. this.cancelOfflineStop(); return; } // Le garde `autoPaused` évite de rejouer la pause à chaque sonde, et donc // de lutter contre un opérateur qui aurait repris la main. if (!this.state.autoPaused && !record.paused) { await this.actions.pauseRecording(); this.state.autoPaused = true; this.emit('log', 'info', 'Flux hors-ligne : enregistrement mis en pause', 'record.paused'); } } catch (err) { this.emit( 'log', 'error', `Pause hors-ligne impossible : ${err instanceof Error ? err.message : String(err)}`, 'command.failed', ); return; } this.scheduleOfflineStop(); } private scheduleOfflineStop(): void { if (this.offlineTimer) return; // déjà armé pour cette coupure const delayMs = this.settings.offlineStopDelayMs; this.state.stopScheduledAt = Date.now() + delayMs; this.emit( 'log', 'info', `Clôture de l'enregistrement dans ${formatDelay(delayMs)} si le flux ne revient pas`, 'watch.offline', ); this.offlineTimer = setTimeout(() => { this.offlineTimer = null; void this.stopForOffline(); }, delayMs); this.offlineTimer.unref?.(); } private cancelOfflineStop(): void { if (this.offlineTimer) clearTimeout(this.offlineTimer); this.offlineTimer = null; this.state.offlineSince = undefined; this.state.stopScheduledAt = undefined; } private async stopForOffline(): Promise { const offlineFor = this.state.offlineSince ? Date.now() - this.state.offlineSince : 0; this.state.stopScheduledAt = undefined; // Le flux a pu revenir entre l'armement et l'échéance. if (this.state.state !== 'offline') return; try { const record = await this.actions.recordState(); if (!record.active) return; await this.actions.stopRecording(); this.state.autoPaused = false; this.emit( 'log', 'info', `Flux hors-ligne depuis ${formatDelay(offlineFor)} : enregistrement clos`, 'record.stopped', ); } catch (err) { this.emit( 'log', 'error', `Clôture automatique impossible : ${err instanceof Error ? err.message : String(err)}`, 'command.failed', ); } } private async handlePrivate(): Promise { if (!this.settings.pauseOnPrivate || this.state.autoPaused) return; if (this.state.pendingConfirmations < this.settings.confirmations) return; try { const record = await this.actions.recordState(); 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', 'record.paused', ); } catch (err) { this.emit( 'log', 'error', `Mise en pause automatique impossible : ${err instanceof Error ? err.message : String(err)}`, 'command.failed', ); } } private async handlePublic(): Promise { const resuming = this.state.autoPaused && this.settings.resumeOnPublic; const recalling = this.fullscreenPending && this.settings.fullscreen.enabled; // Un rappel armé alors que le réglage est désactivé n'a plus lieu d'être : // le désarmer tout de suite évite qu'il parte bien plus tard, au premier // réglage réactivé, pour une interruption depuis longtemps oubliée. if (!recalling) this.fullscreenPending = false; if (!resuming && !recalling) return; // Une seule lecture pour les deux décisions qui suivent : reprendre une // capture en pause, et rappeler le plein écran — qui n'a de sens que s'il y // a justement une capture à cadrer. let record: { active: boolean; paused: boolean }; try { record = await this.actions.recordState(); } catch (err) { this.emit( 'log', 'error', `État de l'enregistrement illisible : ${err instanceof Error ? err.message : String(err)}`, 'command.failed', ); // `autoPaused` est relâché quoi qu'il arrive : s'obstiner à reprendre une // capture dont on ne sait rien reviendrait à réessayer à chaque sonde. Le // rappel de plein écran, lui, reste armé — le différer ne coûte rien, et // la sonde suivante retentera. this.state.autoPaused = false; return; } if (resuming) { try { if (record.active && record.paused) { await this.actions.resumeRecording(); 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; } } if (recalling) { this.fullscreenPending = false; // Seulement s'il y a une fenêtre à cadrer. Le surveillant tourne en // permanence, pas seulement pendant une capture : hors enregistrement, le // rappel s'adresserait à une fenêtre que personne ne filme — et en mode // BiDi il irait jusqu'à lancer Firefox pour l'occasion. Le cas est // courant depuis que le hors-ligne arme lui aussi le rappel : un profil // surveillé sans être enregistré passe hors-ligne puis en direct comme // n'importe quel autre. if (record.active) this.scheduleFullscreen(); } } /** Laisse au lecteur le temps de recharger le flux public avant d'envoyer la touche. */ private scheduleFullscreen(): void { if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer); this.fullscreenTimer = setTimeout(() => { this.fullscreenTimer = null; // L'échec est déjà journalisé par restoreFullscreen ; le rattraper ici // évite un rejet non géré pour une erreur dont on a déjà rendu compte. void this.restoreFullscreen().catch(() => undefined); }, this.settings.fullscreen.delayMs); this.fullscreenTimer.unref?.(); } async restoreFullscreen(): Promise { const fullscreen = this.settings.fullscreen; try { const result = await this.actions.restoreFullscreen(fullscreen); // Un `confirmed: false` n'est pas un échec de commande : la demande est // partie, la page ne l'a pas suivie. Le distinguer permet de la relire // dans l'historique sans la confondre avec une erreur de configuration. // `skipped` prime dessus : un envoi non renvoyé (déjà en cours ou trop // rapproché) n'est jamais un échec ici, quel que soit le sort de celui // qu'il a réutilisé. const failed = !result.skipped && result.confirmed === false; this.emit( 'log', failed ? 'warn' : 'info', describeFullscreen(result, fullscreen.key), failed ? 'fullscreen.failed' : '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}`, 'fullscreen.failed'); throw err; } } } /** Évènement correspondant à l'état atteint ; `unknown` n'en produit aucun. */ const TRANSITIONS: Record = { public: 'watch.public', private: 'watch.private', offline: 'watch.offline', unknown: undefined, }; /** Durée lisible pour les messages de journal : « 1 h », « 12 min », « 45 s ». */ function formatDelay(ms: number): string { if (ms >= 3_600_000) { const hours = ms / 3_600_000; return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} h`; } if (ms >= 60_000) return `${Math.round(ms / 60_000)} min`; return `${Math.round(ms / 1000)} s`; } function label(state: StreamState): string { switch (state) { case 'public': return 'public'; case 'private': return 'privé'; case 'offline': return 'hors-ligne'; default: return 'inconnu'; } } export type { LogLevel };