Files
arr/crates/arr-probe/src/model.rs
T
Miguel Palhas e852f42a9c feat(arr): name subtitle codecs and dispositions
SubtitleTrack now carries the codec, split text formats from bitmap
ones per DESIGN.md §15, plus the forced and SDH dispositions ffprobe
reports. Without the forced flag a file carrying only a forced track
read as satisfied for that language.
2026-08-24 22:32:01 +01:00

304 lines
10 KiB
Rust

//! What a probe returns.
use std::{path::PathBuf, time::Duration};
use arr_core::{
AudioTrack, DolbyVisionProfile, HdrFormat, ProbedMedia, Resolution, SubtitleCodec,
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"),
),
codec: subtitle_codec(stream.codec_name.as_deref()),
forced: stream.disposition.forced != 0,
sdh: stream.disposition.hearing_impaired != 0,
}
}
/// `ffprobe`'s codec names for the formats §15 splits on. Bitmap tracks
/// (PGS, `VobSub`) satisfy viewing and never extract; anything unrecognised is
/// [`SubtitleCodec::Other`], which extracts nothing either.
fn subtitle_codec(codec_name: Option<&str>) -> SubtitleCodec {
match codec_name {
Some("subrip" | "srt") => SubtitleCodec::SubRip,
Some("ass" | "ssa") => SubtitleCodec::Ass,
Some("mov_text") => SubtitleCodec::MovText,
Some("hdmv_pgs_subtitle") => SubtitleCodec::Pgs,
Some("dvd_subtitle") => SubtitleCodec::VobSub,
_ => SubtitleCodec::Other,
}
}
/// 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;
use crate::model::subtitle_codec;
use arr_core::SubtitleCodec;
#[test]
fn matroska_timecodes_parse() {
assert_eq!(
timecode("00:01:30.500000000"),
Some(Duration::from_millis(90_500))
);
assert_eq!(timecode("nonsense"), None);
}
/// The §15 split, spelled exactly as ffprobe spells the codec names.
#[test]
fn text_and_bitmap_codecs_are_told_apart() {
assert_eq!(subtitle_codec(Some("subrip")), SubtitleCodec::SubRip);
assert_eq!(subtitle_codec(Some("ass")), SubtitleCodec::Ass);
assert_eq!(subtitle_codec(Some("mov_text")), SubtitleCodec::MovText);
assert_eq!(
subtitle_codec(Some("hdmv_pgs_subtitle")),
SubtitleCodec::Pgs
);
assert_eq!(subtitle_codec(Some("dvd_subtitle")), SubtitleCodec::VobSub);
assert_eq!(subtitle_codec(Some("dvb_subtitle")), SubtitleCodec::Other);
assert_eq!(subtitle_codec(None), SubtitleCodec::Other);
// Only the three text formats may become a translation source.
assert!(SubtitleCodec::SubRip.is_text());
assert!(!SubtitleCodec::Pgs.is_text());
assert!(!SubtitleCodec::VobSub.is_text());
assert!(!SubtitleCodec::Other.is_text());
}
}