Files
xtremflow/server/src/util/media_probe.rs
T
MichaelandClaude Opus 5.5 3ea6fa0ae3 Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) :
tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API,
mêmes routes et mêmes réponses : le front Flutter ne change pas.

La base de production est reprise telle quelle : schéma, migrations et
formats de dates (DateTime.toIso8601String de Dart) sont reproduits à
l'identique, les sessions et hachages existants restent valides.

Comportement conservé : cascade de routage, anti-SSRF, logs masqués,
rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur,
enregistrements (reprise, relances, fusion), season passes, EPG, logos de
repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ;
la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le
serveur dans un étage rust:1.98.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:57 +02:00

114 lines
3.3 KiB
Rust

//! Inspection des fichiers média locaux (enregistrements) via ffprobe.
//!
//! Les résultats sont mémorisés tant que le fichier ne change pas : une
//! liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture,
//! et la playlist de lecture est rechargée en continu — sans cache, ffprobe
//! tournerait en boucle.
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::UNIX_EPOCH;
struct CacheEntry {
size: u64,
mtime_ms: u128,
value: String,
}
static CACHE: LazyLock<Mutex<HashMap<String, CacheEntry>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin`
/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger.
pub fn ffprobe_path() -> String {
if let Ok(path) = std::env::var("FFPROBE_PATH")
&& !path.is_empty()
{
return path;
}
if cfg!(target_os = "linux") && std::path::Path::new("/usr/local/bin/ffprobe").exists() {
return "/usr/local/bin/ffprobe".to_string();
}
"ffprobe".to_string()
}
/// Durée du média en secondes, ou `None` si elle n'est pas mesurable.
pub async fn duration(path: &str) -> Option<f64> {
let value = probe(path, "duration", &["-show_entries", "format=duration"]).await?;
let seconds: f64 = value.parse().ok()?;
(seconds.is_finite() && seconds > 0.0).then_some(seconds)
}
/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…).
pub async fn video_codec(path: &str) -> Option<String> {
let value = probe(
path,
"videoCodec",
&[
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
],
)
.await?;
(!value.is_empty()).then(|| value.to_lowercase())
}
fn stat(path: &str) -> Option<(u64, u128)> {
let meta = std::fs::metadata(path).ok()?;
let mtime = meta
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_millis();
Some((meta.len(), mtime))
}
/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat
/// tant que taille et date de modification sont inchangées.
async fn probe(path: &str, field: &str, args: &[&str]) -> Option<String> {
let (size, mtime_ms) = stat(path)?;
let key = format!("{field}@{path}");
{
let cache = CACHE.lock().unwrap();
if let Some(entry) = cache.get(&key)
&& entry.size == size
&& entry.mtime_ms == mtime_ms
{
return Some(entry.value.clone());
}
}
let output = tokio::process::Command::new(ffprobe_path())
.args(["-v", "error"])
.args(args)
.args(["-of", "default=noprint_wrappers=1:nokey=1", path])
.stdin(std::process::Stdio::null())
.output()
.await
.ok()?;
// ffprobe imprime une ligne par flux : seule la première nous intéresse.
let stdout = String::from_utf8_lossy(&output.stdout);
let first = stdout
.trim()
.split('\n')
.next()
.unwrap_or("")
.trim()
.to_string();
if first.is_empty() || first == "N/A" {
return None;
}
CACHE.lock().unwrap().insert(
key,
CacheEntry {
size,
mtime_ms,
value: first.clone(),
},
);
Some(first)
}