Files
stream-control/packages/agent/src/watcher.ts
jeanotx32 449a6d917f
All checks were successful
release / build (push) Successful in 23s
release / verify-windows (push) Successful in 1m27s
Fix : FS after off status
2026-08-12 22:40:58 +02:00

467 lines
16 KiB
TypeScript

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<void>;
resumeRecording(): Promise<void>;
/** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */
stopRecording(): Promise<void>;
/**
* 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<FullscreenOutcome>;
}
/**
* 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<WatchState> {
await this.tick();
return this.snapshot;
}
private async tick(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<FullscreenOutcome> {
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<StreamState, AgentEvent | undefined> = {
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 };