//! `ffprobe` wrapper — the second phase of truth, DESIGN.md §5.6. //! //! Release names lie or omit; files do not. Everything here reads a file that //! is already on disk and reports what is actually in it: resolution, video //! codec, HDR format with the Dolby Vision profile as a number, per-track audio //! languages with the strings the tracks call themselves, subtitle tracks, //! duration and size. //! //! Two things this crate deliberately does not do. It never reports a source //! type — no file knows whether it came off a disc or a streaming service, so //! that stays a claim of the release name. And it never returns a //! default-valued [`ProbedFile`]: a file `ffprobe` cannot read is an //! [`Error`], because a zeroed struct would sail through every policy rule //! that inspects a field the file never had. use std::{ ffi::OsString, path::{Path, PathBuf}, process::Stdio, time::Duration, }; use tokio::process::Command; pub mod error; mod ffprobe; mod language; mod model; pub use error::{Error, Result}; pub use model::{FeatureSelection, ProbedFile, RuntimeMatch}; /// The binary invoked when nothing else is configured. pub const DEFAULT_BINARY: &str = "ffprobe"; /// How long one probe may take. Generous: `ffprobe` on a 60 GB remux over a /// network mount is slow, and killing it early would fail an import that was /// about to succeed. pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); /// How far a candidate's playing time may sit from the expected runtime and /// still be the feature. Wide enough for an extended cut, narrow enough that a /// 90-minute extras reel does not pass for a 120-minute film. pub const RUNTIME_TOLERANCE: f64 = 0.15; /// How much of `ffprobe`'s stderr is kept in an error. const STDERR_LIMIT: usize = 512; /// Runs `ffprobe` and turns its output into domain types. #[derive(Clone, Debug)] pub struct Prober { binary: OsString, timeout: Duration, } impl Default for Prober { fn default() -> Self { Self { binary: DEFAULT_BINARY.into(), timeout: DEFAULT_TIMEOUT, } } } impl Prober { /// A prober that runs `ffprobe` from `PATH`. #[must_use] pub fn new() -> Self { Self::default() } /// Run a specific binary instead of whatever `PATH` resolves to. #[must_use] pub fn with_binary(mut self, binary: impl Into) -> Self { self.binary = binary.into(); self } /// Change how long one probe may take. #[must_use] pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } /// Probe one file. /// /// # Errors /// /// [`Error::Spawn`] when `ffprobe` is not installed, [`Error::Rejected`] /// when `ffprobe` cannot read the file, [`Error::Timeout`], /// [`Error::Decode`] on unexpected output, and /// [`Error::NoVideoStream`] / [`Error::NoDuration`] when the file is /// readable but is not a video file this project can import. pub async fn probe(&self, path: impl Into) -> Result { let path = path.into(); let output = self.run(&path).await?; let document = serde_json::from_slice(&output).map_err(|source| Error::Decode { path: path.clone(), source, })?; let mut file = ProbedFile::from_output(path, &document)?; if file.size == 0 { // Every container ffprobe knows reports a size, but a zero would // score as a 0-byte release downstream, so never trust it. file.size = tokio::fs::metadata(&file.path) .await .map_err(|source| Error::Io { path: file.path.clone(), source, })? .len(); } Ok(file) } /// Decide which file in a multi-file torrent is the feature. /// /// The largest video file wins, but only after a runtime sanity check /// against `expected_runtime` — usually TMDB's — so that a bulky extras /// reel does not get imported as the film. When no candidate is within /// [`RUNTIME_TOLERANCE`], the largest is still returned and the result is /// marked [`RuntimeMatch::Mismatched`]; deciding what to do about that is /// the import's call, not this crate's. /// /// Paths that are not importable media are skipped rather than failing the /// selection — a torrent is full of `.nfo` and `.jpg` files. A failure of /// the prober itself is not skipped: see [`Error::is_about_the_file`]. /// /// # Errors /// /// [`Error::NoCandidates`] when no path in the set is a readable video /// file, and anything [`Prober::probe`] raises that is about the prober /// rather than about one file. pub async fn select_feature( &self, paths: impl IntoIterator>, expected_runtime: Option, ) -> Result { let mut candidates: Vec = Vec::new(); for path in paths { let path = path.into(); match self.probe(path.clone()).await { Ok(file) => candidates.push(file), Err(error) if error.is_about_the_file() => { tracing::debug!(path = %path.display(), %error, "not a video file, skipping"); } Err(error) => return Err(error), } } candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.size)); let matched = expected_runtime.and_then(|expected| { candidates .iter() .position(|file| within_tolerance(file.duration, expected)) }); if candidates.is_empty() { return Err(Error::NoCandidates); } let feature = candidates.remove(matched.unwrap_or(0)); let runtime = match (expected_runtime, matched) { (None, _) => RuntimeMatch::Unchecked, (Some(_), Some(_)) => RuntimeMatch::Matched, (Some(expected), None) => RuntimeMatch::Mismatched { expected, actual: feature.duration, }, }; Ok(FeatureSelection { feature, runtime, others: candidates, }) } /// Spawn `ffprobe` and collect its stdout. async fn run(&self, path: &Path) -> Result> { let mut command = Command::new(&self.binary); command .args([ "-v", "error", "-hide_banner", "-print_format", "json", "-show_format", "-show_streams", "-i", ]) .arg(path) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); let child = command.spawn().map_err(|source| Error::Spawn { binary: self.binary.to_string_lossy().into_owned(), source, })?; let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await { Ok(output) => output.map_err(|source| Error::Io { path: path.to_path_buf(), source, })?, Err(_) => { return Err(Error::Timeout { path: path.to_path_buf(), }) } }; if output.status.success() { return Ok(output.stdout); } let stderr = String::from_utf8_lossy(&output.stderr) .trim() .chars() .take(STDERR_LIMIT) .collect(); Err(Error::Rejected { path: path.to_path_buf(), status: output.status.code(), stderr, }) } } fn within_tolerance(actual: Duration, expected: Duration) -> bool { actual.abs_diff(expected) <= expected.mul_f64(RUNTIME_TOLERANCE) } #[cfg(test)] mod tests { use std::time::Duration; use super::within_tolerance; #[test] fn an_extras_reel_is_not_the_feature() { let feature = Duration::from_mins(120); assert!(within_tolerance(Duration::from_mins(118), feature)); assert!(!within_tolerance(Duration::from_mins(90), feature)); } }