1use serde::{Deserialize, Serialize};
8use std::{
9 path::Path,
10 process::{Command, Stdio},
11 sync::OnceLock,
12};
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct MediaInfo {
16 pub container: Option<String>,
17 pub duration_seconds: Option<f64>,
18 pub bit_rate: Option<u64>,
19 pub streams: Vec<String>,
21 pub width: Option<u32>,
22 pub height: Option<u32>,
23}
24
25pub(crate) fn ffprobe_available() -> bool {
26 static FOUND: OnceLock<bool> = OnceLock::new();
27 *FOUND.get_or_init(|| {
28 Command::new("ffprobe")
29 .arg("-version")
30 .stdin(Stdio::null())
31 .stdout(Stdio::null())
32 .stderr(Stdio::null())
33 .status()
34 .is_ok_and(|status| status.success())
35 })
36}
37
38pub(crate) fn parse(json: &[u8]) -> Option<MediaInfo> {
40 let value: serde_json::Value = serde_json::from_slice(json).ok()?;
41 let format = value.get("format")?;
42 let number = |v: &serde_json::Value| v.as_str().and_then(|s| s.parse::<f64>().ok());
43 let streams = value.get("streams")?.as_array()?;
44 let video = streams.iter().find(|s| s["codec_type"] == "video");
45 Some(MediaInfo {
46 container: format["format_name"].as_str().map(str::to_string),
47 duration_seconds: number(&format["duration"]).filter(|d| d.is_finite() && *d >= 0.0),
48 bit_rate: number(&format["bit_rate"]).map(|b| b as u64),
49 streams: streams
50 .iter()
51 .map(|s| {
52 format!(
53 "{}/{}",
54 s["codec_type"].as_str().unwrap_or("unknown"),
55 s["codec_name"].as_str().unwrap_or("unknown")
56 )
57 })
58 .collect(),
59 width: video.and_then(|s| s["width"].as_u64()).map(|v| v as u32),
60 height: video.and_then(|s| s["height"].as_u64()).map(|v| v as u32),
61 })
62}
63
64pub(crate) fn probe(path: &Path) -> Option<MediaInfo> {
65 if !ffprobe_available() {
66 return None;
67 }
68 let output = Command::new("ffprobe")
69 .args([
70 "-v",
71 "error",
72 "-of",
73 "json",
74 "-show_format",
75 "-show_streams",
76 ])
77 .arg(path)
78 .stdin(Stdio::null())
79 .stderr(Stdio::null())
80 .output()
81 .ok()?;
82 output
83 .status
84 .success()
85 .then(|| parse(&output.stdout))
86 .flatten()
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn parses_ffprobe_json_and_rejects_garbage() {
95 let info = parse(br#"{"streams":[{"codec_type":"video","codec_name":"h264","width":720,"height":1280},{"codec_type":"audio","codec_name":"aac"}],"format":{"format_name":"mov,mp4","duration":"3.500000","bit_rate":"1250000"}}"#).unwrap();
96 assert_eq!(info.streams, ["video/h264", "audio/aac"]);
97 assert_eq!((info.width, info.height), (Some(720), Some(1280)));
98 assert_eq!(info.duration_seconds, Some(3.5));
99 assert_eq!(info.bit_rate, Some(1_250_000));
100 assert_eq!(parse(b"not json"), None);
101 assert_eq!(parse(br#"{"format":{}}"#), None);
102 let sparse = parse(br#"{"streams":[],"format":{"duration":"nan"}}"#).unwrap();
103 assert_eq!(sparse.duration_seconds, None);
104 }
105}