Files
arr/crates/arr-probe/src/lib.rs
T
naps62-yolo 1cb7f1b2fa
ci / rust (push) Successful in 1m20s
ci / web (push) Successful in 7s
e2e / e2e (push) Successful in 1m28s
feat(probe): ffprobe wrapper (#59)
2026-08-22 20:52:09 +01:00

252 lines
8.2 KiB
Rust

//! `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<OsString>) -> 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<PathBuf>) -> Result<ProbedFile> {
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<Item = impl Into<PathBuf>>,
expected_runtime: Option<Duration>,
) -> Result<FeatureSelection> {
let mut candidates: Vec<ProbedFile> = 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<Vec<u8>> {
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));
}
}