Feat : pause now showing in another color
This commit is contained in:
@@ -14,7 +14,15 @@ import {
|
||||
} from '@stream-control/shared';
|
||||
import { config } from './config.ts';
|
||||
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
|
||||
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, tagsRepo, targetsRepo } from './db.ts';
|
||||
import {
|
||||
agentsRepo,
|
||||
logsRepo,
|
||||
pausesRepo,
|
||||
sessionsRepo,
|
||||
spansRepo,
|
||||
tagsRepo,
|
||||
targetsRepo,
|
||||
} from './db.ts';
|
||||
import { hub } from './hub.ts';
|
||||
import { previewFileFor } from './previews.ts';
|
||||
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
|
||||
@@ -463,6 +471,7 @@ api.get('/timeline', (req, res) => {
|
||||
res.json({
|
||||
sessions: sessionsRepo.since(from, targetId),
|
||||
spans: spansRepo.since(from, targetId),
|
||||
pauses: pausesRepo.since(from, targetId),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
LogLevel,
|
||||
ObsSettings,
|
||||
Platform,
|
||||
RecordingPause,
|
||||
RecordingSettings,
|
||||
RecordingSpan,
|
||||
StreamSession,
|
||||
@@ -115,6 +116,21 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_spans_started ON recording_spans (started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_spans_open ON recording_spans (agent_id) WHERE ended_at IS NULL;
|
||||
|
||||
-- Passages en pause d'une capture, en surimpression de l'intervalle qui les
|
||||
-- porte. Table à part plutôt qu'un découpage des intervalles : une capture
|
||||
-- mise en pause reste une seule capture, un seul fichier — la scinder en
|
||||
-- ferait passer les morceaux pour des enregistrements distincts. Le CASCADE
|
||||
-- suit celui des intervalles, eux-mêmes rattachés à un profil.
|
||||
CREATE TABLE IF NOT EXISTS recording_pauses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
span_id INTEGER NOT NULL REFERENCES recording_spans (id) ON DELETE CASCADE,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pauses_span ON recording_pauses (span_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pauses_started ON recording_pauses (started_at DESC);
|
||||
|
||||
-- Étiquettes libres, posables sur autant de profils qu'on veut. Table à part
|
||||
-- plutôt qu'un texte répété sur chaque profil : c'est ce qui rend le renommage
|
||||
-- possible d'un geste, et garde une étiquette disponible même quand plus aucun
|
||||
@@ -946,8 +962,9 @@ export const spansRepo = {
|
||||
return row ? toSpan(row) : null;
|
||||
},
|
||||
|
||||
open(agentId: string, targetId: string | null, at: number): void {
|
||||
spanStmts.insert.run(targetId, agentId, at, at);
|
||||
/** Renvoie l'identifiant du nouvel intervalle : les pauses s'y rattachent. */
|
||||
open(agentId: string, targetId: string | null, at: number): number {
|
||||
return Number(spanStmts.insert.run(targetId, agentId, at, at).lastInsertRowid);
|
||||
},
|
||||
|
||||
touch(id: number, at: number, targetId: string | null): void {
|
||||
@@ -976,6 +993,86 @@ export const spansRepo = {
|
||||
},
|
||||
};
|
||||
|
||||
// --- Pauses de capture --------------------------------------------------------
|
||||
|
||||
interface PauseRow {
|
||||
id: number;
|
||||
span_id: number;
|
||||
target_id: string | null;
|
||||
started_at: number;
|
||||
ended_at: number | null;
|
||||
}
|
||||
|
||||
const toPause = (row: PauseRow): RecordingPause => ({
|
||||
id: Number(row.id),
|
||||
spanId: Number(row.span_id),
|
||||
targetId: row.target_id,
|
||||
startedAt: Number(row.started_at),
|
||||
endedAt: num(row.ended_at),
|
||||
});
|
||||
|
||||
const pauseStmts = {
|
||||
openFor: db.prepare(
|
||||
'SELECT id FROM recording_pauses WHERE span_id = ? AND ended_at IS NULL ORDER BY started_at DESC',
|
||||
),
|
||||
insert: db.prepare('INSERT INTO recording_pauses (span_id, started_at) VALUES (?, ?)'),
|
||||
// MAX() borne la fin au début, comme pour les intervalles : une durée négative
|
||||
// rendrait la frise illisible.
|
||||
close: db.prepare(`
|
||||
UPDATE recording_pauses SET ended_at = MAX(started_at, ?)
|
||||
WHERE span_id = ? AND ended_at IS NULL
|
||||
`),
|
||||
/**
|
||||
* Aucune pause ne survit à l'intervalle qui la porte. Répare l'invariant après
|
||||
* coup plutôt que de le supposer : un intervalle refermé par le balayage
|
||||
* périodique (VM disparue en pleine capture) l'est sans passer par le chemin
|
||||
* qui referme les pauses.
|
||||
*/
|
||||
sweep: db.prepare(`
|
||||
UPDATE recording_pauses
|
||||
SET ended_at = (SELECT ended_at FROM recording_spans WHERE id = span_id)
|
||||
WHERE ended_at IS NULL
|
||||
AND (SELECT ended_at FROM recording_spans WHERE id = span_id) IS NOT NULL
|
||||
`),
|
||||
// Le rattachement au profil vient de l'intervalle : c'est lui qui le porte, et
|
||||
// le dupliquer sur la pause le ferait diverger dès qu'une capture est
|
||||
// rattachée après coup.
|
||||
since: db.prepare(`
|
||||
SELECT p.*, s.target_id FROM recording_pauses p
|
||||
JOIN recording_spans s ON s.id = p.span_id
|
||||
WHERE p.started_at >= ? ORDER BY p.started_at
|
||||
`),
|
||||
sinceForTarget: db.prepare(`
|
||||
SELECT p.*, s.target_id FROM recording_pauses p
|
||||
JOIN recording_spans s ON s.id = p.span_id
|
||||
WHERE s.target_id = ? AND p.started_at >= ? ORDER BY p.started_at
|
||||
`),
|
||||
};
|
||||
|
||||
export const pausesRepo = {
|
||||
/** Ouvre une pause — sans effet s'il en reste une ouverte sur cet intervalle. */
|
||||
open(spanId: number, at: number): void {
|
||||
if (pauseStmts.openFor.get(spanId)) return;
|
||||
pauseStmts.insert.run(spanId, at);
|
||||
},
|
||||
|
||||
/** Referme la pause en cours de cet intervalle, s'il y en a une. */
|
||||
close(spanId: number, at: number): void {
|
||||
pauseStmts.close.run(at, spanId);
|
||||
},
|
||||
|
||||
sweepOrphans(): void {
|
||||
pauseStmts.sweep.run();
|
||||
},
|
||||
|
||||
since(from: number, targetId?: string | null): RecordingPause[] {
|
||||
const rows = (
|
||||
targetId ? pauseStmts.sinceForTarget.all(targetId, from) : pauseStmts.since.all(from)
|
||||
) as unknown as PauseRow[];
|
||||
return rows.map(toPause);
|
||||
},
|
||||
};
|
||||
|
||||
interface LogRow {
|
||||
id: number;
|
||||
agent_id: string | null;
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
} from '@stream-control/shared';
|
||||
import { emptyStatus } from '@stream-control/shared';
|
||||
import { config } from './config.ts';
|
||||
import { agentsRepo, logsRepo, spansRepo, targetsRepo, type AgentRecord } from './db.ts';
|
||||
import { agentsRepo, logsRepo, pausesRepo, spansRepo, targetsRepo, type AgentRecord } from './db.ts';
|
||||
|
||||
/**
|
||||
* Au-delà de ce silence, une capture en cours est considérée comme terminée.
|
||||
@@ -102,19 +102,25 @@ class Hub {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tient à jour l'intervalle de capture en cours de cette VM.
|
||||
* Tient à jour l'intervalle de capture en cours de cette VM, et ses pauses.
|
||||
*
|
||||
* Une pause OBS (show privé) n'interrompt pas l'intervalle : elle n'écrit
|
||||
* rien mais ne clôt pas le fichier, et le flux public s'est de toute façon
|
||||
* arrêté pendant ce temps — la frise le montre déjà comme une coupure de
|
||||
* diffusion. Découper ici laisserait croire à deux captures distinctes.
|
||||
* Une pause OBS (show privé) n'interrompt pas l'intervalle : elle n'écrit rien
|
||||
* mais ne clôt pas le fichier. Découper ici laisserait croire à deux captures
|
||||
* distinctes, alors qu'il n'y en a qu'une, et qu'un seul fichier. Elle est donc
|
||||
* relevée à part, en surimpression — ce qui permet à la frise de distinguer le
|
||||
* temps réellement écrit du temps passé à tourner à vide.
|
||||
*/
|
||||
private trackRecording(agentId: string, status: AgentStatus): void {
|
||||
const now = Date.now();
|
||||
const open = spansRepo.openForAgent(agentId);
|
||||
|
||||
if (!status.recording) {
|
||||
if (open) spansRepo.close(open.id, now);
|
||||
if (open) {
|
||||
// Dans cet ordre : la pause ne doit pas survivre à l'intervalle qui la
|
||||
// porte, et sa fin est celle de la capture.
|
||||
pausesRepo.close(open.id, now);
|
||||
spansRepo.close(open.id, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,9 +129,17 @@ class Hub {
|
||||
// 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.recordingTarget(agentId)?.id ?? null);
|
||||
} else {
|
||||
spansRepo.open(agentId, this.recordingTarget(agentId)?.id ?? null, now);
|
||||
// Les deux appels sont idempotents : appelés à chaque cycle de statut, ils
|
||||
// n'ouvrent et ne referment qu'aux transitions.
|
||||
if (status.recordPaused) pausesRepo.open(open.id, now);
|
||||
else pausesRepo.close(open.id, now);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture découverte déjà en pause : rare, mais possible si l'agent se
|
||||
// connecte au milieu d'un show privé.
|
||||
const spanId = spansRepo.open(agentId, this.recordingTarget(agentId)?.id ?? null, now);
|
||||
if (status.recordPaused) pausesRepo.open(spanId, now);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,6 +342,10 @@ class Hub {
|
||||
// pas à maintenant — sans quoi une VM éteinte une semaine passerait pour
|
||||
// avoir enregistré une semaine.
|
||||
spansRepo.sweepStale(Date.now() - SPAN_STALE_MS);
|
||||
// Ce balayage referme des intervalles sans passer par le chemin qui referme
|
||||
// les pauses : une capture disparue en plein show privé laisserait sinon
|
||||
// une pause ouverte pour toujours, donc une frise jaune jusqu'à maintenant.
|
||||
pausesRepo.sweepOrphans();
|
||||
|
||||
const deadline = Date.now() - config.agentTimeoutMs;
|
||||
for (const [agentId, connection] of this.connections) {
|
||||
|
||||
Reference in New Issue
Block a user