├── .github └── workflows │ └── default.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── src └── lib.rs └── tests └── verify.rs /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Lints and Tests 4 | 5 | jobs: 6 | check_test: 7 | name: Check and Test 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout sources 11 | uses: actions/checkout@v2 12 | 13 | - name: Install stable toolchain 14 | uses: actions-rs/toolchain@v1 15 | with: 16 | profile: minimal 17 | toolchain: stable 18 | override: true 19 | 20 | - name: Run cargo check 21 | uses: actions-rs/cargo@v1 22 | continue-on-error: true # WARNING: only for this example, remove it! 23 | with: 24 | command: check 25 | 26 | - name: Run cargo test 27 | uses: actions-rs/cargo@v1 28 | continue-on-error: true # WARNING: only for this example, remove it! 29 | with: 30 | command: test 31 | 32 | lints: 33 | name: Lints 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Checkout sources 37 | uses: actions/checkout@v2 38 | 39 | - name: Install stable toolchain 40 | uses: actions-rs/toolchain@v1 41 | with: 42 | profile: minimal 43 | toolchain: stable 44 | override: true 45 | components: rustfmt, clippy 46 | 47 | - name: Run cargo fmt 48 | uses: actions-rs/cargo@v1 49 | with: 50 | command: fmt 51 | args: --all -- --check 52 | 53 | - name: Run cargo clippy 54 | uses: actions-rs/cargo@v1 55 | with: 56 | command: clippy 57 | args: -- -D warnings 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | /.test_output 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.4.0 - 2024-04-03 2 | 3 | * Allow customizing the ffprobe binary path 4 | [#22](https://github.com/theduke/ffprobe-rs/pull/22) by @lovesegfault 5 | * Add StreamTags::{reel_name/timecode} fields 6 | * Add `Default` impl for `ConfigBuilder` 7 | * Add `extra` field to `FormatTags` [#13](https://github.com/theduke/ffprobe-rs/pull/13) by @imLinguin 8 | 9 | # 0.3.3 - 2022-09-05 10 | 11 | * Add `Format::get_duration` + `Format::try_get_duration` accessors 12 | 13 | # 0.3.2 - 2022-04-23 14 | 15 | * FIX: don't fail on missing `Format::size` values. 16 | Using a default empty string for now. 17 | The field will be changed to `Option<_>` in the future. 18 | 19 | # 0.3.1 - 2022-04-13 20 | 21 | * Add configuration system 22 | * Add a `count_frames` setting 23 | If enabled, the `-count_frames` option will be passed to ffprobe, 24 | which will do a full decode and count available frames. 25 | 26 | # 0.3.0 - 2021-08-02 27 | 28 | * Provided more detailed error information 29 | [#8](https://github.com/theduke/ffprobe-rs/pull/8) 30 | * Make some fields optional 31 | 32 | # 0.2.0 - 2021-06-26 33 | 34 | * Change `Stream::codec_time_base` to `Option<_>` 35 | 36 | 37 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ffprobe" 3 | description = "Typed wrapper for the ffprobe CLI" 4 | version = "0.4.1" 5 | authors = ["Christoph Herzog "] 6 | repository = "https://github.com/theduke/ffprobe-rs" 7 | edition = "2018" 8 | license = "MIT" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [features] 13 | # Generate structs with #[serde(deny_unknown_fields)] 14 | # Only useful for testing! 15 | __internal_deny_unknown_fields = [] 16 | 17 | [dependencies] 18 | serde = { version = "1.0.119", features = ["derive"] } 19 | serde_json = "1.0.61" 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 19 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ffprobe-rs 2 | 3 | 4 | [![crates.io](https://img.shields.io/crates/v/ffprobe?label=latest)](https://crates.io/crates/ffprobe) 5 | [![Documentation](https://docs.rs/ffprobe/badge.svg?version)](https://docs.rs/ffprobe) 6 | 7 | Simple wrapper for the [ffprobe](https://ffmpeg.org/ffprobe.html) CLI utility, 8 | which is part of the ffmpeg tool suite. 9 | 10 | This crate allows retrieving typed information about media files (images and videos) 11 | by invoking `ffprobe` with JSON output options and deserializing the data 12 | into convenient Rust types. 13 | 14 | ## Example 15 | 16 | ```rust 17 | fn main() { 18 | match ffprobe::ffprobe("path/to/video.mp4") { 19 | Ok(info) => { 20 | dbg!(info); 21 | }, 22 | Err(err) => { 23 | eprintln!("Could not analyze file with ffprobe: {:?}", err); 24 | } 25 | } 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Simple wrapper for the [ffprobe](https://ffmpeg.org/ffprobe.html) CLI utility, 2 | //! which is part of the ffmpeg tool suite. 3 | //! 4 | //! This crate allows retrieving typed information about media files (images and videos) 5 | //! by invoking `ffprobe` with JSON output options and deserializing the data 6 | //! into convenient Rust types. 7 | //! 8 | //! 9 | //! 10 | //! ```rust 11 | //! match ffprobe::ffprobe("path/to/video.mp4") { 12 | //! Ok(info) => { 13 | //! dbg!(info); 14 | //! }, 15 | //! Err(err) => { 16 | //! eprintln!("Could not analyze file with ffprobe: {:?}", err); 17 | //! }, 18 | //! } 19 | //! ``` 20 | 21 | #[cfg(target_os = "windows")] 22 | use std::os::windows::process::CommandExt; 23 | 24 | /// Execute ffprobe with default settings and return the extracted data. 25 | /// 26 | /// See [`ffprobe_config`] if you need to customize settings. 27 | pub fn ffprobe(path: impl AsRef) -> Result { 28 | ffprobe_config( 29 | Config { 30 | count_frames: false, 31 | ffprobe_bin: "ffprobe".into(), 32 | }, 33 | path, 34 | ) 35 | } 36 | 37 | /// Run ffprobe with a custom config. 38 | /// See [`ConfigBuilder`] for more details. 39 | pub fn ffprobe_config( 40 | config: Config, 41 | path: impl AsRef, 42 | ) -> Result { 43 | let path = path.as_ref(); 44 | 45 | let mut cmd = std::process::Command::new(config.ffprobe_bin); 46 | 47 | // Default args. 48 | cmd.args([ 49 | "-v", 50 | "error", 51 | "-show_format", 52 | "-show_streams", 53 | "-print_format", 54 | "json", 55 | ]); 56 | 57 | if config.count_frames { 58 | cmd.arg("-count_frames"); 59 | } 60 | 61 | cmd.arg(path); 62 | 63 | // Prevent CMD popup on Windows. 64 | #[cfg(target_os = "windows")] 65 | cmd.creation_flags(0x08000000); 66 | 67 | let out = cmd.output().map_err(FfProbeError::Io)?; 68 | 69 | if !out.status.success() { 70 | return Err(FfProbeError::Status(out)); 71 | } 72 | 73 | serde_json::from_slice::(&out.stdout).map_err(FfProbeError::Deserialize) 74 | } 75 | 76 | /// ffprobe configuration. 77 | /// 78 | /// Use [`Config::builder`] for constructing a new config. 79 | #[derive(Clone, Debug)] 80 | pub struct Config { 81 | count_frames: bool, 82 | ffprobe_bin: std::path::PathBuf, 83 | } 84 | 85 | impl Config { 86 | /// Construct a new ConfigBuilder. 87 | pub fn builder() -> ConfigBuilder { 88 | ConfigBuilder::new() 89 | } 90 | } 91 | 92 | /// Build the ffprobe configuration. 93 | pub struct ConfigBuilder { 94 | config: Config, 95 | } 96 | 97 | impl ConfigBuilder { 98 | pub fn new() -> Self { 99 | Self { 100 | config: Config { 101 | count_frames: false, 102 | ffprobe_bin: "ffprobe".into(), 103 | }, 104 | } 105 | } 106 | 107 | /// Enable the -count_frames setting. 108 | /// Will fully decode the file and count the frames. 109 | /// Frame count will be available in [`Stream::nb_read_frames`]. 110 | pub fn count_frames(mut self, count_frames: bool) -> Self { 111 | self.config.count_frames = count_frames; 112 | self 113 | } 114 | 115 | /// Specify which binary name (e.g. `"ffprobe-6"`) or path (e.g. `"/opt/bin/ffprobe"`) to use 116 | /// for executing `ffprobe`. 117 | pub fn ffprobe_bin(mut self, ffprobe_bin: impl AsRef) -> Self { 118 | self.config.ffprobe_bin = ffprobe_bin.as_ref().to_path_buf(); 119 | self 120 | } 121 | 122 | /// Finalize the builder into a [`Config`]. 123 | pub fn build(self) -> Config { 124 | self.config 125 | } 126 | 127 | /// Run ffprobe with the config produced by this builder. 128 | pub fn run(self, path: impl AsRef) -> Result { 129 | ffprobe_config(self.config, path) 130 | } 131 | } 132 | 133 | impl Default for ConfigBuilder { 134 | fn default() -> Self { 135 | Self::new() 136 | } 137 | } 138 | 139 | #[derive(Debug)] 140 | #[non_exhaustive] 141 | pub enum FfProbeError { 142 | Io(std::io::Error), 143 | Status(std::process::Output), 144 | Deserialize(serde_json::Error), 145 | } 146 | 147 | impl std::fmt::Display for FfProbeError { 148 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 149 | match self { 150 | FfProbeError::Io(e) => e.fmt(f), 151 | FfProbeError::Status(o) => { 152 | write!( 153 | f, 154 | "ffprobe exited with status code {}: {}", 155 | o.status, 156 | String::from_utf8_lossy(&o.stderr) 157 | ) 158 | } 159 | FfProbeError::Deserialize(e) => e.fmt(f), 160 | } 161 | } 162 | } 163 | 164 | impl std::error::Error for FfProbeError {} 165 | 166 | #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 167 | #[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))] 168 | pub struct FfProbe { 169 | pub streams: Vec, 170 | pub format: Format, 171 | } 172 | 173 | #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 174 | #[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))] 175 | pub struct Stream { 176 | pub index: i64, 177 | pub codec_name: Option, 178 | pub sample_aspect_ratio: Option, 179 | pub display_aspect_ratio: Option, 180 | pub color_range: Option, 181 | pub color_space: Option, 182 | pub bits_per_raw_sample: Option, 183 | pub channel_layout: Option, 184 | pub max_bit_rate: Option, 185 | pub nb_frames: Option, 186 | /// Number of frames seen by the decoder. 187 | /// Requires full decoding and is only available if the 'count_frames' 188 | /// setting was enabled. 189 | pub nb_read_frames: Option, 190 | pub codec_long_name: Option, 191 | pub codec_type: Option, 192 | pub codec_time_base: Option, 193 | pub codec_tag_string: String, 194 | pub codec_tag: String, 195 | pub sample_fmt: Option, 196 | pub sample_rate: Option, 197 | pub channels: Option, 198 | pub bits_per_sample: Option, 199 | pub r_frame_rate: String, 200 | pub avg_frame_rate: String, 201 | pub time_base: String, 202 | pub start_pts: Option, 203 | pub start_time: Option, 204 | pub duration_ts: Option, 205 | pub duration: Option, 206 | pub bit_rate: Option, 207 | pub disposition: Disposition, 208 | pub tags: Option, 209 | pub profile: Option, 210 | pub width: Option, 211 | pub height: Option, 212 | pub coded_width: Option, 213 | pub coded_height: Option, 214 | pub closed_captions: Option, 215 | pub has_b_frames: Option, 216 | pub pix_fmt: Option, 217 | pub level: Option, 218 | pub chroma_location: Option, 219 | pub refs: Option, 220 | pub is_avc: Option, 221 | pub nal_length: Option, 222 | pub nal_length_size: Option, 223 | pub field_order: Option, 224 | pub id: Option, 225 | #[serde(default)] 226 | pub side_data_list: Vec, 227 | } 228 | 229 | #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 230 | #[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))] 231 | // Allowed to prevent having to break compatibility of float fields are added. 232 | #[allow(clippy::derive_partial_eq_without_eq)] 233 | pub struct SideData { 234 | pub side_data_type: String, 235 | pub rotation: Option, 236 | } 237 | 238 | #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 239 | #[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))] 240 | // Allowed to prevent having to break compatibility of float fields are added. 241 | #[allow(clippy::derive_partial_eq_without_eq)] 242 | pub struct Disposition { 243 | pub default: i64, 244 | pub dub: i64, 245 | pub original: i64, 246 | pub comment: i64, 247 | pub lyrics: i64, 248 | pub karaoke: i64, 249 | pub forced: i64, 250 | pub hearing_impaired: i64, 251 | pub visual_impaired: i64, 252 | pub clean_effects: i64, 253 | pub attached_pic: i64, 254 | pub timed_thumbnails: i64, 255 | } 256 | 257 | #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 258 | #[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))] 259 | // Allowed to prevent having to break compatibility of float fields are added. 260 | #[allow(clippy::derive_partial_eq_without_eq)] 261 | pub struct StreamTags { 262 | pub language: Option, 263 | pub creation_time: Option, 264 | pub handler_name: Option, 265 | pub encoder: Option, 266 | pub timecode: Option, 267 | pub reel_name: Option, 268 | } 269 | 270 | #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 271 | #[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))] 272 | pub struct Format { 273 | pub filename: String, 274 | pub nb_streams: i64, 275 | pub nb_programs: i64, 276 | pub format_name: String, 277 | pub format_long_name: Option, 278 | pub start_time: Option, 279 | pub duration: Option, 280 | pub size: Option, 281 | pub bit_rate: Option, 282 | pub probe_score: i64, 283 | pub tags: Option, 284 | } 285 | 286 | impl Format { 287 | /// Get the duration parsed into a [`std::time::Duration`]. 288 | pub fn try_get_duration( 289 | &self, 290 | ) -> Option> { 291 | self.duration 292 | .as_ref() 293 | .map(|duration| match duration.parse::() { 294 | Ok(num) => Ok(std::time::Duration::from_secs_f64(num)), 295 | Err(error) => Err(error), 296 | }) 297 | } 298 | 299 | /// Get the duration parsed into a [`std::time::Duration`]. 300 | /// 301 | /// Will return [`None`] if no duration is available, or if parsing fails. 302 | /// See [`Self::try_get_duration`] for a method that returns an error. 303 | pub fn get_duration(&self) -> Option { 304 | self.try_get_duration()?.ok() 305 | } 306 | } 307 | 308 | #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 309 | #[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))] 310 | #[allow(clippy::derive_partial_eq_without_eq)] 311 | pub struct FormatTags { 312 | #[serde(rename = "WMFSDKNeeded")] 313 | pub wmfsdkneeded: Option, 314 | #[serde(rename = "DeviceConformanceTemplate")] 315 | pub device_conformance_template: Option, 316 | #[serde(rename = "WMFSDKVersion")] 317 | pub wmfsdkversion: Option, 318 | #[serde(rename = "IsVBR")] 319 | pub is_vbr: Option, 320 | pub major_brand: Option, 321 | pub minor_version: Option, 322 | pub compatible_brands: Option, 323 | pub creation_time: Option, 324 | pub encoder: Option, 325 | 326 | #[serde(flatten)] 327 | pub extra: std::collections::HashMap, 328 | } 329 | -------------------------------------------------------------------------------- /tests/verify.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use ffprobe::ConfigBuilder; 4 | 5 | fn download(url: &str) -> std::path::PathBuf { 6 | let dir = std::path::PathBuf::from(".test_output"); 7 | if !dir.is_dir() { 8 | std::fs::create_dir(&dir).unwrap(); 9 | } 10 | 11 | let filename = url 12 | .replace("http:", ":") 13 | .replace("https", "") 14 | .replace('/', "__") 15 | .replace(':', "__"); 16 | let path = dir.join(filename); 17 | 18 | if !path.is_file() { 19 | let status = std::process::Command::new("curl") 20 | .args(&[ 21 | "-H", 22 | "user-agent: Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0", 23 | "--insecure", 24 | "-L", 25 | "-o", 26 | ]) 27 | .arg(&path) 28 | .arg(url) 29 | .spawn() 30 | .unwrap() 31 | .wait() 32 | .unwrap(); 33 | 34 | if !status.success() { 35 | panic!("Download failed"); 36 | } 37 | } 38 | 39 | path 40 | } 41 | 42 | fn check(path: &Path) { 43 | eprintln!("Testing file {}", path.display()); 44 | ffprobe::ffprobe(path).unwrap(); 45 | } 46 | 47 | fn check_count_frames(path: &Path) { 48 | eprintln!("Testing file {}", path.display()); 49 | let out = ConfigBuilder::new().count_frames(true).run(path).unwrap(); 50 | 51 | let stream = out 52 | .streams 53 | .iter() 54 | .find(|s| s.codec_type.clone().unwrap_or_default() == "video") 55 | .unwrap(); 56 | 57 | assert!(stream.nb_read_frames.is_some()); 58 | } 59 | 60 | #[test] 61 | fn download_and_probe() { 62 | let item_urls = vec![ 63 | // Images. 64 | "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg", 65 | // Videos. 66 | "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", 67 | "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-avi-file.avi", 68 | "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-avi-file.avi", 69 | "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mov-file.mov", 70 | "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mpg-file.mpg", 71 | "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-wmv-file.wmv", 72 | // Audios. 73 | // TODO: add some audio files 74 | ]; 75 | 76 | let item_paths = item_urls 77 | .iter() 78 | .map(|url| download(url)) 79 | .collect::>(); 80 | 81 | for path in &item_paths { 82 | check(path); 83 | } 84 | 85 | check_count_frames(item_paths.last().unwrap()); 86 | } 87 | --------------------------------------------------------------------------------