252 lines
8.2 KiB
Rust
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));
|
|
}
|
|
}
|