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
10const FFPROBE_TIMEOUT: Duration = Duration::from_secs(20);
12
13pub 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#[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
43fn 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
74pub 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
129pub 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
138pub 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
159pub 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
166pub 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}