From 0f81df1c5ebb4354508878edea1cbd2da82d937a Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 21 Oct 2026 00:03:39 +0200 Subject: [PATCH] =?UTF-8?q?Enregistrements=20:=20relancer=20jusqu'=C3=A0?= =?UTF-8?q?=20la=20fin=20du=20match,=20code=20amont=20journalis=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un match NBA a échoué après 30 refus du fournisseur en 13 min, alors qu'il restait 3 h 20 de diffusion. Le plafond MAX_FFMPEG_ATTEMPTS condamnait tout l'enregistrement sur un refus temporaire : on relance désormais tant que la fenêtre est ouverte, au rythme de ffmpeg_retry_delay (30 s au plus). - direct_proxy journalise le code HTTP du panneau : FFmpeg n'affiche que « 4XX », le code exact (ici 407) n'était visible nulle part. - direct_proxy libère les connexions de lecture orphelines du compte avant d'ouvrir le flux, comme turbo.ts et HLS. Un flux encore regardé n'est jamais coupé. Co-Authored-By: Claude Opus 5.5 --- server/src/api/streaming.rs | 15 ++++++++-- server/src/services/recording_decision.rs | 34 ++++++++++++---------- server/src/services/recording_scheduler.rs | 27 +++++------------ 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/server/src/api/streaming.rs b/server/src/api/streaming.rs index 5b08275..f1a2bed 100644 --- a/server/src/api/streaming.rs +++ b/server/src/api/streaming.rs @@ -903,14 +903,24 @@ impl Streaming { redact_url(&target) ); + // Compte à une connexion : un FFmpeg de lecture orphelin (spectateur + // parti, session pas encore expirée) garde le slot et le panneau + // refuse l'enregistrement. On coupe les orphelins comme les autres + // routes ; un flux encore regardé n'est jamais coupé. + self.claim_upstream(&playlist, &format!("direct_{stream_id}")) + .await; + // Première connexion hors du générateur : elle seule décide du code // de réponse. let client = state.http.clone(); let Some(first) = open_live_upstream(&client, &target).await else { + println!("[Live Proxy] {stream_id} : fournisseur injoignable"); return http::text(502, "Upstream connection failed"); }; let status = first.status().as_u16(); if status != 200 { + // FFmpeg ne journalise que « 4XX » : le code exact n'est qu'ici. + println!("[Live Proxy] {stream_id} : refusé par le fournisseur (HTTP {status})"); return http::text(status, "Upstream refused"); } @@ -954,8 +964,9 @@ impl Streaming { tokio::time::sleep(Duration::from_secs(1)).await; match open_live_upstream(&client, &target).await { Some(r) if r.status().as_u16() == 200 => next = Some(r), - _ => println!( - "[Live Proxy] {id} : reconnexion {attempt}/{LIVE_PROXY_MAX_RETRIES} échouée" + other => println!( + "[Live Proxy] {id} : reconnexion {attempt}/{LIVE_PROXY_MAX_RETRIES} échouée ({})", + other.map_or("injoignable".to_string(), |r| format!("HTTP {}", r.status().as_u16())) ), } } diff --git a/server/src/services/recording_decision.rs b/server/src/services/recording_decision.rs index 577ba66..601e079 100644 --- a/server/src/services/recording_decision.rs +++ b/server/src/services/recording_decision.rs @@ -74,22 +74,22 @@ pub enum PostExitAction { Fail, } -/// É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. -pub const MAX_FFMPEG_ATTEMPTS: u32 = 30; - /// 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. +/// +/// Aucun plafond d'échecs : tant que la fenêtre est ouverte, on relance +/// (au rythme de [ffmpeg_retry_delay]). Sur un compte à une connexion, le +/// panneau refuse le flux tant qu'un autre lecteur l'occupe ; abandonner +/// au bout de quelques minutes faisait perdre tout le match (constaté en +/// prod : 30 refus en 13 min, 3 h 20 de match perdues). pub fn decide_post_exit_action( now: DateTime, end: DateTime, exit_code: i32, - consecutive_failures: u32, has_file: bool, ) -> PostExitAction { let remaining = end - now; - if remaining > Duration::seconds(60) && consecutive_failures + 1 < MAX_FFMPEG_ATTEMPTS { + if remaining > Duration::seconds(60) { return PostExitAction::Retry; } // `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement. @@ -189,27 +189,29 @@ mod tests { let n = now(); let m40 = n + Duration::minutes(40); assert_eq!( - decide_post_exit_action(n, m40, 1, 0, true), + decide_post_exit_action(n, m40, 1, true), PostExitAction::Retry ); assert_eq!( - decide_post_exit_action(n, m40, 0, 2, true), + decide_post_exit_action(n, m40, 0, true), + PostExitAction::Retry + ); + // Compte IPTV à une connexion occupé pendant 13 min : les refus en + // série ne doivent pas faire abandonner les heures restantes. + assert_eq!( + decide_post_exit_action(n, m40, 8, false), PostExitAction::Retry ); assert_eq!( - decide_post_exit_action(n, m40, 1, MAX_FFMPEG_ATTEMPTS - 1, true), + decide_post_exit_action(n, n, 0, true), PostExitAction::Complete ); assert_eq!( - decide_post_exit_action(n, n, 0, 0, true), + decide_post_exit_action(n, n, 1, true), PostExitAction::Complete ); assert_eq!( - decide_post_exit_action(n, n, 1, 0, true), - PostExitAction::Complete - ); - assert_eq!( - decide_post_exit_action(n, n, 1, 0, false), + decide_post_exit_action(n, n, 1, false), PostExitAction::Fail ); } diff --git a/server/src/services/recording_scheduler.rs b/server/src/services/recording_scheduler.rs index 7b0e70b..63b37df 100644 --- a/server/src/services/recording_scheduler.rs +++ b/server/src/services/recording_scheduler.rs @@ -7,8 +7,8 @@ use crate::dart_time::{DartDateTime, display_local, now_display}; use crate::db::AppDatabase; use crate::models::Recording; use crate::services::recording_decision::{ - MAX_FFMPEG_ATTEMPTS, OrphanAction, PostExitAction, RecordingAction, capture_duration, - decide_orphan_action, decide_post_exit_action, decide_recording_action, ffmpeg_retry_delay, + OrphanAction, PostExitAction, RecordingAction, capture_duration, decide_orphan_action, + decide_post_exit_action, decide_recording_action, ffmpeg_retry_delay, }; use crate::util::b64; use crate::util::log_redactor::redact_url; @@ -828,7 +828,7 @@ impl RecordingScheduler { return; } - // Une capture qui a tenu longtemps repart d'un quota de relances neuf. + // Une capture qui a tenu longtemps repart du délai de relance minimal. let (lasted, has_data) = { let state = active.state.lock().unwrap(); ( @@ -836,21 +836,11 @@ impl RecordingScheduler { active_has_data(&active.file_path, &state.extra_parts), ) }; - let consecutive = { - let mut state = active.state.lock().unwrap(); - if lasted > Duration::from_secs(120) && has_data { - state.consecutive_failures = 0; - } - state.consecutive_failures - }; + if lasted > Duration::from_secs(120) && has_data { + active.state.lock().unwrap().consecutive_failures = 0; + } - match decide_post_exit_action( - Utc::now(), - recording.end_utc(), - exit_code, - consecutive, - has_data, - ) { + match decide_post_exit_action(Utc::now(), recording.end_utc(), exit_code, has_data) { PostExitAction::Retry => { let failures = { let mut state = active.state.lock().unwrap(); @@ -860,10 +850,9 @@ impl RecordingScheduler { }; let delay = ffmpeg_retry_delay(failures); let message = format!( - "FFmpeg s'est arrêté (code {exit_code}) après {}s, avant la fin prévue — relance dans {}s (tentative {failures}/{})", + "FFmpeg s'est arrêté (code {exit_code}) après {}s, avant la fin prévue — relance dans {}s (échec consécutif n°{failures})", lasted.as_secs(), delay.as_secs(), - MAX_FFMPEG_ATTEMPTS - 1 ); println!("[RecordingScheduler] {}: {message}", recording.title); active.log(&format!("\n[{}] {message}", now_display()));