From b45ad4bca3c4fe0e23d07177f42c25450111794f Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Thu, 13 Aug 2026 14:42:14 -0400 Subject: [PATCH] Feat : pause now showing in another color --- README.md | 31 ++++-- packages/server/src/api.ts | 11 +- packages/server/src/db.ts | 101 +++++++++++++++++- packages/server/src/hub.ts | 36 +++++-- packages/shared/src/index.ts | 22 ++++ .../web/src/components/StreamerDetail.tsx | 11 +- packages/web/src/components/Timeline.tsx | 24 ++++- packages/web/src/components/TimelinePage.tsx | 16 ++- packages/web/src/styles.css | 21 ++++ 9 files changed, 244 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0d173fb..f886c8a 100644 --- a/README.md +++ b/README.md @@ -813,8 +813,9 @@ Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, **Streamers**) montre la sienne ; l'onglet **Timeline** les empile toutes, une voie par profil. -Les diffusions publiques s'y lisent en bleu, et **les portions réellement capturées se -superposent en rouge**, sur la même piste. C'est le point de la vue : ce qui intéresse n'est +Les diffusions publiques s'y lisent en bleu, **les portions capturées se superposent en +rouge**, et **les passages en pause en jaune** par-dessus le rouge — la capture tournait, mais +n'écrivait rien. Le tout sur la même piste. C'est le point de la vue : ce qui intéresse n'est pas « quand ai-je enregistré » dans l'absolu mais quelle part de chaque diffusion a été gardée — deux pistes séparées obligeraient l'œil à faire l'alignement lui-même. La vue globale rend visibles les chevauchements : deux streamers en direct en même temps sur une @@ -854,12 +855,26 @@ une fois écrit, il est servi avec un cache immuable. | Table | Écrite par | Une ligne = | | --- | --- | --- | | `stream_sessions` | la veille, sur transition d'état | une diffusion publique observée | -| `recording_spans` | le hub, sur changement de statut d'un agent | un intervalle réellement capturé | +| `recording_spans` | le hub, sur changement de statut d'un agent | un intervalle de capture ouvert | +| `recording_pauses` | le hub, sur changement de statut d'un agent | une pause à l'intérieur d'un intervalle | -Deux tables et non une : un enregistrement démarre en général après le début du stream, -peut s'arrêter avant sa fin, et une même diffusion peut en compter plusieurs. Une pause OBS -(show privé) ne coupe pas l'intervalle — elle n'écrit rien mais ne ferme pas le fichier, et -la diffusion publique s'est de toute façon interrompue pendant ce temps. +Plusieurs tables et non une : un enregistrement démarre en général après le début du stream, +peut s'arrêter avant sa fin, et une même diffusion peut en compter plusieurs. + +**Une pause OBS (show privé, flux hors-ligne) ne coupe pas l'intervalle** — elle n'écrit rien +mais ne ferme pas le fichier. La découper en deux intervalles ferait passer les morceaux pour +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**, et apparaît en jaune par-dessus le rouge de la capture : +la barre se lit d'un coup d'œil comme « la capture tournait ici, mais n'écrivait rien ». Les +totaux annoncés déduisent ces pauses — les y inclure surestimerait ce qui est réellement +dans le fichier. + +Deux invariants tiennent ce relevé : les ouvertures et fermetures de pause sont **idempotentes** +(le hub les rejoue à chaque cycle de statut, toutes les 2 s, et elles n'agissent qu'aux +transitions), et **aucune pause ne survit à l'intervalle qui la porte**. Ce second point est +réparé après coup plutôt que supposé : une VM disparue en pleine pause voit son intervalle +refermé par le balayage périodique, sans passer par le chemin qui referme les pauses — sans +cette réparation, la frise resterait jaune jusqu'à maintenant. Le rattachement d'une capture à un profil passe par le pseudo sur lequel la veille de l'agent est calée — c'est `startTargetRecording()` qui l'y pose. Une capture lancée à la main depuis @@ -966,7 +981,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer { res.json({ sessions: sessionsRepo.since(from, targetId), spans: spansRepo.since(from, targetId), + pauses: pausesRepo.since(from, targetId), }); }); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 096b735..f4ecdf0 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -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; diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index 4aadf1d..f10711f 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -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) { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 95d507b..43b7177 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1042,9 +1042,31 @@ export interface RecordingSpan { endedAt: number | null; } +/** + * Un passage en pause à l'intérieur d'une capture. + * + * En surimpression d'un {@link RecordingSpan}, jamais à sa place : une capture + * mise en pause reste une seule capture, un seul fichier. La découper en + * plusieurs intervalles ferait passer les morceaux pour des enregistrements + * distincts — alors que rien n'a été clos, seulement suspendu. + * + * C'est ce qui permet de distinguer sur la frise le temps réellement écrit du + * temps où la capture tournait à vide (show privé, flux hors-ligne). + */ +export interface RecordingPause { + id: number; + spanId: number; + /** Repris de l'intervalle porteur, qui seul rattache la capture à un profil. */ + targetId: string | null; + startedAt: number; + /** Nul tant que la capture est toujours en pause. */ + endedAt: number | null; +} + export interface TimelineData { sessions: StreamSession[]; spans: RecordingSpan[]; + pauses: RecordingPause[]; } /** diff --git a/packages/web/src/components/StreamerDetail.tsx b/packages/web/src/components/StreamerDetail.tsx index 7f298e9..2cde831 100644 --- a/packages/web/src/components/StreamerDetail.tsx +++ b/packages/web/src/components/StreamerDetail.tsx @@ -22,7 +22,7 @@ interface Props { onClose: () => void; } -const EMPTY: TimelineData = { sessions: [], spans: [] }; +const EMPTY: TimelineData = { sessions: [], spans: [], pauses: [] }; /** * Fiche détaillée d'un profil : identité, réglages, et son historique de @@ -343,6 +343,7 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro

diffusion publique enregistré + en pause

{loading ? ( @@ -358,6 +359,7 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro label: target.label ?? target.username, sessions: timeline.sessions, spans: timeline.spans, + pauses: timeline.pauses, }, ]} /> @@ -367,7 +369,12 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro