Files
arr/crates/arr-probe/tests/probe.rs
T
Miguel Palhas 0bdf0103bd feat(arr): extract text subtitle tracks to srt
ffmpeg, spawned and left to die like ffprobe, maps one subtitle stream
and converts it to SRT under §15's sidecar name. Text formats become
legal translation sources; bitmap tracks never extract.
2026-08-24 22:32:26 +01:00

346 lines
11 KiB
Rust

//! Real `ffprobe` runs against the committed fixture clips (DESIGN.md §12).
//!
//! The clips are generated by `scripts/make-probe-fixtures.sh`. Every
//! assertion here is about output `ffprobe` actually produced, not a recorded
//! JSON document, because the shape of that output is exactly what breaks
//! between ffmpeg releases.
// Same per-target quirk as elsewhere in the workspace: an integration test
// links the library's dependencies without using them directly.
use {serde as _, serde_json as _, tempfile as _, thiserror as _, tracing as _};
use std::{path::PathBuf, time::Duration};
use arr_core::{DolbyVisionProfile, HdrFormat, Language, Resolution, SubtitleCodec};
use arr_probe::{Error, Extractor, Prober, RuntimeMatch};
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
#[tokio::test]
async fn a_feature_reports_every_track_it_carries() {
let file = Prober::new()
.probe(fixture("movie-1080p.mkv"))
.await
.expect("the fixture is readable");
assert_eq!(file.media.resolution, Resolution::R1080p);
assert_eq!(file.video_codec, "h264");
assert_eq!(file.media.hdr, HdrFormat::Sdr);
assert!(file.size > 0);
assert!(file.duration >= Duration::from_secs(3));
// A file never claims a source; that stays a claim of the release name.
assert_eq!(file.media.source, None);
let languages: Vec<&Language> = file
.media
.audio_tracks
.iter()
.map(|track| &track.language)
.collect();
assert_eq!(
languages,
vec![
&Language::Other("en".to_owned()),
&Language::PortugueseBrazil,
&Language::PortuguesePortugal,
]
);
let brazilian = &file.media.audio_tracks[1];
assert_eq!(brazilian.title.as_deref(), Some("Português (Brasil)"));
assert_eq!(
brazilian.handler_name.as_deref(),
Some("Portuguese (Brazil)")
);
let subtitles: Vec<&Language> = file
.media
.subtitle_tracks
.iter()
.map(|track| &track.language)
.collect();
assert_eq!(
subtitles,
vec![
&Language::Other("en".to_owned()),
// `por` with nothing else to go on. Not guessed either way.
&Language::PortugueseUnverified,
&Language::Other("en".to_owned()),
]
);
// §15's split: every text format is named, and the forced disposition is
// read off the stream rather than guessed from the title.
let codecs: Vec<SubtitleCodec> = file
.media
.subtitle_tracks
.iter()
.map(|track| track.codec)
.collect();
assert_eq!(codecs, vec![SubtitleCodec::SubRip; 3]);
assert!(codecs.iter().all(|codec| codec.is_text()));
let forced: Vec<bool> = file
.media
.subtitle_tracks
.iter()
.map(|track| track.forced)
.collect();
assert_eq!(forced, vec![false, false, true]);
assert!(file.media.subtitle_tracks.iter().all(|track| !track.sdh));
}
/// A text track extracts to an SRT sidecar, whole, at the requested place.
#[tokio::test]
async fn a_text_track_extracts_to_srt() {
let work = tempfile::tempdir().expect("scratch dir");
let destination = work.path().join("movie-1080p.en.srt");
Extractor::new()
.extract_srt(&fixture("movie-1080p.mkv"), 0, &destination)
.await
.expect("a subrip track extracts");
let content = std::fs::read_to_string(&destination).expect("the sidecar was written");
assert!(content.contains("hello"), "{content:?}");
assert!(!work.path().join("movie-1080p.en.srt.partial").exists());
}
/// The stream index is the position among subtitle streams: index 1 carries
/// the Portuguese cues.
#[tokio::test]
async fn the_stream_index_counts_subtitle_streams_only() {
let work = tempfile::tempdir().expect("scratch dir");
let destination = work.path().join("movie-1080p.por-unverified.srt");
Extractor::new()
.extract_srt(&fixture("movie-1080p.mkv"), 1, &destination)
.await
.expect("the second subtitle stream extracts");
let content = std::fs::read_to_string(&destination).expect("the sidecar was written");
assert!(content.contains("olá"), "{content:?}");
}
/// A stream that does not exist is `ffmpeg`'s failure to report, not arr's.
#[tokio::test]
async fn an_out_of_range_stream_fails_the_extraction() {
let work = tempfile::tempdir().expect("scratch dir");
let destination = work.path().join("missing.srt");
let error = Extractor::new()
.extract_srt(&fixture("movie-1080p.mkv"), 9, &destination)
.await
.expect_err("there are only three subtitle streams");
assert!(matches!(error, Error::Extract { .. }), "{error:?}");
assert!(!destination.exists());
assert!(!work.path().join("missing.srt.partial").exists());
}
#[tokio::test]
async fn a_missing_ffmpeg_is_an_error() {
let error = Extractor::new()
.with_binary("ffmpeg-that-does-not-exist")
.extract_srt(&fixture("movie-1080p.mkv"), 0, &std::env::temp_dir())
.await
.expect_err("no binary to run");
assert!(matches!(error, Error::Spawn { .. }), "{error:?}");
}
#[tokio::test]
async fn hdr10_comes_from_the_transfer_function() {
let file = Prober::new()
.probe(fixture("hdr10-2160p.mkv"))
.await
.expect("the fixture is readable");
assert_eq!(file.media.resolution, Resolution::R2160p);
assert_eq!(file.video_codec, "hevc");
assert_eq!(file.media.hdr, HdrFormat::Hdr10);
}
#[tokio::test]
async fn hlg_comes_from_the_transfer_function() {
let file = Prober::new()
.probe(fixture("hlg-720p.mkv"))
.await
.expect("the fixture is readable");
assert_eq!(file.video_codec, "hevc");
assert_eq!(file.media.hdr, HdrFormat::Hlg);
}
#[tokio::test]
async fn ten_bit_hevc_without_hdr_transfer_is_sdr() {
let file = Prober::new()
.probe(fixture("sdr-10bit-hevc-720p.mkv"))
.await
.expect("the fixture is readable");
assert_eq!(file.video_codec, "hevc");
assert_eq!(file.media.hdr, HdrFormat::Sdr);
}
#[tokio::test]
async fn dolby_vision_profile_5_is_reported_as_a_number() {
let file = Prober::new()
.probe(fixture("dv-profile5.mp4"))
.await
.expect("the fixture is readable");
assert_eq!(
file.media.hdr,
HdrFormat::DolbyVision(DolbyVisionProfile {
profile: 5,
compatibility_id: Some(0),
})
);
}
#[tokio::test]
async fn dolby_vision_profile_8_1_keeps_its_compatibility_id() {
let file = Prober::new()
.probe(fixture("dv-profile8-1.mp4"))
.await
.expect("the fixture is readable");
assert_eq!(
file.media.hdr,
HdrFormat::DolbyVision(DolbyVisionProfile {
profile: 8,
compatibility_id: Some(1),
})
);
}
#[tokio::test]
async fn a_text_file_is_not_media() {
let error = Prober::new()
.probe(fixture("notes.nfo"))
.await
.expect_err("a text file is not media");
assert!(matches!(error, Error::NotVideo { .. }), "{error:?}");
}
/// Cover art is a video stream as far as ffprobe is concerned.
#[tokio::test]
async fn an_audio_file_with_artwork_is_not_video() {
let error = Prober::new()
.probe(fixture("audio-with-cover.m4a"))
.await
.expect_err("an attached picture is not a video stream");
assert!(matches!(error, Error::NoVideoStream { .. }), "{error:?}");
}
#[tokio::test]
async fn a_missing_file_is_an_error() {
let error = Prober::new()
.probe(fixture("does-not-exist.mkv"))
.await
.expect_err("nothing to probe");
assert!(matches!(error, Error::Rejected { .. }), "{error:?}");
}
#[tokio::test]
async fn a_missing_ffprobe_is_an_error() {
let error = Prober::new()
.with_binary("ffprobe-that-does-not-exist")
.probe(fixture("movie-1080p.mkv"))
.await
.expect_err("no binary to run");
assert!(matches!(error, Error::Spawn { .. }), "{error:?}");
}
/// The extras reel is the larger file on disk and the shorter one on the
/// clock. Size alone picks it; the runtime check does not.
#[tokio::test]
async fn runtime_beats_size_when_picking_the_feature() {
let selection = Prober::new()
.select_feature(
[
fixture("extras-360p.mkv"),
fixture("movie-1080p.mkv"),
fixture("notes.nfo"),
],
Some(Duration::from_secs(3)),
)
.await
.expect("one of them is a video file");
assert_eq!(selection.feature.path, fixture("movie-1080p.mkv"));
assert_eq!(selection.runtime, RuntimeMatch::Matched);
assert_eq!(selection.others.len(), 1);
assert_eq!(selection.others[0].path, fixture("extras-360p.mkv"));
assert!(selection.others[0].size > selection.feature.size);
}
#[tokio::test]
async fn without_an_expected_runtime_the_largest_file_wins() {
let selection = Prober::new()
.select_feature(
[fixture("movie-1080p.mkv"), fixture("extras-360p.mkv")],
None,
)
.await
.expect("both are video files");
assert_eq!(selection.feature.path, fixture("extras-360p.mkv"));
assert_eq!(selection.runtime, RuntimeMatch::Unchecked);
}
#[tokio::test]
async fn nothing_within_tolerance_still_returns_the_largest_file() {
let expected = Duration::from_hours(2);
let selection = Prober::new()
.select_feature(
[fixture("movie-1080p.mkv"), fixture("extras-360p.mkv")],
Some(expected),
)
.await
.expect("both are video files");
assert_eq!(selection.feature.path, fixture("extras-360p.mkv"));
assert!(
matches!(
selection.runtime,
RuntimeMatch::Mismatched { expected: e, .. } if e == expected
),
"{:?}",
selection.runtime
);
}
/// A prober that cannot run is not the same fact as a torrent without video,
/// and must not be reported as one.
#[tokio::test]
async fn a_broken_prober_fails_the_selection() {
let error = Prober::new()
.with_binary("ffprobe-that-does-not-exist")
.select_feature([fixture("movie-1080p.mkv")], None)
.await
.expect_err("nothing could be probed");
assert!(matches!(error, Error::Spawn { .. }), "{error:?}");
}
#[tokio::test]
async fn a_torrent_with_no_video_file_selects_nothing() {
let error = Prober::new()
.select_feature([fixture("notes.nfo")], None)
.await
.expect_err("no video among the candidates");
assert!(matches!(error, Error::NoCandidates), "{error:?}");
}