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).
- [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.
- 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
ne découpe pas le fichier, une vraie fin de diffusion le termine.
- [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
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
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

View File

@@ -79,6 +79,27 @@ export function describeMetrics(
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.
*
@@ -762,6 +783,33 @@ export class FirefoxController {
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.
*

View File

@@ -33,7 +33,12 @@ import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts';
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.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 { sendHotkey } from './hotkey.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
// la précédente ne doit pas laisser celle-ci sans surveillance.
fullscreenMisses = 0;
stallRecoveries = 0;
resetPlaybackTracking();
const opened = await openPage(url);
const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`);
await sleep(wait);
const fullscreenResult = await preparePlayer(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;
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'enregistrement.
await selectMaxStreamQuality();
// 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 };
return fullscreenResult;
}
/** Ouvre une page par la voie correspondant au mode de pilotage. */
@@ -497,6 +517,161 @@ let fullscreenMisses = 0;
const MAX_FULLSCREEN_MISSES = 3;
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.
*
@@ -511,6 +686,9 @@ async function watchFullscreen(): Promise<void> {
const bidi = driver();
const settings = watcher.snapshotSettings.fullscreen;
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
// rien à cadrer ; en pause (show privé), l'overlay du site occupe le lecteur
@@ -719,6 +897,13 @@ function startStatusLoop(): void {
});
}, FULLSCREEN_WATCH_MS);
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 {
@@ -726,6 +911,8 @@ function stopStatusLoop(): void {
statusTimer = null;
if (fullscreenTimer) clearInterval(fullscreenTimer);
fullscreenTimer = null;
if (playbackTimer) clearInterval(playbackTimer);
playbackTimer = null;
}
// --- Cycle de vie ------------------------------------------------------------

View File

@@ -458,6 +458,20 @@ export interface BrowserSettings {
* « Firefox est déjà ouvert ».
*/
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',
remotePort: 9222,
profileDir: '',
stallTimeoutMs: 30_000,
};
export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
@@ -503,6 +518,7 @@ export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
const base = DEFAULT_BROWSER_SETTINGS;
const delay = Number(input.readyDelayMs);
const port = Number(input.remotePort);
const stall = Number(input.stallTimeoutMs);
return {
enabled: input.enabled === true,
@@ -526,6 +542,14 @@ export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
? Math.min(Math.max(Math.round(port), 1024), 65_535)
: base.remotePort,
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.
</span>
</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>
<label className="field">