Files
arr/crates/arr-probe/src/model.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

261 lines
8.2 KiB
Rust

//! What a probe returns.
use std::{path::PathBuf, time::Duration};
use arr_core::{AudioTrack, DolbyVisionProfile, HdrFormat, ProbedMedia, Resolution, SubtitleTrack};
use crate::{
error::{Error, Result},
ffprobe::{Output, Stream},
language,
};
/// The facts `ffprobe` reports about one file.
///
/// [`ProbedFile::media`] is the part the policy engine consumes post-download
/// (DESIGN.md §5.6). The rest — size, duration, codec — is what naming,
/// feature selection and the UI need.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProbedFile {
/// The file that was probed.
pub path: PathBuf,
/// Size in bytes, as the container reports it.
pub size: u64,
/// Playing time.
pub duration: Duration,
/// `ffprobe`'s codec name for the video stream, e.g. `hevc` or `h264`.
pub video_codec: String,
/// Everything the policy engine looks at.
pub media: ProbedMedia,
}
impl ProbedFile {
/// Build one from an `ffprobe` document.
///
/// # Errors
///
/// [`Error::NoVideoStream`] when the file carries no video, and
/// [`Error::NoDuration`] when neither the container nor any stream reports
/// a playing time.
pub(crate) fn from_output(path: PathBuf, output: &Output) -> Result<Self> {
if let Some(format) = output.format.name.as_deref() {
if let Some(name) = format.split(',').find(|name| is_still_or_text(name)) {
return Err(Error::NotVideo {
path,
format: name.to_owned(),
});
}
}
let video = output
.streams
.iter()
.find(|stream| stream.is_video())
.ok_or_else(|| Error::NoVideoStream { path: path.clone() })?;
let duration = duration(output).ok_or_else(|| Error::NoDuration { path: path.clone() })?;
let size = output
.format
.size
.as_deref()
.and_then(|size| size.parse().ok())
.unwrap_or_default();
let media = ProbedMedia {
resolution: resolution(video),
// A file cannot tell you where it came from. Source stays a claim
// of the release name (DESIGN.md §5.6).
source: None,
hdr: hdr(video),
audio_tracks: output
.streams
.iter()
.filter(|stream| stream.is_kind("audio"))
.map(audio_track)
.collect(),
subtitle_tracks: output
.streams
.iter()
.filter(|stream| stream.is_kind("subtitle"))
.map(subtitle_track)
.collect(),
};
Ok(Self {
path,
size,
duration,
video_codec: video.codec_name.clone().unwrap_or_default(),
media,
})
}
}
/// How a candidate's playing time compared to the runtime metadata claims.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeMatch {
/// Within tolerance of the expected runtime.
Matched,
/// No expected runtime was supplied, so nothing was checked.
Unchecked,
/// Every candidate was outside tolerance; the largest was taken anyway.
/// An extended cut looks like this, and so does an extras reel.
Mismatched {
/// What was expected, typically TMDB's runtime.
expected: Duration,
/// What the chosen file actually runs for.
actual: Duration,
},
}
/// Which file in a multi-file torrent is the feature.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FeatureSelection {
/// The chosen file.
pub feature: ProbedFile,
/// Whether the runtime sanity check agreed with the choice.
pub runtime: RuntimeMatch,
/// The other readable video files, largest first. Samples, extras and the
/// occasional second feature end up here.
pub others: Vec<ProbedFile>,
}
/// Containers that are not video however hard `ffprobe` tries.
///
/// The `tty` demuxer will read a `.nfo` as ANSI art and report a video stream
/// with a codec, a resolution and a duration, so "has a video stream" is not
/// on its own enough to call something importable.
fn is_still_or_text(format: &str) -> bool {
matches!(format, "tty" | "image2" | "gif" | "srt" | "ass" | "webvtt")
|| format.ends_with("_pipe")
}
fn audio_track(stream: &Stream) -> AudioTrack {
let title = stream.tags.get("title");
let handler_name = stream.tags.get("handler_name");
AudioTrack {
language: language::resolve(stream.tags.get("language"), title, handler_name),
title: title.map(str::to_owned),
handler_name: handler_name.map(str::to_owned),
}
}
fn subtitle_track(stream: &Stream) -> SubtitleTrack {
SubtitleTrack {
language: language::resolve(
stream.tags.get("language"),
stream.tags.get("title"),
stream.tags.get("handler_name"),
),
}
}
/// Frame size to a resolution bucket.
///
/// Width leads: a 2.39:1 scope transfer of a 2160p film is 3840x1600, and
/// bucketing that by height would call it 1080p.
fn resolution(video: &Stream) -> Resolution {
let width = video.width.unwrap_or_default();
let height = video.height.unwrap_or_default();
match () {
() if width >= 3000 || height >= 1700 => Resolution::R2160p,
() if width >= 1800 || height >= 900 => Resolution::R1080p,
() if width >= 1200 || height >= 620 => Resolution::R720p,
() => Resolution::Other(u16::try_from(height).unwrap_or(u16::MAX)),
}
}
/// HDR format, Dolby Vision profile included.
///
/// The profile is the whole point: DESIGN.md §5.3 accepts 8.1 and rejects 5
/// and 7, and no release name can be trusted for it.
fn hdr(video: &Stream) -> HdrFormat {
if let Some(side_data) = video
.side_data_list
.iter()
.find(|side_data| side_data.dv_profile.is_some())
{
if let Some(profile) = side_data.dv_profile {
return HdrFormat::DolbyVision(DolbyVisionProfile {
profile,
compatibility_id: side_data.dv_bl_signal_compatibility_id,
});
}
}
if video.side_data_list.iter().any(|side_data| {
side_data
.kind
.as_deref()
.is_some_and(|kind| kind.contains("2094"))
}) {
return HdrFormat::Hdr10Plus;
}
match video.color_transfer.as_deref() {
Some("smpte2084") => HdrFormat::Hdr10,
Some("arib-std-b67") => HdrFormat::Hlg,
_ => HdrFormat::Sdr,
}
}
/// Container duration first, then the longest stream, then Matroska's
/// per-stream `DURATION` tag, which is all a stream-copied remux carries.
fn duration(output: &Output) -> Option<Duration> {
if let Some(duration) = output.format.duration.as_deref().and_then(seconds) {
return Some(duration);
}
output
.streams
.iter()
.filter_map(|stream| {
stream
.duration
.as_deref()
.and_then(seconds)
.or_else(|| stream.tags.get("duration").and_then(timecode))
})
.max()
}
/// `"1.023000"` — seconds as a float.
fn seconds(value: &str) -> Option<Duration> {
let seconds: f64 = value.trim().parse().ok()?;
if seconds.is_finite() && seconds > 0.0 {
Duration::try_from_secs_f64(seconds).ok()
} else {
None
}
}
/// `"00:00:01.023000000"` — Matroska's tag spelling.
fn timecode(value: &str) -> Option<Duration> {
let mut parts = value.trim().split(':');
let hours: u64 = parts.next()?.parse().ok()?;
let minutes: u64 = parts.next()?.parse().ok()?;
let seconds: f64 = parts.next()?.parse().ok()?;
if parts.next().is_some() || !seconds.is_finite() || seconds < 0.0 {
return None;
}
let whole = Duration::from_secs(hours * 3600 + minutes * 60);
Some(whole + Duration::try_from_secs_f64(seconds).ok()?)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::timecode;
#[test]
fn matroska_timecodes_parse() {
assert_eq!(
timecode("00:01:30.500000000"),
Some(Duration::from_millis(90_500))
);
assert_eq!(timecode("nonsense"), None);
}
}