Skip to main content

heldar_kernel/
util.rs

1use std::path::Path;
2use std::process::Stdio;
3use std::time::Duration;
4
5use anyhow::{anyhow, Context};
6use chrono::{DateTime, NaiveDateTime, Utc};
7use serde::Deserialize;
8use tokio::process::Command;
9
10/// Hard cap for a single ffprobe invocation so one pathological file cannot wedge the indexer.
11const FFPROBE_TIMEOUT: Duration = Duration::from_secs(20);
12
13/// Fail fast at startup if the configured ffmpeg/ffprobe binaries aren't runnable. They are required
14/// for recording, clip/snapshot export, sampling, and indexing; a missing binary otherwise surfaces
15/// only later as silent per-camera failures (cameras stuck "connecting", empty timelines).
16pub fn check_media_binaries(cfg: &crate::config::Config) -> anyhow::Result<()> {
17    for (label, bin) in [("ffmpeg", &cfg.ffmpeg_bin), ("ffprobe", &cfg.ffprobe_bin)] {
18        std::process::Command::new(bin)
19            .arg("-version")
20            .stdout(std::process::Stdio::null())
21            .stderr(std::process::Stdio::null())
22            .status()
23            .map_err(|e| {
24                anyhow!(
25                    "required media binary `{label}` (`{bin}`) is not runnable: {e}. \
26                     Install ffmpeg, or set HELDAR_FFMPEG_BIN / HELDAR_FFPROBE_BIN to its path."
27                )
28            })?;
29    }
30    Ok(())
31}
32
33/// Subset of media properties extracted via ffprobe.
34#[derive(Debug, Clone)]
35pub struct ProbeInfo {
36    pub duration_s: f64,
37    pub codec: Option<String>,
38    pub width: Option<i64>,
39    pub height: Option<i64>,
40    pub fps: Option<f64>,
41}
42
43/// Parse an ffprobe rational like "20/1" or "30000/1001" into frames-per-second.
44fn parse_rational(s: &str) -> Option<f64> {
45    let (n, d) = s.split_once('/')?;
46    let n: f64 = n.parse().ok()?;
47    let d: f64 = d.parse().ok()?;
48    if d == 0.0 {
49        None
50    } else {
51        Some(n / d)
52    }
53}
54
55#[derive(Deserialize)]
56struct FfprobeOut {
57    #[serde(default)]
58    streams: Vec<FfprobeStream>,
59    format: Option<FfprobeFormat>,
60}
61#[derive(Deserialize)]
62struct FfprobeStream {
63    codec_type: Option<String>,
64    codec_name: Option<String>,
65    width: Option<i64>,
66    height: Option<i64>,
67    avg_frame_rate: Option<String>,
68}
69#[derive(Deserialize)]
70struct FfprobeFormat {
71    duration: Option<String>,
72}
73
74/// Probe a media file for duration and video stream properties.
75pub async fn ffprobe_file(ffprobe_bin: &str, path: &Path) -> anyhow::Result<ProbeInfo> {
76    let mut cmd = Command::new(ffprobe_bin);
77    cmd.kill_on_drop(true)
78        .args([
79            "-v",
80            "error",
81            "-show_entries",
82            "format=duration",
83            "-show_entries",
84            "stream=codec_type,codec_name,width,height,avg_frame_rate",
85            "-of",
86            "json",
87        ])
88        .arg(path)
89        .stdin(Stdio::null())
90        .stdout(Stdio::piped())
91        .stderr(Stdio::piped());
92
93    let out = tokio::time::timeout(FFPROBE_TIMEOUT, cmd.output())
94        .await
95        .map_err(|_| anyhow!("ffprobe timed out for {}", path.display()))?
96        .with_context(|| format!("spawning ffprobe for {}", path.display()))?;
97
98    if !out.status.success() {
99        return Err(anyhow!(
100            "ffprobe failed for {}: {}",
101            path.display(),
102            String::from_utf8_lossy(&out.stderr).trim()
103        ));
104    }
105
106    let parsed: FfprobeOut =
107        serde_json::from_slice(&out.stdout).context("parsing ffprobe json output")?;
108    let duration_s = parsed
109        .format
110        .and_then(|f| f.duration)
111        .and_then(|d| d.parse::<f64>().ok())
112        .unwrap_or(0.0);
113    let video = parsed
114        .streams
115        .iter()
116        .find(|s| s.codec_type.as_deref() == Some("video"));
117
118    Ok(ProbeInfo {
119        duration_s,
120        codec: video.and_then(|s| s.codec_name.clone()),
121        width: video.and_then(|s| s.width),
122        height: video.and_then(|s| s.height),
123        fps: video
124            .and_then(|s| s.avg_frame_rate.as_deref())
125            .and_then(parse_rational),
126    })
127}
128
129/// Parse a UTC segment start time from a filename like `20260613_120500.mp4`.
130pub fn parse_segment_time(filename: &str) -> Option<DateTime<Utc>> {
131    let stem = filename.split('.').next().unwrap_or(filename);
132    let stem = stem.trim_end_matches('Z');
133    NaiveDateTime::parse_from_str(stem, "%Y%m%d_%H%M%S")
134        .ok()
135        .map(|n| n.and_utc())
136}
137
138/// Turn an arbitrary string into a safe lowercase slug for camera ids / path segments.
139pub fn slugify(s: &str) -> String {
140    let mut out = String::with_capacity(s.len());
141    let mut last_dash = false;
142    for ch in s.chars() {
143        if ch.is_ascii_alphanumeric() {
144            out.push(ch.to_ascii_lowercase());
145            last_dash = false;
146        } else if !last_dash {
147            out.push('_');
148            last_dash = true;
149        }
150    }
151    let trimmed = out.trim_matches('_').to_string();
152    if trimmed.is_empty() {
153        "camera".to_string()
154    } else {
155        trimmed
156    }
157}
158
159/// Parse an RFC3339 / ISO-8601 timestamp (accepts a trailing `Z`) into UTC.
160pub fn parse_rfc3339(s: &str) -> Option<DateTime<Utc>> {
161    DateTime::parse_from_rfc3339(s.trim())
162        .ok()
163        .map(|d| d.with_timezone(&Utc))
164}
165
166/// Probe a live stream URL (RTSP-aware) to confirm reachability and read codec/dimensions.
167pub async fn ffprobe_stream(ffprobe_bin: &str, url: &str) -> anyhow::Result<ProbeInfo> {
168    let out = Command::new(ffprobe_bin)
169        .kill_on_drop(true)
170        .args([
171            "-v",
172            "error",
173            "-rtsp_transport",
174            "tcp",
175            "-timeout",
176            "8000000",
177            "-show_entries",
178            "format=duration",
179            "-show_entries",
180            "stream=codec_type,codec_name,width,height,avg_frame_rate",
181            "-of",
182            "json",
183            url,
184        ])
185        .stdin(Stdio::null())
186        .stdout(Stdio::piped())
187        .stderr(Stdio::piped())
188        .output()
189        .await
190        .context("spawning ffprobe for stream")?;
191    if !out.status.success() {
192        return Err(anyhow!("{}", String::from_utf8_lossy(&out.stderr).trim()));
193    }
194    let parsed: FfprobeOut =
195        serde_json::from_slice(&out.stdout).context("parsing ffprobe json output")?;
196    let duration_s = parsed
197        .format
198        .and_then(|f| f.duration)
199        .and_then(|d| d.parse::<f64>().ok())
200        .unwrap_or(0.0);
201    let video = parsed
202        .streams
203        .iter()
204        .find(|s| s.codec_type.as_deref() == Some("video"));
205    Ok(ProbeInfo {
206        duration_s,
207        codec: video.and_then(|s| s.codec_name.clone()),
208        width: video.and_then(|s| s.width),
209        height: video.and_then(|s| s.height),
210        fps: video
211            .and_then(|s| s.avg_frame_rate.as_deref())
212            .and_then(parse_rational),
213    })
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn slugify_normalizes() {
222        assert_eq!(slugify("Gate A 01"), "gate_a_01");
223        assert_eq!(slugify("  !!!  "), "camera");
224        assert_eq!(slugify("Caf\u{e9}-Cam #2"), "caf_cam_2");
225    }
226
227    #[test]
228    fn parse_segment_time_reads_utc_filename() {
229        let t = parse_segment_time("20260613_050219.mp4").unwrap();
230        assert_eq!(t.to_rfc3339(), "2026-06-13T05:02:19+00:00");
231    }
232
233    #[test]
234    fn parse_rfc3339_accepts_trailing_z() {
235        let t = parse_rfc3339("2026-06-13T05:02:19Z").unwrap();
236        assert_eq!(t.to_rfc3339(), "2026-06-13T05:02:19+00:00");
237    }
238}