Skip to main content

metadata/
video.rs

1use crate::types::VideoMeta;
2use serde_json::Value;
3use std::io::Read;
4use std::path::Path;
5use std::process::{Command, Stdio};
6use std::thread;
7use std::time::Duration;
8use wait_timeout::ChildExt;
9
10/// Wall-clock limit for `ffprobe` (corrupted files may hang indefinitely without this).
11#[derive(Debug, Clone)]
12pub struct FfprobeOptions {
13    pub timeout: Duration,
14}
15
16impl Default for FfprobeOptions {
17    fn default() -> Self {
18        Self {
19            timeout: Duration::from_secs(30),
20        }
21    }
22}
23
24/// Run `ffprobe` subprocess, parse JSON stdout, apply hard timeout.
25pub fn read_video_ffprobe(path: &Path, opts: &FfprobeOptions) -> Option<VideoMeta> {
26    let mut child = Command::new("ffprobe")
27        .args([
28            "-v",
29            "quiet",
30            "-print_format",
31            "json",
32            "-show_format",
33            "-show_streams",
34        ])
35        .arg(path)
36        .stdout(Stdio::piped())
37        .stderr(Stdio::null())
38        .spawn()
39        .ok()?;
40
41    let mut stdout = child.stdout.take()?;
42    let reader = thread::spawn(move || {
43        let mut buf = Vec::new();
44        stdout.read_to_end(&mut buf).map(|_| buf)
45    });
46
47    let wait = child.wait_timeout(opts.timeout).ok()?;
48    match wait {
49        Some(status) if status.success() => {
50            let bytes = match reader.join() {
51                Ok(Ok(b)) => b,
52                _ => return None,
53            };
54            let text = String::from_utf8_lossy(&bytes);
55            let meta = parse_ffprobe_json(&text);
56            if meta.codec_name.is_none()
57                && meta.width.is_none()
58                && meta.height.is_none()
59                && meta.duration_secs.is_none()
60            {
61                None
62            } else {
63                Some(meta)
64            }
65        }
66        Some(_) => {
67            let _ = reader.join();
68            None
69        }
70        None => {
71            let _ = child.kill();
72            let _ = reader.join();
73            None
74        }
75    }
76}
77
78fn parse_ffprobe_json(json: &str) -> VideoMeta {
79    let v: Value = match serde_json::from_str(json) {
80        Ok(v) => v,
81        Err(_) => return VideoMeta::default(),
82    };
83
84    let mut meta = VideoMeta::default();
85
86    if let Some(fmt) = v.get("format").and_then(|f| f.as_object()) {
87        if let Some(d) = fmt.get("duration").and_then(|x| x.as_str()) {
88            meta.duration_secs = d.parse().ok();
89        }
90    }
91
92    if let Some(streams) = v.get("streams").and_then(|s| s.as_array()) {
93        for s in streams {
94            let kind = s.get("codec_type").and_then(|x| x.as_str());
95            if kind == Some("video") {
96                meta.codec_name = s
97                    .get("codec_name")
98                    .and_then(|x| x.as_str())
99                    .map(String::from);
100                meta.width = s.get("width").and_then(|x| x.as_u64()).map(|u| u as u32);
101                meta.height = s.get("height").and_then(|x| x.as_u64()).map(|u| u as u32);
102                break;
103            }
104        }
105    }
106
107    meta
108}