Files
xtremflow/bin/services/recording_decision.dart
Claude f798b43a1e fix(enregistrements): un enregistrement mené à terme n'est plus marqué « échoué »
Chaque enregistrement arrivé au bout de sa fenêtre était marqué
« Échoué / Interruption inattendue du serveur » alors que le fichier était
bien sur le disque : le tick lisait la base AVANT d'arrêter les captures
terminées, si bien que l'instantané annonçait encore « recording » pour un
enregistrement déjà retiré de la table des processus actifs — la détection
d'orphelin le requalifiait aussitôt en échec. La base est désormais lue
après les arrêts, et la requalification revérifie le statut courant.

Deux autres façons de perdre un enregistrement sont corrigées au passage :

- FFmpeg livre encore des morceaux de stderr après la résolution de
  `exitCode` ; écrire sur l'`IOSink` déjà fermé levait une `StateError`
  depuis un callback de stream, donc une erreur asynchrone non rattrapée
  qui tue l'isolate — et avec lui le serveur et toutes les captures en
  cours. Le log passe par un écrivain tolérant et n'est fermé qu'une fois
  stdout et stderr drainés.
- Une coupure amont terminait la capture définitivement. FFmpeg reçoit
  maintenant les options de reconnexion (comme le proxy live), et le
  planificateur relance la capture sur la fin de fenêtre quand le process
  sort trop tôt (backoff 3→30 s, quota remis à zéro après une capture
  saine). Les parties issues des relances sont recollées dans le fichier
  principal via le demuxer `concat`, la lecture reste donc un seul fichier.

Également :
- reprise des captures interrompues par un redémarrage du conteneur tant
  que la fenêtre est ouverte, au lieu d'un échec sec ; fichier partiel
  conservé (statut « terminé ») quand la fenêtre est passée
- `-t` calculé sur le temps restant jusqu'à la fin programmée : un
  démarrage tardif ne rogne plus la fin du programme
- `-hide_banner -nostats` : le log d'enregistrement redevient lisible (et
  ne pèse plus des mégaoctets, il est relu en entier par l'API)
- `RECORDINGS_DIR` et `FFMPEG_PATH` surchargeables, et le motif d'erreur
  est effacé au (re)démarrage d'une capture

Vérifié avec un faux ffmpeg sur un planificateur réel : avant correctif,
un enregistrement mené jusqu'à la fin de fenêtre ressort « failed /
Interruption inattendue du serveur » ; après, « completed » — de même que
la reprise après interruption, la relance après coupure et la fusion des
parties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5xWYTbtZwJuYB3E51K243
2026-08-26 19:09:31 +00:00

127 lines
4.3 KiB
Dart

/// Pure decision logic for the recording scheduler, extracted for testability.
enum RecordingAction {
/// Start the recording now.
start,
/// Capacity reached but the window is still open: retry on a later tick.
wait,
/// The end time passed before the recording could start.
fail,
/// Not yet due — nothing to do.
none,
}
/// Decides what to do with a scheduled recording on a scheduler tick.
RecordingAction decideRecordingAction({
required DateTime now,
required DateTime startTime,
required DateTime endTime,
required int activeCount,
required int maxConcurrent,
}) {
final nowUtc = now.toUtc();
final startUtc = startTime.toUtc();
final endUtc = endTime.toUtc();
if (nowUtc.isAfter(endUtc)) return RecordingAction.fail;
if (!nowUtc.isAfter(startUtc)) return RecordingAction.none;
if (activeCount >= maxConcurrent) return RecordingAction.wait;
return RecordingAction.start;
}
/// Ce que le planificateur doit faire d'un enregistrement resté au statut
/// « recording » en base alors qu'aucun processus FFmpeg ne lui correspond
/// (redémarrage du conteneur, crash de l'isolate…).
enum OrphanAction {
/// La fenêtre est encore ouverte : relancer la capture.
resume,
/// La fenêtre est passée mais un fichier partiel existe : le conserver.
finish,
/// Rien d'exploitable n'a été capturé.
fail,
}
/// Décide du sort d'un enregistrement orphelin détecté au démarrage/à un tick.
OrphanAction decideOrphanAction({
required DateTime now,
required DateTime endTime,
required bool hasFile,
Duration minRemaining = const Duration(seconds: 60),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
if (remaining > minRemaining) return OrphanAction.resume;
return hasFile ? OrphanAction.finish : OrphanAction.fail;
}
/// Ce que le planificateur doit faire quand un processus FFmpeg se termine.
enum PostExitAction {
/// La capture est allée jusqu'au bout (ou assez loin) : marquer terminé.
complete,
/// FFmpeg s'est arrêté trop tôt : relancer la capture sur la fin de fenêtre.
retry,
/// Arrêt prématuré sans rien d'exploitable.
fail,
}
/// Nombre d'échecs FFmpeg consécutifs tolérés avant d'abandonner un
/// enregistrement dont la fenêtre est encore ouverte.
///
/// Le compteur est remis à zéro dès qu'un lancement a duré assez longtemps
/// pour être considéré sain : une coupure toutes les dix minutes ne doit pas
/// finir par épuiser le quota.
const int maxFfmpegAttempts = 30;
/// Décide de la suite à donner à la sortie d'un processus FFmpeg.
///
/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une
/// coupure amont à mi-parcours ne doit pas condamner l'heure restante.
PostExitAction decidePostExitAction({
required DateTime now,
required DateTime endTime,
required int exitCode,
required int consecutiveFailures,
required bool hasFile,
int maxAttempts = maxFfmpegAttempts,
Duration minRemaining = const Duration(seconds: 60),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
if (remaining > minRemaining && consecutiveFailures + 1 < maxAttempts) {
return PostExitAction.retry;
}
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
final cleanExit = exitCode == 0 || exitCode == 255;
return (cleanExit || hasFile) ? PostExitAction.complete : PostExitAction.fail;
}
/// Durée de capture à demander à FFmpeg (`-t`).
///
/// Se base sur le temps qu'il reste jusqu'à la fin programmée, et non sur la
/// durée théorique du programme : un démarrage tardif (ou une relance après
/// coupure) ne doit pas décaler la fin de l'enregistrement.
Duration captureDuration({
required DateTime now,
required DateTime endTime,
Duration minimum = const Duration(seconds: 30),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
return remaining < minimum ? minimum : remaining;
}
/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs.
///
/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités
/// en connexions simultanées ; s'acharner à cette cadence pendant une panne
/// amont ne ferait qu'épuiser le quota de relances.
Duration ffmpegRetryDelay(int consecutiveFailures) {
final steps = (consecutiveFailures - 1).clamp(0, 4);
final seconds = 3 * (1 << steps);
return Duration(seconds: seconds > 30 ? 30 : seconds);
}