Feat : Streamer watch list
This commit is contained in:
115
packages/server/src/watchlist.ts
Normal file
115
packages/server/src/watchlist.ts
Normal 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();
|
||||
Reference in New Issue
Block a user