Feat : Prio order for auto stream record
This commit is contained in:
67
README.md
67
README.md
@@ -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
|
||||
ne découpe pas le fichier, une vraie fin de diffusion le termine.
|
||||
- [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,
|
||||
réglables VM par VM : de « Économe » à « Sans perte ».
|
||||
- [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.
|
||||
|
||||
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 —
|
||||
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
|
||||
« 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
|
||||
nom du profil qui l'occupe.
|
||||
- **Aucune préemption.** Si la VM enregistre déjà quoi que ce soit, l'automatisme passe son
|
||||
tour plutôt que d'écraser la capture en cours.
|
||||
- **Trois tentatives par diffusion.** Les échecs restants une fois ces garde-fous passés
|
||||
(OBS injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme
|
||||
abandonne jusqu'à la diffusion suivante et le dit dans le journal.
|
||||
**Trois tentatives par diffusion.** Les échecs restants une fois les garde-fous passés (OBS
|
||||
injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme abandonne
|
||||
jusqu'à la diffusion suivante et le dit dans le journal. Passer son tour faute de VM libre
|
||||
n'en consomme aucune — sinon un profil bloqué une heure aurait épuisé ses essais avant même
|
||||
que la machine ne se libère.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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 |
|
||||
| `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/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 |
|
||||
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
|
||||
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -884,10 +884,28 @@ export interface WatchTarget {
|
||||
favorite: boolean;
|
||||
/**
|
||||
* 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
|
||||
* automatisme par agent : une VM n'enregistre qu'un flux à la fois.
|
||||
* depuis assez longtemps. Exige un agent assigné.
|
||||
*
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
rawStatus: string | null;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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é ? */
|
||||
export function isRecordingTarget(
|
||||
agent: Pick<AgentView, 'status' | 'watch'> | null | undefined,
|
||||
|
||||
@@ -121,7 +121,12 @@ export const api = {
|
||||
|
||||
updateTarget: (
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared';
|
||||
import { PRIORITY_LEVELS, priorityLabel } from '@stream-control/shared';
|
||||
import { api } from '../api';
|
||||
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';
|
||||
|
||||
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 context = recordingContext(target, agent, targets);
|
||||
const verdict = preemption(target, context);
|
||||
const queue = target.agentId ? agentQueue(target.agentId, targets) : [];
|
||||
const state = stateLabel(target);
|
||||
const isLive = target.state === 'public';
|
||||
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
|
||||
@@ -210,10 +220,62 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
|
||||
</span>
|
||||
</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 && (
|
||||
<p className="muted small">
|
||||
ⓘ {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>
|
||||
)}
|
||||
|
||||
@@ -229,10 +291,11 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
|
||||
) : (
|
||||
<button
|
||||
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é')}
|
||||
>
|
||||
● Enregistrer
|
||||
{verdict?.allowed ? `● Enregistrer (interrompt ${context.busyWith})` : '● Enregistrer'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { formatDateTime, formatDuration, formatRelative } from '../format';
|
||||
import { recordingContext, stateLabel, type RecordingContext } from '../streamers';
|
||||
import { preemption, recordingContext, stateLabel, type RecordingContext } from '../streamers';
|
||||
import { StreamerDetail } from './StreamerDetail';
|
||||
|
||||
interface Props {
|
||||
@@ -49,9 +50,9 @@ export function StreamersPage({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* décision, un favori hors-ligne non.
|
||||
* En direct d'abord, puis show privé, puis le reste ; à statut égal les
|
||||
* favoris devant, puis la priorité. L'ordre du statut prime : un direct en
|
||||
* cours appelle une décision, un favori hors-ligne non.
|
||||
*/
|
||||
const ordered = useMemo(
|
||||
() =>
|
||||
@@ -61,6 +62,7 @@ export function StreamersPage({
|
||||
return (
|
||||
rank(a) - rank(b) ||
|
||||
Number(b.favorite) - Number(a.favorite) ||
|
||||
b.priority - a.priority ||
|
||||
a.username.localeCompare(b.username)
|
||||
);
|
||||
}),
|
||||
@@ -174,6 +176,7 @@ function StreamerCard({
|
||||
const state = stateLabel(target);
|
||||
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
|
||||
const context = recordingContext(target, agent, targets);
|
||||
const verdict = preemption(target, context);
|
||||
const isLive = target.state === 'public';
|
||||
|
||||
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
|
||||
@@ -293,17 +296,23 @@ function StreamerCard({
|
||||
) : (
|
||||
<button
|
||||
className="primary"
|
||||
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)}
|
||||
title={recordTitle(target, agent, context)}
|
||||
disabled={busy || !agent?.online || !isLive || verdict?.allowed === false}
|
||||
title={recordTitle(target, agent, context, verdict)}
|
||||
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
|
||||
>
|
||||
● Enregistrer
|
||||
{verdict?.allowed ? '● Enregistrer ⚡' : '● Enregistrer'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<footer className="card-foot muted small">
|
||||
@@ -326,12 +335,14 @@ function recordTitle(
|
||||
target: WatchTarget,
|
||||
agent: AgentView | null,
|
||||
context: RecordingContext,
|
||||
verdict: PreemptionVerdict | null,
|
||||
): string {
|
||||
if (!target.agentId) return 'Assigne un agent à ce streamer (clique la fiche)';
|
||||
if (!agent?.online) return 'Agent hors-ligne';
|
||||
if (context.busyWith) {
|
||||
return `${agent.name} enregistre déjà ${context.busyWith} — une VM ne traite qu'un flux à la fois`;
|
||||
if (verdict?.allowed) {
|
||||
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";
|
||||
return agent.browser.enabled
|
||||
? "Ouvrir la page, passer en plein écran et lancer l'enregistrement"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
|
||||
import { isRecordingTarget } from '@stream-control/shared';
|
||||
import type {
|
||||
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. */
|
||||
|
||||
@@ -36,6 +41,8 @@ export interface RecordingContext {
|
||||
self: boolean;
|
||||
/** La VM capture, mais autre chose : ce que l'on peut en nommer. */
|
||||
busyWith: string | null;
|
||||
/** Le profil suivi que la VM capture, quand il est identifiable. */
|
||||
current: WatchTarget | null;
|
||||
}
|
||||
|
||||
export function recordingContext(
|
||||
@@ -43,17 +50,50 @@ export function recordingContext(
|
||||
agent: AgentView | null,
|
||||
targets: WatchTarget[],
|
||||
): RecordingContext {
|
||||
if (!agent?.status.recording) return { self: false, busyWith: null };
|
||||
if (isRecordingTarget(agent, target)) return { self: true, busyWith: null };
|
||||
if (!agent?.status.recording) return { self: false, busyWith: null, current: 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
|
||||
// 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.
|
||||
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);
|
||||
return { self: false, busyWith: other ? (other.label ?? other.username) : `@${username}` };
|
||||
const other = targets.find((candidate) => candidate.username === username) ?? null;
|
||||
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.online) return ` — en attente : ${agent.name} est hors-ligne`;
|
||||
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';
|
||||
return ' — armé, en attente du prochain direct';
|
||||
}
|
||||
|
||||
@@ -734,6 +734,18 @@ fieldset.group > legend {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user