Enregistrements : relancer jusqu'à la fin du match, code amont journalisé

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-21 00:03:39 +02:00
1 parent 6368fd6ee2
commit 0f81df1c5e
3 files changed
+39 -37

No files matched your search

+13 -2
View File
@@ -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()))
),
}
}
+18 -16
View File
@@ -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<Utc>,
end: DateTime<Utc>,
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
);
}
+8 -19
View File
@@ -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()));