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