Feat : Prio order for auto stream record
All checks were successful
release / build (push) Successful in 27s
release / verify-windows (push) Successful in 1m20s

This commit is contained in:
jeanotx32
2026-08-12 16:01:18 +02:00
parent 96b364bd66
commit eae694d49a
12 changed files with 442 additions and 72 deletions

View File

@@ -33,7 +33,9 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
- [Clôture différée sur passage hors-ligne](#passage-hors-ligne) : une coupure brève - [Clôture différée sur passage hors-ligne](#passage-hors-ligne) : une coupure brève
ne découpe pas le fichier, une vraie fin de diffusion le termine. ne découpe pas le fichier, une vraie fin de diffusion le termine.
- [Enregistrement automatique](#enregistrement-automatique) d'un streamer sur une VM - [Enregistrement automatique](#enregistrement-automatique) d'un streamer sur une VM
dédiée, dès qu'il est en direct depuis assez longtemps. dédiée, dès qu'il est en direct depuis assez longtemps, avec
[priorité et interruption](#priorité-et-interruption) quand plusieurs profils se disputent
la même machine.
- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface, - [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface,
réglables VM par VM : de « Économe » à « Sans perte ». réglables VM par VM : de « Économe » à « Sans perte ».
- [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil - [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil
@@ -520,7 +522,9 @@ identifiants du formulaire, sans attendre un vrai passage en direct pour découv
de frappe. de frappe.
Déclenché sur le même évènement que la notification navigateur : un profil dont `notify` est Déclenché sur le même évènement que la notification navigateur : un profil dont `notify` est
coché (la cloche 🔔) passant à l'état public. Un échec d'envoi (identifiants invalides, coché (la cloche 🔔) passant à l'état public. **Un profil nouvellement suivi est silencieux**
suivre quelqu'un sert souvent à l'observer, et une liste qui grandit ne doit pas transformer
le téléphone en sonnette. La cloche s'active profil par profil, depuis sa fiche. Un échec d'envoi (identifiants invalides,
Pushover injoignable) finit dans le journal, jamais en erreur qui interromprait la veille — Pushover injoignable) finit dans le journal, jamais en erreur qui interromprait la veille —
c'est un canal secondaire, pas une condition de son fonctionnement. c'est un canal secondaire, pas une condition de son fonctionnement.
@@ -535,20 +539,61 @@ Sur la fiche d'un streamer (onglet Streamers), assigne un agent puis coche
(60 s par défaut), le serveur lance la capture sur cet agent, exactement comme le bouton (60 s par défaut), le serveur lance la capture sur cet agent, exactement comme le bouton
« Enregistrer » — surveillance des shows privés comprise. « Enregistrer » — surveillance des shows privés comprise.
Trois règles cadrent l'automatisme : Plusieurs profils peuvent être en automatisme sur la même VM ; c'est la
[priorité](#priorité-et-interruption) qui les départage. **Par défaut la capture en place
l'emporte** : un profil qui trouve sa VM occupée passe son tour sans rien écraser.
- **Un streamer par VM.** Activer l'automatisme sur un agent déjà pris est refusé, avec le **Trois tentatives par diffusion.** Les échecs restants une fois les garde-fous passés (OBS
nom du profil qui l'occupe. injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme abandonne
- **Aucune préemption.** Si la VM enregistre déjà quoi que ce soit, l'automatisme passe son jusqu'à la diffusion suivante et le dit dans le journal. Passer son tour faute de VM libre
tour plutôt que d'écraser la capture en cours. n'en consomme aucune — sinon un profil bloqué une heure aurait épuisé ses essais avant même
- **Trois tentatives par diffusion.** Les échecs restants une fois ces garde-fous passés que la machine ne se libère.
(OBS injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme
abandonne jusqu'à la diffusion suivante et le dit dans le journal.
Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort d'un show privé Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort d'un show privé
repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la
première lecture produirait des fichiers de dix secondes. première lecture produirait des fichiers de dix secondes.
## Priorité et interruption
Une VM ne capture qu'un flux à la fois, mais rien n'empêche d'y assigner plusieurs profils.
Deux réglages, sur la fiche d'un streamer, disent qui l'emporte.
**Priorité***Basse, Normale (défaut), Haute, Critique*. L'échelle est globale pour rester
comparable partout, mais **elle ne tranche qu'entre profils assignés au même agent** : deux
VM distinctes ne se disputent rien, et un classement global mêlerait des profils qui ne se
croiseront jamais. La fiche affiche donc l'ordre qui compte — celui de la VM :
« Ordre sur vm-rec-01 : 1. alice (Haute) ⚡ · 2. bob (Normale) ».
**Peut interrompre un enregistrement en cours** — décoché par défaut. C'est l'autorisation
de couper une capture de rang inférieur pour prendre sa place.
Ce droit est délibérément **séparé de la priorité, et non déduit d'elle** : interrompre
détruit la fin d'un fichier. Cela ne doit pas découler d'un simple changement de rang, mais
d'un choix explicite, profil par profil.
L'interruption n'a lieu que si les trois conditions sont réunies :
1. le profil candidat a le droit d'interrompre ;
2. sa priorité est **strictement** supérieure — à rang égal, la capture en place reste, ce
qui rend les égalités inoffensives ;
3. la capture en cours est **identifiable**, c'est-à-dire rattachée à un profil suivi. Une
capture lancée à la main depuis l'onglet Agents n'est jamais coupée : on ignore ce
qu'elle vaut, et il n'y a aucune priorité à lui comparer.
La capture évincée est **close proprement** — commande d'arrêt, puis attente de la
confirmation de l'agent avant de relancer — et non écrasée : son fichier reste. Le journal
en garde une trace en avertissement : « Priorité : « bob » interrompu au profit de
« alice » sur vm-rec-01 ».
La règle vaut aussi pour le bouton **Enregistrer** : quand elle s'applique, il annonce ce
qu'il fera (« Enregistrer (interrompt bob) ») plutôt que de le faire découvrir par un échec ;
sinon il reste désactivé, avec le motif du refus en infobulle.
Enfin, la veille sonde les profils **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é — même résultat, au prix d'un fichier de quelques secondes.
## Frise des diffusions ## Frise des diffusions
Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, onglet Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, onglet
@@ -674,7 +719,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
| `GET` | `/api/agents` | Liste des agents et de leur état | | `GET` | `/api/agents` | Liste des agents et de leur état |
| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** | | `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** |
| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset | | `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset |
| `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme, favori | | `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme, favori, priorité, interruption |
| `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils | | `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils |
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) | | `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
| `DELETE` | `/api/agents/:id` | Supprime l'agent | | `DELETE` | `/api/agents/:id` | Supprime l'agent |

View File

@@ -236,7 +236,8 @@ api.post('/watchlist', (req, res) => {
username, username,
label: typeof req.body?.label === 'string' && req.body.label.trim() ? req.body.label.trim() : null, 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, 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é`); hub.log(null, 'info', `Veille : « ${username} » ajouté`);
@@ -266,20 +267,12 @@ api.patch('/watchlist/:id', (req, res) => {
return; return;
} }
// Un streamer par VM : deux automatismes sur le même agent se disputeraient la // Plusieurs automatismes par VM sont désormais permis : c'est la priorité qui
// machine, et le second échouerait systématiquement sur « déjà en // arbitre. Une version antérieure refusait le second en 409, faute de règle
// enregistrement ». Autant le refuser ici, où l'on peut l'expliquer. // pour départager — le refus n'avait plus lieu d'être une fois cette règle
if (autoRecord && agentId) { // écrite, et l'interdire empêcherait justement d'exprimer un ordre.
const conflict = targetsRepo
.list() const priority = Number(req.body?.priority);
.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;
}
}
targetsRepo.updateSettings(target.id, { targetsRepo.updateSettings(target.id, {
label: typeof req.body?.label === 'string' ? req.body.label.trim() || null : target.label, 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, notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify,
autoRecord, autoRecord,
favorite: typeof req.body?.favorite === 'boolean' ? req.body.favorite : target.favorite, 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); const updated = targetsRepo.get(target.id);

View File

@@ -71,7 +71,9 @@ db.exec(`
username TEXT NOT NULL, username TEXT NOT NULL,
label TEXT, label TEXT,
agent_id 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', state TEXT NOT NULL DEFAULT 'unknown',
raw_status TEXT, raw_status TEXT,
state_since INTEGER NOT NULL, 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'); addColumnIfMissing('watch_targets', 'auto_record', 'INTEGER NOT NULL DEFAULT 0');
// Épinglage en tête de liste, sans autre effet. // Épinglage en tête de liste, sans autre effet.
addColumnIfMissing('watch_targets', 'favorite', 'INTEGER NOT NULL DEFAULT 0'); 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 * `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de
@@ -460,6 +469,8 @@ interface TargetRow {
notify: number; notify: number;
auto_record: number; auto_record: number;
favorite: number; favorite: number;
priority: number;
preempt: number;
state: string; state: string;
raw_status: string | null; raw_status: string | null;
state_since: number; state_since: number;
@@ -485,6 +496,8 @@ function toTarget(row: TargetRow): WatchTarget {
notify: Number(row.notify) === 1, notify: Number(row.notify) === 1,
favorite: Number(row.favorite) === 1, favorite: Number(row.favorite) === 1,
autoRecord: Number(row.auto_record) === 1, autoRecord: Number(row.auto_record) === 1,
priority: Number(row.priority),
preempt: Number(row.preempt) === 1,
state: (row.state as StreamState) ?? 'unknown', state: (row.state as StreamState) ?? 'unknown',
rawStatus: row.raw_status, rawStatus: row.raw_status,
stateSince: Number(row.state_since), stateSince: Number(row.state_since),
@@ -507,9 +520,11 @@ const targetStmts = {
state, state_since, created_at) state, state_since, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?) VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?)
`), `),
updateSettings: db.prepare( updateSettings: db.prepare(`
'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ?, auto_record = ?, favorite = ? WHERE id = ?', UPDATE watch_targets
), SET label = ?, agent_id = ?, notify = ?, auto_record = ?, favorite = ?, priority = ?, preempt = ?
WHERE id = ?
`),
updateState: db.prepare(` updateState: db.prepare(`
UPDATE watch_targets UPDATE watch_targets
SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ?, SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ?,
@@ -550,7 +565,7 @@ export const targetsRepo = {
input.username, input.username,
input.label ?? null, input.label ?? null,
input.agentId ?? null, input.agentId ?? null,
input.notify === false ? 0 : 1, input.notify === true ? 1 : 0,
now, now,
now, now,
); );
@@ -567,6 +582,8 @@ export const targetsRepo = {
notify: boolean; notify: boolean;
autoRecord: boolean; autoRecord: boolean;
favorite: boolean; favorite: boolean;
priority: number;
preempt: boolean;
}, },
): void { ): void {
targetStmts.updateSettings.run( targetStmts.updateSettings.run(
@@ -575,6 +592,8 @@ export const targetsRepo = {
settings.notify ? 1 : 0, settings.notify ? 1 : 0,
settings.autoRecord ? 1 : 0, settings.autoRecord ? 1 : 0,
settings.favorite ? 1 : 0, settings.favorite ? 1 : 0,
settings.priority,
settings.preempt ? 1 : 0,
id, id,
); );
}, },

View File

@@ -122,17 +122,24 @@ class Hub {
// Le profil n'est cherché que tant qu'il manque : une capture déjà // 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 // rattachée l'est pour de bon, inutile de reposer la question toutes les
// deux secondes. // 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 { } 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); const record = agentsRepo.get(agentId);
if (!record?.watch.username) return null; 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 { resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void {

View File

@@ -1,8 +1,19 @@
import type { WatchTarget } from '@stream-control/shared'; import type { WatchTarget } from '@stream-control/shared';
import { normalizeWatchSettings } from '@stream-control/shared'; import { normalizeWatchSettings, preemptionVerdict } from '@stream-control/shared';
import { agentsRepo } from './db.ts'; import { agentsRepo, type AgentRecord } from './db.ts';
import { hub } from './hub.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é. * 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 (!record) throw new Error("L'agent assigné n'existe plus");
if (!hub.isOnline(record.id)) throw new Error(`Agent « ${record.name} » hors-ligne`); 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 // 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é. // 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) { 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 // 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));
}

View File

@@ -1,5 +1,9 @@
import type { StreamState, WatchTarget } from '@stream-control/shared'; 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 { config } from './config.ts';
import { sessionsRepo, targetsRepo } from './db.ts'; import { sessionsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.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 if (this.running) return; // un cycle lent ne doit pas s'empiler sur le suivant
this.running = true; this.running = true;
try { 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 // Séquentiel et espacé : une poignée de profils ne justifie pas de
// marteler l'API en parallèle. // marteler l'API en parallèle.
for (const target of targets) { for (const target of targets) {
@@ -162,9 +173,16 @@ class Watchlist {
if (Date.now() - liveSince < config.autoRecordDelayMs) return; if (Date.now() - liveSince < config.autoRecordDelayMs) return;
if (!hub.isOnline(target.agentId)) 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. // VM occupée : par défaut la capture en place l'emporte, sauf si ce profil
if (hub.statusOf(target.agentId).recording) return; // 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 agentId = target.agentId;
const name = target.label ?? target.username; const name = target.label ?? target.username;

View File

@@ -884,10 +884,28 @@ export interface WatchTarget {
favorite: boolean; favorite: boolean;
/** /**
* Lancer l'enregistrement sans intervention dès que ce profil est en direct * Lancer l'enregistrement sans intervention dès que ce profil est en direct
* depuis assez longtemps. Exige un agent assigné, et un seul profil en * depuis assez longtemps. Exige un agent assigné.
* automatisme par agent : une VM n'enregistre qu'un flux à la fois. *
* Plusieurs profils peuvent l'activer sur une même VM : elle n'en capture
* qu'un à la fois, et c'est {@link WatchTarget.priority} qui arbitre.
*/ */
autoRecord: boolean; autoRecord: boolean;
/**
* Rang dans la file d'attente d'une VM, le plus élevé l'emportant.
*
* L'échelle est globale pour rester comparable partout, mais elle ne tranche
* qu'entre profils assignés au même agent : deux VM distinctes ne se
* disputent rien.
*/
priority: number;
/**
* Autorise ce profil à couper une capture de priorité inférieure sur sa VM.
*
* Séparé de la priorité, et non déduit d'elle : interrompre un
* enregistrement en cours perd la fin d'un fichier. Cela ne doit pas
* découler d'un simple changement de rang, mais d'un choix explicite.
*/
preempt: boolean;
state: StreamState; state: StreamState;
rawStatus: string | null; rawStatus: string | null;
/** Depuis quand l'état est stable, selon nos propres observations. */ /** Depuis quand l'état est stable, selon nos propres observations. */
@@ -963,6 +981,76 @@ export function recordingUsername(agent: Pick<AgentView, 'status' | 'watch'>): s
return agent.watch.username || null; return agent.watch.username || null;
} }
/**
* Rangs proposés par l'interface. La colonne reste un entier libre : affiner
* l'échelle un jour ne demandera pas de migration.
*/
export const PRIORITY_LEVELS = [
{ value: 0, label: 'Basse' },
{ value: 1, label: 'Normale' },
{ value: 2, label: 'Haute' },
{ value: 3, label: 'Critique' },
] as const;
export const DEFAULT_PRIORITY = 1;
export function priorityLabel(value: number): string {
return PRIORITY_LEVELS.find((level) => level.value === value)?.label ?? `Rang ${value}`;
}
export interface PreemptionVerdict {
allowed: boolean;
/** Pourquoi l'interruption est refusée — destiné à être affiché tel quel. */
reason?: string;
}
/**
* `candidate` a-t-il le droit de couper la capture en cours au profit de la sienne ?
*
* Trois refus, dans cet ordre, du plus prudent au plus attendu :
*
* 1. **Capture non identifiée.** Si l'on ne sait pas quel profil la VM
* enregistre, on ne la coupe pas : le risque est de détruire une capture
* dont on ignore la valeur, et l'on n'a de toute façon aucune priorité à
* lui comparer.
* 2. **Interruption non autorisée** sur le profil candidat.
* 3. **Priorité insuffisante** — strictement supérieure exigée, ce qui rend
* les égalités inoffensives : à rang égal, la capture en place l'emporte.
*/
export function preemptionVerdict(
candidate: Pick<WatchTarget, 'id' | 'priority' | 'preempt'>,
current: Pick<WatchTarget, 'id' | 'username' | 'label' | 'priority'> | null,
): PreemptionVerdict {
if (!current) {
return {
allowed: false,
reason: "la capture en cours n'a pas pu être rattachée à un profil suivi",
};
}
// Rien à interrompre : c'est déjà ce profil qui est capturé. Refusé plutôt
// qu'autorisé — l'appelant s'apprêtait à lancer une seconde capture.
if (current.id === candidate.id) {
return { allowed: false, reason: 'sa capture est déjà en cours sur cette VM' };
}
const name = current.label ?? current.username;
if (!candidate.preempt) {
return {
allowed: false,
reason: `« ${name} » est en cours de capture et l'interruption n'est pas autorisée sur ce profil`,
};
}
if (candidate.priority <= current.priority) {
return {
allowed: false,
reason:
`« ${name} » est en cours de capture avec une priorité au moins égale ` +
`(${priorityLabel(current.priority)})`,
};
}
return { allowed: true };
}
/** Cette VM enregistre-t-elle bien *ce* profil, et non un autre qui lui est assigné ? */ /** Cette VM enregistre-t-elle bien *ce* profil, et non un autre qui lui est assigné ? */
export function isRecordingTarget( export function isRecordingTarget(
agent: Pick<AgentView, 'status' | 'watch'> | null | undefined, agent: Pick<AgentView, 'status' | 'watch'> | null | undefined,

View File

@@ -121,7 +121,12 @@ export const api = {
updateTarget: ( updateTarget: (
id: string, id: string,
body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite'>>, body: Partial<
Pick<
WatchTarget,
'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite' | 'priority' | 'preempt'
>
>,
) => ) =>
request<{ target: WatchTarget }>(`/watchlist/${id}`, { request<{ target: WatchTarget }>(`/watchlist/${id}`, {
method: 'PATCH', method: 'PATCH',

View File

@@ -1,8 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared'; import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared';
import { PRIORITY_LEVELS, priorityLabel } from '@stream-control/shared';
import { api } from '../api'; import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format'; import { formatDateTime, formatDuration, formatRelative } from '../format';
import { autoRecordHint, recordingContext, stateLabel, TIMELINE_RANGES } from '../streamers'; import {
agentQueue,
autoRecordHint,
preemption,
recordingContext,
stateLabel,
TIMELINE_RANGES,
} from '../streamers';
import { Timeline } from './Timeline'; import { Timeline } from './Timeline';
interface Props { interface Props {
@@ -33,6 +41,8 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null; const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const context = recordingContext(target, agent, targets); const context = recordingContext(target, agent, targets);
const verdict = preemption(target, context);
const queue = target.agentId ? agentQueue(target.agentId, targets) : [];
const state = stateLabel(target); const state = stateLabel(target);
const isLive = target.state === 'public'; const isLive = target.state === 'public';
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null; const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
@@ -210,10 +220,62 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
</span> </span>
</label> </label>
<label className="field">
<span>Priorité</span>
<select
value={target.priority}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { priority: Number(event.target.value) }))
}
>
{PRIORITY_LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={target.preempt}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { preempt: event.target.checked }))
}
/>
<span>
Peut interrompre un enregistrement en cours
<span className="muted small">
{' — '}
uniquement face à une priorité strictement inférieure sur la même VM. La capture
interrompue est close proprement, son fichier reste.
</span>
</span>
</label>
{queue.length > 1 && (
<p className="muted small queue-order">
Ordre sur {agent?.name} :{' '}
{queue.map((entry, index) => (
<span key={entry.id} className={entry.id === target.id ? 'queue-self' : undefined}>
{index > 0 && ' · '}
{index + 1}. {entry.label ?? entry.username} ({priorityLabel(entry.priority)})
{entry.preempt ? ' ⚡' : ''}
</span>
))}
</p>
)}
{context.busyWith && ( {context.busyWith && (
<p className="muted small"> <p className="muted small">
ⓘ {agent?.name} enregistre actuellement {context.busyWith} — ce profil n'est pas ⓘ {agent?.name} enregistre actuellement {context.busyWith} — ce profil n'est pas
capturé. Une VM ne traite qu'un flux à la fois. capturé.{' '}
{verdict?.allowed
? 'Sa priorité lui permet de reprendre la VM.'
: `Il n'en reprendra pas la main : ${verdict?.reason}.`}
</p> </p>
)} )}
@@ -229,10 +291,11 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
) : ( ) : (
<button <button
className="primary" className="primary"
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)} disabled={busy || !agent?.online || !isLive || verdict?.allowed === false}
title={verdict?.allowed === false ? verdict.reason : undefined}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')} onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
> >
● Enregistrer {verdict?.allowed ? `● Enregistrer (interrompt ${context.busyWith})` : '● Enregistrer'}
</button> </button>
)} )}
<button <button

View File

@@ -1,8 +1,9 @@
import { useMemo, useState, type FormEvent } from 'react'; import { useMemo, useState, type FormEvent } from 'react';
import type { AgentView, WatchTarget } from '@stream-control/shared'; import type { AgentView, PreemptionVerdict, WatchTarget } from '@stream-control/shared';
import { DEFAULT_PRIORITY, priorityLabel } from '@stream-control/shared';
import { api } from '../api'; import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format'; import { formatDateTime, formatDuration, formatRelative } from '../format';
import { recordingContext, stateLabel, type RecordingContext } from '../streamers'; import { preemption, recordingContext, stateLabel, type RecordingContext } from '../streamers';
import { StreamerDetail } from './StreamerDetail'; import { StreamerDetail } from './StreamerDetail';
interface Props { interface Props {
@@ -49,9 +50,9 @@ export function StreamersPage({
} }
/** /**
* En direct d'abord, puis show privé, puis le reste ; à statut égal, les * En direct d'abord, puis show privé, puis le reste ; à statut égal les
* favoris devant. L'ordre du statut prime : un direct en cours appelle une * favoris devant, puis la priorité. L'ordre du statut prime : un direct en
* décision, un favori hors-ligne non. * cours appelle une décision, un favori hors-ligne non.
*/ */
const ordered = useMemo( const ordered = useMemo(
() => () =>
@@ -61,6 +62,7 @@ export function StreamersPage({
return ( return (
rank(a) - rank(b) || rank(a) - rank(b) ||
Number(b.favorite) - Number(a.favorite) || Number(b.favorite) - Number(a.favorite) ||
b.priority - a.priority ||
a.username.localeCompare(b.username) a.username.localeCompare(b.username)
); );
}), }),
@@ -174,6 +176,7 @@ function StreamerCard({
const state = stateLabel(target); const state = stateLabel(target);
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null; const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const context = recordingContext(target, agent, targets); const context = recordingContext(target, agent, targets);
const verdict = preemption(target, context);
const isLive = target.state === 'public'; const isLive = target.state === 'public';
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null; const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
@@ -293,17 +296,23 @@ function StreamerCard({
) : ( ) : (
<button <button
className="primary" className="primary"
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)} disabled={busy || !agent?.online || !isLive || verdict?.allowed === false}
title={recordTitle(target, agent, context)} title={recordTitle(target, agent, context, verdict)}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')} onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
> >
Enregistrer {verdict?.allowed ? '● Enregistrer ⚡' : '● Enregistrer'}
</button> </button>
)} )}
<div className="spacer" /> <div className="spacer" />
<span className="muted small">{agent ? agent.name : 'aucun agent'}</span> <span className="muted small">
{agent ? agent.name : 'aucun agent'}
{/* Le rang par défaut resterait du bruit : il ne dit rien de plus que
l'absence de réglage. */}
{target.priority !== DEFAULT_PRIORITY && ` · ${priorityLabel(target.priority)}`}
{target.preempt && <span title="Peut interrompre une capture de rang inférieur"> </span>}
</span>
</div> </div>
<footer className="card-foot muted small"> <footer className="card-foot muted small">
@@ -326,12 +335,14 @@ function recordTitle(
target: WatchTarget, target: WatchTarget,
agent: AgentView | null, agent: AgentView | null,
context: RecordingContext, context: RecordingContext,
verdict: PreemptionVerdict | null,
): string { ): string {
if (!target.agentId) return 'Assigne un agent à ce streamer (clique la fiche)'; if (!target.agentId) return 'Assigne un agent à ce streamer (clique la fiche)';
if (!agent?.online) return 'Agent hors-ligne'; if (!agent?.online) return 'Agent hors-ligne';
if (context.busyWith) { if (verdict?.allowed) {
return `${agent.name} enregistre déjà ${context.busyWith} — une VM ne traite qu'un flux à la fois`; return `Interrompt la capture de ${context.busyWith} sur ${agent.name} — priorité supérieure`;
} }
if (verdict) return `${agent.name} déjà pris : ${verdict.reason}`;
if (target.state !== 'public') return "Le streamer n'est pas en direct"; if (target.state !== 'public') return "Le streamer n'est pas en direct";
return agent.browser.enabled return agent.browser.enabled
? "Ouvrir la page, passer en plein écran et lancer l'enregistrement" ? "Ouvrir la page, passer en plein écran et lancer l'enregistrement"

View File

@@ -1,5 +1,10 @@
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared'; import type {
import { isRecordingTarget } from '@stream-control/shared'; AgentView,
PreemptionVerdict,
StreamState,
WatchTarget,
} from '@stream-control/shared';
import { isRecordingTarget, preemptionVerdict } from '@stream-control/shared';
/** Logique d'affichage partagée entre la fiche compacte et la fiche détaillée. */ /** Logique d'affichage partagée entre la fiche compacte et la fiche détaillée. */
@@ -36,6 +41,8 @@ export interface RecordingContext {
self: boolean; self: boolean;
/** La VM capture, mais autre chose : ce que l'on peut en nommer. */ /** La VM capture, mais autre chose : ce que l'on peut en nommer. */
busyWith: string | null; busyWith: string | null;
/** Le profil suivi que la VM capture, quand il est identifiable. */
current: WatchTarget | null;
} }
export function recordingContext( export function recordingContext(
@@ -43,17 +50,50 @@ export function recordingContext(
agent: AgentView | null, agent: AgentView | null,
targets: WatchTarget[], targets: WatchTarget[],
): RecordingContext { ): RecordingContext {
if (!agent?.status.recording) return { self: false, busyWith: null }; if (!agent?.status.recording) return { self: false, busyWith: null, current: null };
if (isRecordingTarget(agent, target)) return { self: true, busyWith: null }; if (isRecordingTarget(agent, target)) return { self: true, busyWith: null, current: target };
// La VM enregistre sans que sa veille soit calée sur un pseudo : capture // La VM enregistre sans que sa veille soit calée sur un pseudo : capture
// lancée à la main depuis l'onglet Agents, le plus souvent. On ne peut pas // lancée à la main depuis l'onglet Agents, le plus souvent. On ne peut pas
// dire quoi, mais surtout pas laisser croire que c'est ce profil. // dire quoi, mais surtout pas laisser croire que c'est ce profil.
const username = agent.watch.username; const username = agent.watch.username;
if (!username) return { self: false, busyWith: 'une source non identifiée' }; if (!username) {
return { self: false, busyWith: 'une source non identifiée', current: null };
}
const other = targets.find((candidate) => candidate.username === username); const other = targets.find((candidate) => candidate.username === username) ?? null;
return { self: false, busyWith: other ? (other.label ?? other.username) : `@${username}` }; return {
self: false,
busyWith: other ? (other.label ?? other.username) : `@${username}`,
current: other,
};
}
/**
* Verdict d'interruption pour ce profil, ou `null` s'il n'y a rien à couper.
*
* Même règle que côté serveur, appelée depuis le paquet partagé : le bouton
* doit annoncer ce qui va se passer — « interrompt Bob » — plutôt que de le
* faire découvrir par un échec.
*/
export function preemption(
target: WatchTarget,
context: RecordingContext,
): PreemptionVerdict | null {
if (!context.busyWith) return null;
return preemptionVerdict(target, context.current);
}
/**
* Les profils en automatisme sur une VM, du plus prioritaire au moins.
*
* C'est la seule file qui compte : deux VM distinctes ne se disputent rien, et
* un classement global mêlerait des profils qui ne se croiseront jamais.
*/
export function agentQueue(agentId: string, targets: WatchTarget[]): WatchTarget[] {
return targets
.filter((target) => target.agentId === agentId && target.autoRecord)
.sort((a, b) => b.priority - a.priority || a.username.localeCompare(b.username));
} }
/** /**
@@ -70,7 +110,13 @@ export function autoRecordHint(
if (!agent) return ' — agent introuvable'; if (!agent) return ' — agent introuvable';
if (!agent.online) return ` — en attente : ${agent.name} est hors-ligne`; if (!agent.online) return ` — en attente : ${agent.name} est hors-ligne`;
if (context.self) return ' — enregistrement en cours'; if (context.self) return ' — enregistrement en cours';
if (context.busyWith) return ` — en attente : ${agent.name} enregistre ${context.busyWith}`;
if (context.busyWith) {
return preemption(target, context)?.allowed
? ` — prioritaire : interrompra ${context.busyWith}`
: ` — en attente : ${agent.name} enregistre ${context.busyWith}`;
}
if (target.state === 'public') return ' — armé, direct en cours'; if (target.state === 'public') return ' — armé, direct en cours';
return ' — armé, en attente du prochain direct'; return ' — armé, en attente du prochain direct';
} }

View File

@@ -734,6 +734,18 @@ fieldset.group > legend {
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
} }
.queue-order {
margin: 0;
padding: 8px 10px;
background: var(--panel-2);
border-radius: 8px;
line-height: 1.7;
}
.queue-self {
color: var(--text);
font-weight: 600;
}
.session-list { .session-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;