Feat : Prio order for auto stream record
This commit is contained in:
@@ -236,7 +236,8 @@ api.post('/watchlist', (req, res) => {
|
||||
username,
|
||||
label: typeof req.body?.label === 'string' && req.body.label.trim() ? req.body.label.trim() : null,
|
||||
agentId: typeof req.body?.agentId === 'string' ? req.body.agentId : null,
|
||||
notify: req.body?.notify !== false,
|
||||
// Silencieux tant que l'opérateur ne l'a pas demandé, cloche 🔔 sur la fiche.
|
||||
notify: req.body?.notify === true,
|
||||
});
|
||||
|
||||
hub.log(null, 'info', `Veille : « ${username} » ajouté`);
|
||||
@@ -266,20 +267,12 @@ api.patch('/watchlist/:id', (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Un streamer par VM : deux automatismes sur le même agent se disputeraient la
|
||||
// machine, et le second échouerait systématiquement sur « déjà en
|
||||
// enregistrement ». Autant le refuser ici, où l'on peut l'expliquer.
|
||||
if (autoRecord && agentId) {
|
||||
const conflict = targetsRepo
|
||||
.list()
|
||||
.find((other) => other.id !== target.id && other.autoRecord && other.agentId === agentId);
|
||||
if (conflict) {
|
||||
res.status(409).json({
|
||||
error: `« ${conflict.label ?? conflict.username} » est déjà en automatisme sur cet agent`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Plusieurs automatismes par VM sont désormais permis : c'est la priorité qui
|
||||
// arbitre. Une version antérieure refusait le second en 409, faute de règle
|
||||
// pour départager — le refus n'avait plus lieu d'être une fois cette règle
|
||||
// écrite, et l'interdire empêcherait justement d'exprimer un ordre.
|
||||
|
||||
const priority = Number(req.body?.priority);
|
||||
|
||||
targetsRepo.updateSettings(target.id, {
|
||||
label: typeof req.body?.label === 'string' ? req.body.label.trim() || null : target.label,
|
||||
@@ -287,6 +280,12 @@ api.patch('/watchlist/:id', (req, res) => {
|
||||
notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify,
|
||||
autoRecord,
|
||||
favorite: typeof req.body?.favorite === 'boolean' ? req.body.favorite : target.favorite,
|
||||
// Borné : la valeur sert de comparaison, une saisie farfelue n'apporterait
|
||||
// rien de plus qu'un rang très haut ou très bas.
|
||||
priority: Number.isFinite(priority)
|
||||
? Math.min(Math.max(Math.round(priority), 0), 100)
|
||||
: target.priority,
|
||||
preempt: typeof req.body?.preempt === 'boolean' ? req.body.preempt : target.preempt,
|
||||
});
|
||||
|
||||
const updated = targetsRepo.get(target.id);
|
||||
|
||||
@@ -71,7 +71,9 @@ db.exec(`
|
||||
username TEXT NOT NULL,
|
||||
label TEXT,
|
||||
agent_id TEXT,
|
||||
notify INTEGER NOT NULL DEFAULT 1,
|
||||
-- Éteintes par défaut : suivre un profil sert souvent à l'observer, et une
|
||||
-- liste qui grandit ne doit pas transformer le téléphone en sonnette.
|
||||
notify INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'unknown',
|
||||
raw_status TEXT,
|
||||
state_since INTEGER NOT NULL,
|
||||
@@ -152,6 +154,13 @@ addColumnIfMissing('watch_targets', 'last_live_ended_at', 'INTEGER');
|
||||
addColumnIfMissing('watch_targets', 'auto_record', 'INTEGER NOT NULL DEFAULT 0');
|
||||
// Épinglage en tête de liste, sans autre effet.
|
||||
addColumnIfMissing('watch_targets', 'favorite', 'INTEGER NOT NULL DEFAULT 0');
|
||||
// Arbitrage entre profils qui se disputent la même VM. Le défaut « Normale »
|
||||
// et non zéro : les profils existants doivent atterrir au milieu de l'échelle,
|
||||
// pas tout en bas, faute de quoi le premier rang réglé les surclasserait tous.
|
||||
addColumnIfMissing('watch_targets', 'priority', 'INTEGER NOT NULL DEFAULT 1');
|
||||
// Droit de couper une capture de rang inférieur. Refusé par défaut : un
|
||||
// enregistrement en cours ne s'interrompt pas sans un geste explicite.
|
||||
addColumnIfMissing('watch_targets', 'preempt', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
/**
|
||||
* `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de
|
||||
@@ -460,6 +469,8 @@ interface TargetRow {
|
||||
notify: number;
|
||||
auto_record: number;
|
||||
favorite: number;
|
||||
priority: number;
|
||||
preempt: number;
|
||||
state: string;
|
||||
raw_status: string | null;
|
||||
state_since: number;
|
||||
@@ -485,6 +496,8 @@ function toTarget(row: TargetRow): WatchTarget {
|
||||
notify: Number(row.notify) === 1,
|
||||
favorite: Number(row.favorite) === 1,
|
||||
autoRecord: Number(row.auto_record) === 1,
|
||||
priority: Number(row.priority),
|
||||
preempt: Number(row.preempt) === 1,
|
||||
state: (row.state as StreamState) ?? 'unknown',
|
||||
rawStatus: row.raw_status,
|
||||
stateSince: Number(row.state_since),
|
||||
@@ -507,9 +520,11 @@ const targetStmts = {
|
||||
state, state_since, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?)
|
||||
`),
|
||||
updateSettings: db.prepare(
|
||||
'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ?, auto_record = ?, favorite = ? WHERE id = ?',
|
||||
),
|
||||
updateSettings: db.prepare(`
|
||||
UPDATE watch_targets
|
||||
SET label = ?, agent_id = ?, notify = ?, auto_record = ?, favorite = ?, priority = ?, preempt = ?
|
||||
WHERE id = ?
|
||||
`),
|
||||
updateState: db.prepare(`
|
||||
UPDATE watch_targets
|
||||
SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ?,
|
||||
@@ -550,7 +565,7 @@ export const targetsRepo = {
|
||||
input.username,
|
||||
input.label ?? null,
|
||||
input.agentId ?? null,
|
||||
input.notify === false ? 0 : 1,
|
||||
input.notify === true ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
@@ -567,6 +582,8 @@ export const targetsRepo = {
|
||||
notify: boolean;
|
||||
autoRecord: boolean;
|
||||
favorite: boolean;
|
||||
priority: number;
|
||||
preempt: boolean;
|
||||
},
|
||||
): void {
|
||||
targetStmts.updateSettings.run(
|
||||
@@ -575,6 +592,8 @@ export const targetsRepo = {
|
||||
settings.notify ? 1 : 0,
|
||||
settings.autoRecord ? 1 : 0,
|
||||
settings.favorite ? 1 : 0,
|
||||
settings.priority,
|
||||
settings.preempt ? 1 : 0,
|
||||
id,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -122,17 +122,24 @@ class Hub {
|
||||
// Le profil n'est cherché que tant qu'il manque : une capture déjà
|
||||
// rattachée l'est pour de bon, inutile de reposer la question toutes les
|
||||
// deux secondes.
|
||||
spansRepo.touch(open.id, now, open.targetId ? null : this.recordedTargetId(agentId));
|
||||
spansRepo.touch(open.id, now, open.targetId ? null : this.recordingTarget(agentId)?.id ?? null);
|
||||
} else {
|
||||
spansRepo.open(agentId, this.recordedTargetId(agentId), now);
|
||||
spansRepo.open(agentId, this.recordingTarget(agentId)?.id ?? null, now);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le profil que cette VM capture, d'après le pseudo sur lequel sa veille est calée. */
|
||||
private recordedTargetId(agentId: string): string | null {
|
||||
/**
|
||||
* Le profil que cette VM capture, d'après le pseudo sur lequel sa veille est
|
||||
* calée — `startTargetRecording()` l'y pose, c'est le seul lien fiable.
|
||||
*
|
||||
* Nul si la VM n'enregistre pas, ou si sa veille ne désigne aucun profil
|
||||
* suivi : une capture lancée à la main depuis l'onglet Agents, par exemple.
|
||||
*/
|
||||
recordingTarget(agentId: string): WatchTarget | null {
|
||||
if (!this.statusOf(agentId).recording) return null;
|
||||
const record = agentsRepo.get(agentId);
|
||||
if (!record?.watch.username) return null;
|
||||
return targetsRepo.findByUsername(record.watch.provider, record.watch.username)?.id ?? null;
|
||||
return targetsRepo.findByUsername(record.watch.provider, record.watch.username);
|
||||
}
|
||||
|
||||
resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { WatchTarget } from '@stream-control/shared';
|
||||
import { normalizeWatchSettings } from '@stream-control/shared';
|
||||
import { agentsRepo } from './db.ts';
|
||||
import { normalizeWatchSettings, preemptionVerdict } from '@stream-control/shared';
|
||||
import { agentsRepo, type AgentRecord } from './db.ts';
|
||||
import { hub } from './hub.ts';
|
||||
|
||||
/**
|
||||
* Attente de la confirmation d'arrêt avant de relancer sur le même agent.
|
||||
*
|
||||
* Le résultat de la commande revient avant que le statut ne le reflète : c'est
|
||||
* le cycle de statut de l'agent (deux secondes) qui fait foi, et OBS met un
|
||||
* instant à clore son fichier. Relancer trop tôt se heurterait au garde-fou
|
||||
* « déjà en enregistrement » que l'on vient justement de lever.
|
||||
*/
|
||||
const STOP_TIMEOUT_MS = 20_000;
|
||||
const STOP_POLL_MS = 250;
|
||||
|
||||
/**
|
||||
* Lance l'enregistrement d'un profil sur l'agent qui lui est assigné.
|
||||
*
|
||||
@@ -18,10 +29,12 @@ export async function startTargetRecording(target: WatchTarget): Promise<void> {
|
||||
if (!record) throw new Error("L'agent assigné n'existe plus");
|
||||
if (!hub.isOnline(record.id)) throw new Error(`Agent « ${record.name} » hors-ligne`);
|
||||
|
||||
// Une VM n'enregistre qu'un flux à la fois : écraser une capture en cours
|
||||
// perdrait la première sans que personne ne l'ait demandé.
|
||||
// Une VM n'enregistre qu'un flux à la fois. Écraser une capture en cours
|
||||
// reste refusé par défaut — sauf si ce profil a reçu le droit d'interrompre
|
||||
// et qu'il l'emporte en priorité, auquel cas on referme proprement avant de
|
||||
// relancer plutôt que de laisser les deux se marcher dessus.
|
||||
if (hub.statusOf(record.id).recording) {
|
||||
throw new Error(`Agent « ${record.name} » déjà en enregistrement`);
|
||||
await preemptRecording(record, target);
|
||||
}
|
||||
|
||||
// La surveillance de l'agent suit le profil qu'on enregistre : c'est elle qui
|
||||
@@ -69,3 +82,47 @@ export async function startTargetRecording(target: WatchTarget): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Referme la capture en cours au profit de `target`, ou refuse en expliquant.
|
||||
*
|
||||
* Le verdict est calculé côté partagé et non ici : le dashboard doit pouvoir
|
||||
* annoncer ce que fera le bouton — « interrompt Bob » plutôt qu'un échec
|
||||
* découvert après coup — et deux règles séparées finiraient par diverger.
|
||||
*/
|
||||
async function preemptRecording(record: AgentRecord, target: WatchTarget): Promise<void> {
|
||||
const current = hub.recordingTarget(record.id);
|
||||
const verdict = preemptionVerdict(target, current);
|
||||
|
||||
if (!verdict.allowed) {
|
||||
throw new Error(`Agent « ${record.name} » déjà en enregistrement : ${verdict.reason}`);
|
||||
}
|
||||
|
||||
const name = target.label ?? target.username;
|
||||
const displaced = current ? (current.label ?? current.username) : 'la capture en cours';
|
||||
hub.log(
|
||||
record.id,
|
||||
'warn',
|
||||
`Priorité : « ${displaced} » interrompu au profit de « ${name} » sur ${record.name}`,
|
||||
Date.now(),
|
||||
'record.stopped',
|
||||
);
|
||||
|
||||
await stopAgentRecording(record);
|
||||
}
|
||||
|
||||
/** Arrête la capture et attend que l'agent le confirme par son statut. */
|
||||
export async function stopAgentRecording(record: AgentRecord): Promise<void> {
|
||||
await hub.sendCommand(record.id, record.browser.enabled ? 'capture.stop' : 'record.stop');
|
||||
|
||||
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!hub.statusOf(record.id).recording) return;
|
||||
await delay(STOP_POLL_MS);
|
||||
}
|
||||
throw new Error(`Agent « ${record.name} » n'a pas confirmé l'arrêt de sa capture`);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { StreamState, WatchTarget } from '@stream-control/shared';
|
||||
import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/shared';
|
||||
import {
|
||||
DEFAULT_WATCH_SETTINGS,
|
||||
fetchStripchatStatus,
|
||||
preemptionVerdict,
|
||||
} from '@stream-control/shared';
|
||||
import { config } from './config.ts';
|
||||
import { sessionsRepo, targetsRepo } from './db.ts';
|
||||
import { hub } from './hub.ts';
|
||||
@@ -51,7 +55,14 @@ class Watchlist {
|
||||
if (this.running) return; // un cycle lent ne doit pas s'empiler sur le suivant
|
||||
this.running = true;
|
||||
try {
|
||||
const targets = targetsRepo.list();
|
||||
// Du plus prioritaire au moins prioritaire : c'est l'ordre de sondage qui
|
||||
// décide qui tente sa chance en premier sur une VM libre. Sonder au
|
||||
// hasard laisserait un profil mineur s'en emparer, pour se faire couper
|
||||
// dans la foulée par son aîné — le résultat serait le même, au prix d'un
|
||||
// fichier de quelques secondes.
|
||||
const targets = targetsRepo
|
||||
.list()
|
||||
.sort((a, b) => b.priority - a.priority || a.username.localeCompare(b.username));
|
||||
// Séquentiel et espacé : une poignée de profils ne justifie pas de
|
||||
// marteler l'API en parallèle.
|
||||
for (const target of targets) {
|
||||
@@ -162,9 +173,16 @@ class Watchlist {
|
||||
if (Date.now() - liveSince < config.autoRecordDelayMs) return;
|
||||
|
||||
if (!hub.isOnline(target.agentId)) return;
|
||||
// Une VM déjà occupée n'est jamais préemptée : l'enregistrement en cours
|
||||
// prime sur celui qu'on allait lancer.
|
||||
if (hub.statusOf(target.agentId).recording) return;
|
||||
|
||||
// VM occupée : par défaut la capture en place l'emporte, sauf si ce profil
|
||||
// a reçu le droit d'interrompre et le rang pour le faire. Le verdict est
|
||||
// recalculé par startTargetRecording — le refaire ici évite seulement de
|
||||
// consommer une des trois tentatives à chaque cycle pour une interruption
|
||||
// qui restera refusée tant que rien ne change.
|
||||
if (hub.statusOf(target.agentId).recording) {
|
||||
const verdict = preemptionVerdict(target, hub.recordingTarget(target.agentId));
|
||||
if (!verdict.allowed) return;
|
||||
}
|
||||
|
||||
const agentId = target.agentId;
|
||||
const name = target.label ?? target.username;
|
||||
|
||||
Reference in New Issue
Block a user