421 lines
14 KiB
TypeScript
421 lines
14 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();
|
|
|
|
if (next === 'private') {
|
|
// Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît.
|
|
this.fullscreenPending = true;
|
|
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> {
|
|
if (this.state.autoPaused && this.settings.resumeOnPublic) {
|
|
try {
|
|
const record = await this.actions.recordState();
|
|
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 (this.fullscreenPending && this.settings.fullscreen.enabled) {
|
|
this.fullscreenPending = false;
|
|
this.scheduleFullscreen();
|
|
} else {
|
|
this.fullscreenPending = false;
|
|
}
|
|
}
|
|
|
|
/** 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.
|
|
this.emit(
|
|
'log',
|
|
result.confirmed === false ? 'warn' : 'info',
|
|
describeFullscreen(result, fullscreen.key),
|
|
result.confirmed === false ? '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 };
|