Feat : Streamer watch list
Some checks failed
release / build (push) Successful in 28s
release / verify-windows (push) Failing after 1m13s

This commit is contained in:
jeanotx32
2026-08-11 19:24:31 +02:00
parent b85a535dd8
commit e87afc49c0
13 changed files with 936 additions and 48 deletions

View File

@@ -0,0 +1,115 @@
import type { StreamState, WatchTarget } from '@stream-control/shared';
import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/shared';
import { config } from './config.ts';
import { targetsRepo } from './db.ts';
import { hub } from './hub.ts';
/**
* Sonde périodiquement les profils surveillés et signale les passages en direct.
*
* Côté serveur et non côté agent : un profil se surveille indépendamment de
* toute machine d'enregistrement, et une seule requête suffit quel que soit le
* nombre d'agents.
*/
class Watchlist {
private timer: NodeJS.Timeout | null = null;
private running = false;
start(): void {
if (this.timer) return;
this.timer = setInterval(() => void this.tick(), config.watchlistIntervalMs);
this.timer.unref?.();
void this.tick();
}
stop(): void {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
/** Sonde immédiate d'un seul profil, déclenchée depuis le dashboard. */
async checkOne(id: string): Promise<WatchTarget | null> {
const target = targetsRepo.get(id);
if (!target) return null;
await this.probe(target);
return targetsRepo.get(id);
}
private async tick(): Promise<void> {
if (this.running) return; // un cycle lent ne doit pas s'empiler sur le suivant
this.running = true;
try {
const targets = targetsRepo.list();
// Séquentiel et espacé : une poignée de profils ne justifie pas de
// marteler l'API en parallèle.
for (const target of targets) {
await this.probe(target);
await delay(250);
}
} finally {
this.running = false;
}
}
private async probe(target: WatchTarget): Promise<void> {
let next: StreamState;
let raw: string | null = null;
let error: string | null = null;
try {
const result = await fetchStripchatStatus(
target.username,
DEFAULT_WATCH_SETTINGS.privateStatuses,
);
next = result.state;
raw = result.raw;
} catch (err) {
// Une sonde en échec ne change pas l'état connu : on garde le dernier
// verdict fiable plutôt que d'annoncer un faux passage hors-ligne.
error = err instanceof Error ? err.message : String(err);
next = target.state;
}
const changed = next !== target.state;
targetsRepo.updateState(target.id, {
state: next,
rawStatus: raw ?? target.rawStatus,
stateSince: changed ? Date.now() : target.stateSince,
lastError: error,
});
const updated = targetsRepo.get(target.id);
if (!updated) return;
hub.publishTarget(updated);
if (changed) {
const name = updated.label ?? updated.username;
hub.log(null, 'info', `Veille : ${name} est passé « ${labelOf(next)} »`);
// Seul le passage effectif au flux public déclenche une notification.
if (next === 'public' && target.state !== 'public' && updated.notify) {
hub.publishTargetLive(updated);
}
}
}
}
function labelOf(state: StreamState): string {
switch (state) {
case 'public':
return 'en direct';
case 'private':
return 'en show privé';
case 'offline':
return 'hors-ligne';
default:
return 'inconnu';
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const watchlist = new Watchlist();