Feat : handle infinite throbber
Some checks failed
release / build (push) Failing after 27s
release / verify-windows (push) Has been skipped

This commit is contained in:
jeanotx32
2026-08-13 14:26:26 -04:00
parent 00535d5e19
commit 830b27470c
5 changed files with 338 additions and 14 deletions

View File

@@ -30,6 +30,9 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
- Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur). - Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur).
- [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés) - [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés)
(Stripchat), avec reprise et rappel du plein écran au retour du flux public. (Stripchat), avec reprise et rappel du plein écran au retour du flux public.
- Le plein écran est [mesuré, pas supposé](#mesurer-plutôt-que-demander), relu pendant la
capture et rétabli s'il se perd ; un
[lecteur figé](#lecteur-figé--rechargement-automatique) est détecté et la page rechargée.
- [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
@@ -253,6 +256,47 @@ occupe le lecteur. Le constat part au journal **avec les mesures qui l'ont motiv
après coup. Après trois rappels sans effet, la surveillance se suspend jusqu'au prochain après coup. Après trois rappels sans effet, la surveillance se suspend jusqu'au prochain
enregistrement plutôt que d'insister toutes les 30 s. enregistrement plutôt que d'insister toutes les 30 s.
##### Lecteur figé : rechargement automatique
Le lecteur reste parfois bloqué sur son indicateur de chargement sans jamais repartir. Rien ne
le signale : la plateforme donne toujours le stream public, OBS continue d'enregistrer un
écran d'attente, et seul un rechargement de la page débloque la situation. L'agent le fait
maintenant lui-même, après **30 s sans la moindre avancée de la lecture** (réglable par agent
dans « Pilotage du navigateur », `0` désactive, plancher à 10 s).
**Le blocage se mesure à la position de lecture, pas à l'apparition d'un indicateur.**
`video.currentTime` est une propriété standard de HTML : elle survivra à n'importe quelle
refonte de l'habillage du lecteur, là où guetter une classe CSS de *throbber* se casserait à
la première. Une mise en mémoire tampon de quelques secondes est normale sur un direct — c'est
l'absence prolongée d'avancée qui distingue la panne.
**`paused` ne court-circuite rien, et c'est délibéré.** Vérifié en conditions réelles contre un
Firefox piloté : selon la façon dont le flux se rompt, le lecteur reste tantôt « en lecture »
avec son indicateur (`paused: false`, `readyState` retombé à 2), tantôt suspendu de lui-même
(`paused: true`). Les deux se soldent par une position qui n'avance plus et appellent le même
remède ; les distinguer aurait laissé passer la moitié des cas.
La reprise rejoue **exactement la séquence d'ouverture** — rechargement, attente que le lecteur
démarre, plein écran, puis qualité — le code étant partagé avec le démarrage de capture plutôt
que dupliqué. Pendant ce temps les deux surveillances s'abstiennent : un rechargement fait
forcément perdre plein écran et lecture, et les laisser réagir déclencherait la reprise en
cours d'exécution.
Trois garde-fous, parce que recharger pendant un enregistrement n'est pas anodin :
- **uniquement pendant une capture active et non suspendue** — hors capture il n'y a rien à
sauver, et une pause de show privé arrête légitimement la lecture ;
- **uniquement si la veille donne le stream public**, quand elle est activée. Sans ce second
garde-fou, un opérateur ayant désactivé la mise en pause automatique verrait la page se
recharger en boucle à chaque fin de diffusion — le flux s'arrête alors légitimement sans
qu'OBS se mette en pause ;
- **trois rechargements enchaînés au maximum.** Si recharger n'y change rien, la cause est
ailleurs, et boucler ne ferait que hacher le fichier toutes les 30 s. Le compteur repart dès
que la lecture avance à nouveau, et à chaque nouvelle capture.
Le constat part au journal avec les relevés qui l'ont motivé (« position 412,3 s, readyState 2 »),
pour distinguer après coup un flux qui manque de données d'un lecteur qui s'est suspendu.
**Un seul envoi à la fois, et pas plus d'un toutes les 10 s.** Deux mécanismes indépendants **Un seul envoi à la fois, et pas plus d'un toutes les 10 s.** Deux mécanismes indépendants
peuvent réclamer ce rappel : la séquence d'ouverture (une fois, à l'arrivée sur la page) et peuvent réclamer ce rappel : la séquence d'ouverture (une fois, à l'arrivée sur la page) et
la surveillance de l'agent, qui le redemande de lui-même après un show privé. Sans la surveillance de l'agent, qui le redemande de lui-même après un show privé. Sans

View File

@@ -79,6 +79,27 @@ export function describeMetrics(
return parts.join(', '); return parts.join(', ');
} }
/**
* Instantané de la lecture, tel que le lecteur le rapporte.
*
* Lu sur l'élément `<video>` standard, sans rien connaître du lecteur du site :
* ces propriétés sont celles de la spécification HTML, elles survivront à
* n'importe quelle refonte de l'habillage.
*/
export interface PlaybackSample {
/** Position de lecture, en secondes. C'est son avancée qui prouve que le flux vit. */
currentTime: number;
/** Lecture volontairement suspendue : ce n'est pas un blocage. */
paused: boolean;
/**
* `HTMLMediaElement.readyState`, de 0 (rien) à 4 (assez de données).
* Purement indicatif ici, pour le journal : c'est l'avancée du temps qui
* tranche, pas cette valeur — un lecteur peut afficher `HAVE_ENOUGH_DATA` et
* rester figé.
*/
readyState: number;
}
/** /**
* Croissance minimale de la hauteur occupée pour conclure au plein écran. * Croissance minimale de la hauteur occupée pour conclure au plein écran.
* *
@@ -762,6 +783,33 @@ export class FirefoxController {
return { fullscreen, metrics, baseline: this.baseline }; return { fullscreen, metrics, baseline: this.baseline };
} }
/**
* Relève l'état de lecture, ou `null` si la page ne montre aucun lecteur.
*
* Comme {@link checkFullscreen}, ne réveille jamais Firefox : c'est une
* observation, elle ne doit rien provoquer.
*/
async samplePlayback(): Promise<PlaybackSample | null> {
if (this.client?.isOpen !== true || !this.context) return null;
const raw = await this.evaluate(`JSON.stringify((() => {
const video = document.querySelector('video');
if (!video) return null;
return {
currentTime: video.currentTime,
paused: video.paused,
readyState: video.readyState,
};
})())`).catch(() => null);
if (typeof raw !== 'string') return null;
try {
return JSON.parse(raw) as PlaybackSample | null;
} catch {
return null;
}
}
/** /**
* Fixe le repère du mode fenêtré, une fois par page. * Fixe le repère du mode fenêtré, une fois par page.
* *

View File

@@ -33,7 +33,12 @@ import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts'; import { StreamWatcher } from './watcher.ts';
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts'; import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts'; import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
import { FirefoxController, describeMetrics, type StreamQualityOutcome } from './firefox.ts'; import {
FirefoxController,
describeMetrics,
type PlaybackSample,
type StreamQualityOutcome,
} from './firefox.ts';
import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts'; import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
import { sendHotkey } from './hotkey.ts'; import { sendHotkey } from './hotkey.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts'; import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
@@ -255,11 +260,36 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
// Une nouvelle capture repart avec ses propres chances : un abandon décidé sur // Une nouvelle capture repart avec ses propres chances : un abandon décidé sur
// la précédente ne doit pas laisser celle-ci sans surveillance. // la précédente ne doit pas laisser celle-ci sans surveillance.
fullscreenMisses = 0; fullscreenMisses = 0;
stallRecoveries = 0;
resetPlaybackTracking();
const opened = await openPage(url); const opened = await openPage(url);
const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs); const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`); const fullscreenResult = await preparePlayer(wait);
await sleep(wait);
// Dernière chance d'écrire un preset en attente : une fois l'enregistrement
// commencé, OBS refuse toute modification jusqu'à l'arrêt. Sans cet appel
// explicite, l'écriture dépendait du hasard d'un cycle de statut (toutes les
// 2 s) tombant avant le démarrage — perdu, le preset restait en attente
// jusqu'au prochain arrêt, et cet enregistrement tournait sur l'ancien réglage.
await drainPresetApply();
await obs.execute('record.start');
report('info', `Capture démarrée pour ${url}`, 'capture.started');
return { opened, fullscreen: fullscreenResult, recording: true };
}
/**
* Laisse le lecteur démarrer, puis le met en état : plein écran, puis qualité.
*
* Extrait de {@link startCapture} parce que la reprise après blocage
* ({@link recoverStalledPlayer}) doit rejouer exactement la même séquence : un
* rechargement de page repart d'un lecteur nu, tout comme une ouverture.
* Dupliquer l'enchaînement aurait laissé les deux copies diverger.
*/
async function preparePlayer(waitMs: number): Promise<unknown> {
report('info', `Attente de ${Math.round(waitMs / 1000)} s avant le plein écran`);
await sleep(waitMs);
const fullscreen = watcher.snapshotSettings.fullscreen; const fullscreen = watcher.snapshotSettings.fullscreen;
let fullscreenResult: unknown = 'ignoré'; let fullscreenResult: unknown = 'ignoré';
@@ -294,17 +324,7 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
// l'éloignement du curseur reste la dernière chose à toucher la page avant // l'éloignement du curseur reste la dernière chose à toucher la page avant
// l'enregistrement. // l'enregistrement.
await selectMaxStreamQuality(); await selectMaxStreamQuality();
return fullscreenResult;
// Dernière chance d'écrire un preset en attente : une fois l'enregistrement
// commencé, OBS refuse toute modification jusqu'à l'arrêt. Sans cet appel
// explicite, l'écriture dépendait du hasard d'un cycle de statut (toutes les
// 2 s) tombant avant le démarrage — perdu, le preset restait en attente
// jusqu'au prochain arrêt, et cet enregistrement tournait sur l'ancien réglage.
await drainPresetApply();
await obs.execute('record.start');
report('info', `Capture démarrée pour ${url}`, 'capture.started');
return { opened, fullscreen: fullscreenResult, recording: true };
} }
/** Ouvre une page par la voie correspondant au mode de pilotage. */ /** Ouvre une page par la voie correspondant au mode de pilotage. */
@@ -497,6 +517,161 @@ let fullscreenMisses = 0;
const MAX_FULLSCREEN_MISSES = 3; const MAX_FULLSCREEN_MISSES = 3;
let fullscreenTimer: NodeJS.Timeout | null = null; let fullscreenTimer: NodeJS.Timeout | null = null;
/**
* Fréquence d'échantillonnage de la lecture.
*
* Plus serrée que la relecture du plein écran : c'est l'écart entre deux
* relevés qui borne la précision du seuil de blocage. À 10 s, un blocage
* configuré à 30 s est constaté entre 30 et 40 s après coup.
*/
const PLAYBACK_SAMPLE_MS = 10_000;
let playbackTimer: NodeJS.Timeout | null = null;
/** Dernière position de lecture observée, et l'instant où elle a bougé. */
let lastPlaybackTime: number | null = null;
let lastProgressAt = 0;
/**
* Rechargements enchaînés sans que la lecture reparte. Au-delà, on cesse : si
* recharger n'y fait rien, le problème est ailleurs, et boucler ne ferait que
* hacher l'enregistrement toutes les 30 s. Le compteur repart dès que la
* lecture avance à nouveau, et à chaque nouvelle capture.
*/
let stallRecoveries = 0;
const MAX_STALL_RECOVERIES = 3;
/**
* Une reprise est en cours : le lecteur est délibérément en train d'être
* rechargé. Les deux surveillances s'abstiennent le temps que ça se fasse —
* sans quoi le rechargement, qui fait forcément perdre plein écran et lecture,
* déclencherait la reprise qu'il est justement en train d'exécuter.
*/
let recovering = false;
/** Repart de zéro : plus rien à comparer sur ce lecteur. */
function resetPlaybackTracking(): void {
lastPlaybackTime = null;
lastProgressAt = 0;
}
/**
* Détecte un lecteur figé et le remet en route.
*
* Le lecteur reste parfois coincé sur son indicateur de chargement sans jamais
* repartir. Rien ne le signale : la plateforme donne toujours le stream public,
* OBS continue d'enregistrer, et seul un rechargement de la page débloque la
* situation. C'était jusqu'ici une intervention manuelle.
*
* Le blocage se mesure à l'absence d'avancée de la position de lecture, et non
* à l'apparition d'un indicateur : c'est le symptôme réel, il ne dépend
* d'aucune classe CSS, et il survivra à n'importe quelle refonte du lecteur.
*/
async function watchPlayback(): Promise<void> {
const bidi = driver();
const timeout = browserSettings.stallTimeoutMs;
if (!bidi || timeout <= 0 || recovering) return;
if (stallRecoveries >= MAX_STALL_RECOVERIES) return;
// Même garde que la surveillance du plein écran : hors capture il n'y a rien
// à sauver, et une pause de show privé arrête légitimement la lecture — la
// prendre pour un blocage rechargerait la page à chaque passage en privé.
const record = await obs.recordState().catch(() => null);
if (!record?.active || record.paused) {
resetPlaybackTracking();
return;
}
// Second garde-fou, indispensable quand la mise en pause automatique est
// désactivée : le flux s'arrête alors légitimement (show privé, fin de
// diffusion) sans qu'OBS se mette en pause, et un lecteur à l'arrêt n'a rien
// d'anormal. Recharger n'y changerait rien, et la boucle hacherait le fichier.
if (watcher.snapshotSettings.enabled && watcher.snapshot.state !== 'public') {
resetPlaybackTracking();
return;
}
const sample = await bidi.samplePlayback();
if (!sample) {
resetPlaybackTracking();
return;
}
const now = Date.now();
// Une position qui recule ou qui saute désigne un lecteur remplacé ou un
// repositionnement du direct : c'est du mouvement, donc le contraire d'un
// blocage.
if (lastPlaybackTime === null || sample.currentTime !== lastPlaybackTime) {
lastPlaybackTime = sample.currentTime;
lastProgressAt = now;
// La lecture repart : les rechargements précédents ont porté leurs fruits,
// ou le blocage s'est résorbé seul.
stallRecoveries = 0;
return;
}
// `paused` ne court-circuite rien, et c'est délibéré. Vérifié en conditions
// réelles : selon la façon dont le flux se rompt, le lecteur reste tantôt
// « en lecture » avec un indicateur de chargement, tantôt suspendu de
// lui-même. Les deux se soldent par une position qui n'avance plus, et les
// deux appellent le même remède — le distinguer ne ferait que laisser passer
// la moitié des cas.
const stalledFor = now - lastProgressAt;
if (stalledFor < timeout) return;
await recoverStalledPlayer(stalledFor, sample);
}
/** Recharge la page, puis remet le lecteur en état — plein écran et qualité. */
async function recoverStalledPlayer(stalledFor: number, sample: PlaybackSample): Promise<void> {
const bidi = driver();
const url = bidi?.state.url;
if (!bidi || !url || url === 'about:blank') {
resetPlaybackTracking();
return;
}
recovering = true;
stallRecoveries += 1;
// Les relevés partent avec le constat : c'est ce qui permet de distinguer
// après coup un flux qui manque de données d'un lecteur qui s'est suspendu.
report(
'warn',
`Lecteur figé depuis ${Math.round(stalledFor / 1000)} s ` +
`(position ${sample.currentTime.toFixed(1)} s, readyState ${sample.readyState}` +
`${sample.paused ? ', suspendu' : ''}) — rechargement de la page ` +
`(${stallRecoveries}/${MAX_STALL_RECOVERIES})`,
'browser.opened',
);
try {
await bidi.navigate(url);
// Même séquence qu'à l'ouverture d'une capture : la page revient nue, il
// faut lui rendre le plein écran et la qualité. Le compteur d'échecs du
// plein écran repart aussi — c'est une page neuve, pas la précédente.
fullscreenMisses = 0;
await preparePlayer(browserSettings.readyDelayMs);
report('info', 'Lecteur rechargé et remis en état', 'browser.opened');
if (stallRecoveries >= MAX_STALL_RECOVERIES) {
report(
'warn',
`Le lecteur s'est figé ${MAX_STALL_RECOVERIES} fois de suite : surveillance suspendue ` +
"jusqu'au prochain enregistrement. Recharger n'y change rien, la cause est ailleurs.",
'command.failed',
);
}
} catch (err) {
report(
'error',
`Reprise du lecteur figé impossible : ${err instanceof Error ? err.message : String(err)}`,
'command.failed',
);
} finally {
// Après coup, pas avant : le temps passé à recharger ne doit pas compter
// comme une absence d'avancée, sans quoi la reprise suivante partirait
// immédiatement.
resetPlaybackTracking();
recovering = false;
}
}
/** /**
* Vérifie que le lecteur occupe toujours la fenêtre, et le rétablit sinon. * Vérifie que le lecteur occupe toujours la fenêtre, et le rétablit sinon.
* *
@@ -511,6 +686,9 @@ async function watchFullscreen(): Promise<void> {
const bidi = driver(); const bidi = driver();
const settings = watcher.snapshotSettings.fullscreen; const settings = watcher.snapshotSettings.fullscreen;
if (!bidi || !settings.enabled || fullscreenMisses >= MAX_FULLSCREEN_MISSES) return; if (!bidi || !settings.enabled || fullscreenMisses >= MAX_FULLSCREEN_MISSES) return;
// Un rechargement en cours fait forcément perdre le plein écran : le constater
// ici enverrait une touche au milieu de la reprise, qui le rétablit déjà.
if (recovering) return;
// Uniquement pendant une capture réellement en cours. Hors capture il n'y a // Uniquement pendant une capture réellement en cours. Hors capture il n'y a
// rien à cadrer ; en pause (show privé), l'overlay du site occupe le lecteur // rien à cadrer ; en pause (show privé), l'overlay du site occupe le lecteur
@@ -719,6 +897,13 @@ function startStatusLoop(): void {
}); });
}, FULLSCREEN_WATCH_MS); }, FULLSCREEN_WATCH_MS);
fullscreenTimer.unref?.(); fullscreenTimer.unref?.();
playbackTimer = setInterval(() => {
void watchPlayback().catch((err: unknown) => {
console.error('Surveillance de la lecture en échec :', err);
});
}, PLAYBACK_SAMPLE_MS);
playbackTimer.unref?.();
} }
function stopStatusLoop(): void { function stopStatusLoop(): void {
@@ -726,6 +911,8 @@ function stopStatusLoop(): void {
statusTimer = null; statusTimer = null;
if (fullscreenTimer) clearInterval(fullscreenTimer); if (fullscreenTimer) clearInterval(fullscreenTimer);
fullscreenTimer = null; fullscreenTimer = null;
if (playbackTimer) clearInterval(playbackTimer);
playbackTimer = null;
} }
// --- Cycle de vie ------------------------------------------------------------ // --- Cycle de vie ------------------------------------------------------------

View File

@@ -458,6 +458,20 @@ export interface BrowserSettings {
* « Firefox est déjà ouvert ». * « Firefox est déjà ouvert ».
*/ */
profileDir: string; profileDir: string;
/**
* Durée de blocage du lecteur au-delà de laquelle l'agent recharge la page.
* `0` désactive la reprise.
*
* Le lecteur se fige parfois sur son indicateur de chargement sans jamais
* repartir, le flux ne reprenant qu'après un rechargement manuel. Rien dans le
* statut du stream ne le signale : la plateforme le donne toujours public, et
* OBS continue d'enregistrer un écran d'attente.
*
* Le seuil se mesure au temps sans la moindre avancée de la lecture, pas à
* l'apparition d'un indicateur : c'est le blocage réel qui compte, et une
* mise en mémoire tampon de quelques secondes est normale sur un direct.
*/
stallTimeoutMs: number;
} }
/** /**
@@ -496,6 +510,7 @@ export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
onStop: 'blank', onStop: 'blank',
remotePort: 9222, remotePort: 9222,
profileDir: '', profileDir: '',
stallTimeoutMs: 30_000,
}; };
export function normalizeBrowserSettings(raw: unknown): BrowserSettings { export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
@@ -503,6 +518,7 @@ export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
const base = DEFAULT_BROWSER_SETTINGS; const base = DEFAULT_BROWSER_SETTINGS;
const delay = Number(input.readyDelayMs); const delay = Number(input.readyDelayMs);
const port = Number(input.remotePort); const port = Number(input.remotePort);
const stall = Number(input.stallTimeoutMs);
return { return {
enabled: input.enabled === true, enabled: input.enabled === true,
@@ -526,6 +542,14 @@ export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
? Math.min(Math.max(Math.round(port), 1024), 65_535) ? Math.min(Math.max(Math.round(port), 1024), 65_535)
: base.remotePort, : base.remotePort,
profileDir: typeof input.profileDir === 'string' ? input.profileDir.trim() : base.profileDir, profileDir: typeof input.profileDir === 'string' ? input.profileDir.trim() : base.profileDir,
// Plancher à 10 s hors désactivation : en deçà, une mise en mémoire tampon
// ordinaire suffirait à déclencher un rechargement, et le remède serait pire
// que le mal. La borne haute laisse la place à un flux très instable.
stallTimeoutMs: Number.isFinite(stall)
? stall <= 0
? 0
: Math.min(Math.max(Math.round(stall), 10_000), 600_000)
: base.stallTimeoutMs,
}; };
} }

View File

@@ -511,6 +511,27 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
Le temps que la page charge et que le lecteur démarre. Le temps que la page charge et que le lecteur démarre.
</span> </span>
</label> </label>
{browser.mode === 'bidi' && (
<label className="field grow">
<span>Recharger si le lecteur se fige (s)</span>
<input
value={String(Math.round(browser.stallTimeoutMs / 1000))}
onChange={(event) =>
patchBrowser({ stallTimeoutMs: (Number(event.target.value) || 0) * 1000 })
}
inputMode="numeric"
/>
<span className="muted small">
Le lecteur reste parfois bloqué sur son indicateur de chargement sans jamais
repartir : rien ne le signale, le stream est toujours annoncé public et OBS
continue d'enregistrer un écran d'attente. Passé ce délai <em>sans la moindre
avancée de la lecture</em>, l'agent recharge la page puis remet le plein écran
et la qualité. <strong>0 désactive</strong> ; en dessous de 10 s, une simple
mise en mémoire tampon suffirait à déclencher un rechargement.
</span>
</label>
)}
</div> </div>
<label className="field"> <label className="field">