Generated
+1
@@ -193,6 +193,7 @@ dependencies = [
|
||||
"arr-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -97,6 +97,21 @@ pub fn episode_file_name(
|
||||
}
|
||||
}
|
||||
|
||||
/// A subtitle sidecar's filename (§15): the video's name, the language, and
|
||||
/// `srt` — `… [2160p][WEB-DL][HDR10].pt-PT.srt`. A machine translation
|
||||
/// carries an extra `.mt` segment so `ls` says which subtitles arr made.
|
||||
#[must_use]
|
||||
pub fn subtitle_name(video_name: &str, language: &Language, machine_translated: bool) -> String {
|
||||
let stem = video_name
|
||||
.rsplit_once('.')
|
||||
.map_or(video_name, |(stem, _)| stem);
|
||||
if machine_translated {
|
||||
format!("{stem}.{language}.mt.srt")
|
||||
} else {
|
||||
format!("{stem}.{language}.srt")
|
||||
}
|
||||
}
|
||||
|
||||
/// The §7.4 attribute tags, in a fixed order: resolution, source, HDR,
|
||||
/// Portuguese audio.
|
||||
///
|
||||
@@ -215,6 +230,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The §15 sidecar names: plain for a real subtitle, `.mt` for arr's own.
|
||||
#[test]
|
||||
fn subtitle_sidecars_carry_the_language_and_the_mt_marker() {
|
||||
let video = "Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].mkv";
|
||||
assert_eq!(
|
||||
subtitle_name(video, &Language::PortuguesePortugal, false),
|
||||
"Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].pt-PT.srt"
|
||||
);
|
||||
assert_eq!(
|
||||
subtitle_name(video, &Language::PortugueseBrazil, true),
|
||||
"Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].pt-BR.mt.srt"
|
||||
);
|
||||
}
|
||||
|
||||
/// The kids audit surface: a pt-PT track is tagged, SDR is not.
|
||||
#[test]
|
||||
fn portuguese_audio_is_tagged_and_sdr_is_not() {
|
||||
|
||||
@@ -315,9 +315,57 @@ pub struct AudioTrack {
|
||||
pub handler_name: Option<String>,
|
||||
}
|
||||
|
||||
/// How an embedded subtitle track is encoded (DESIGN.md §15).
|
||||
///
|
||||
/// The split that matters is text versus bitmap: a text track extracts to a
|
||||
/// sidecar SRT and can feed a translator, a bitmap one satisfies its language
|
||||
/// for viewing and nothing more. There is no OCR.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum SubtitleCodec {
|
||||
/// `subrip`, the format sidecars are written in.
|
||||
SubRip,
|
||||
/// Advanced `SubStation` Alpha, and SSA with it.
|
||||
Ass,
|
||||
/// MP4's timed text.
|
||||
MovText,
|
||||
/// Presentation graphics — the bitmap track on `BluRay`.
|
||||
Pgs,
|
||||
/// `VobSub` — the bitmap track on DVD.
|
||||
VobSub,
|
||||
/// Anything else `ffprobe` names that is not one of the above.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl fmt::Display for SubtitleCodec {
|
||||
/// `ffprobe`'s codec name, so the probe column spells what the file said.
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::SubRip => "subrip",
|
||||
Self::Ass => "ass",
|
||||
Self::MovText => "mov_text",
|
||||
Self::Pgs => "hdmv_pgs_subtitle",
|
||||
Self::VobSub => "dvd_subtitle",
|
||||
Self::Other => "other",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SubtitleCodec {
|
||||
/// §15: only these extract to SRT and may become a translation source.
|
||||
#[must_use]
|
||||
pub const fn is_text(self) -> bool {
|
||||
matches!(self, Self::SubRip | Self::Ass | Self::MovText)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SubtitleTrack {
|
||||
pub language: Language,
|
||||
pub codec: SubtitleCodec,
|
||||
/// Foreign lines and on-screen signs only. Never satisfies a want (§15).
|
||||
pub forced: bool,
|
||||
/// Complete, with sound descriptions. Satisfies, ranked below plain (§15).
|
||||
pub sdh: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
||||
@@ -1038,6 +1038,9 @@ mod tests {
|
||||
let mut media = probed_audio(vec![track(Language::PortuguesePortugal)]);
|
||||
media.subtitle_tracks = vec![SubtitleTrack {
|
||||
language: Language::PortugueseBrazil,
|
||||
codec: crate::SubtitleCodec::SubRip,
|
||||
forced: false,
|
||||
sdh: false,
|
||||
}];
|
||||
assert_eq!(
|
||||
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
|
||||
|
||||
@@ -1199,7 +1199,14 @@ fn probed_json(media: &ProbedMedia) -> serde_json::Value {
|
||||
"sub_tracks": media
|
||||
.subtitle_tracks
|
||||
.iter()
|
||||
.map(|track| serde_json::json!({ "language": track.language.to_string() }))
|
||||
.map(|track| {
|
||||
serde_json::json!({
|
||||
"language": track.language.to_string(),
|
||||
"codec": track.codec.to_string(),
|
||||
"forced": track.forced,
|
||||
"sdh": track.sdh,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -94,6 +94,21 @@ pub enum Error {
|
||||
/// among them.
|
||||
#[error("no video file among the candidates")]
|
||||
NoCandidates,
|
||||
|
||||
/// `ffmpeg` failed extracting a subtitle track — a missing or bitmap
|
||||
/// stream included, which is how §15's "never extract an image track"
|
||||
/// surfaces when it is asked for anyway.
|
||||
#[error("ffmpeg failed extracting subtitle stream {stream} from {path}: {stderr}")]
|
||||
Extract {
|
||||
/// The video that was read.
|
||||
path: PathBuf,
|
||||
/// The subtitle stream that was mapped.
|
||||
stream: usize,
|
||||
/// Exit status, absent when the process was killed by a signal.
|
||||
status: Option<i32>,
|
||||
/// `ffmpeg`'s own diagnostics, truncated to something loggable.
|
||||
stderr: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Extracting an embedded text subtitle track to a sidecar SRT (§15).
|
||||
//!
|
||||
//! One `ffmpeg` invocation per track, spawned and left to die like `ffprobe`:
|
||||
//! no long-lived process, nothing to clean up at rest. The output is forced
|
||||
//! to SRT whatever the track's own format — `ass` and `mov_text` included —
|
||||
//! because §15's sidecars and every translation backend downstream speak SRT.
|
||||
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The binary invoked when nothing else is configured.
|
||||
pub const DEFAULT_BINARY: &str = "ffmpeg";
|
||||
|
||||
/// How long one extraction may take. Generous: a full-length PGS-to-SRT
|
||||
/// conversion of a 60 GB remux over a network mount is slow.
|
||||
pub const DEFAULT_EXTRACTION_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How much of `ffmpeg`'s stderr is kept in an error.
|
||||
const STDERR_LIMIT: usize = 512;
|
||||
|
||||
/// Extracts embedded subtitle tracks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Extractor {
|
||||
binary: OsString,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for Extractor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
binary: DEFAULT_BINARY.into(),
|
||||
timeout: DEFAULT_EXTRACTION_TIMEOUT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Extractor {
|
||||
/// An extractor that runs `ffmpeg` 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 extraction may take.
|
||||
#[must_use]
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Write one embedded subtitle track out as an SRT sidecar.
|
||||
///
|
||||
/// `subtitle_stream` is the track's position among the file's subtitle
|
||||
/// streams — the index into `ProbedMedia::subtitle_tracks`, not the
|
||||
/// container's absolute stream id. Only text codecs (`is_text`) extract;
|
||||
/// asking for a bitmap track fails in `ffmpeg`, so callers decide from
|
||||
/// [`SubtitleCodec::is_text`] first.
|
||||
///
|
||||
/// The write lands whole or not at all: `ffmpeg` writes beside the target
|
||||
/// under a dot-name and the result is renamed into place, so Jellyfin
|
||||
/// never sees a half-written sidecar.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::Spawn`] when `ffmpeg` is not installed,
|
||||
/// [`Error::Extract`] when it exits non-zero — which includes mapping a
|
||||
/// stream that does not exist or carries bitmaps — and
|
||||
/// [`Error::Timeout`] / [`Error::Io`] on the usual failures.
|
||||
pub async fn extract_srt(
|
||||
&self,
|
||||
video: &Path,
|
||||
subtitle_stream: usize,
|
||||
destination: &Path,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = destination.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|source| Error::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
let temp = partial_name(destination);
|
||||
let result = self.run(video, subtitle_stream, &temp).await;
|
||||
if result.is_err() {
|
||||
let _ = tokio::fs::remove_file(&temp).await;
|
||||
return result;
|
||||
}
|
||||
tokio::fs::rename(&temp, destination)
|
||||
.await
|
||||
.map_err(|source| Error::Io {
|
||||
path: destination.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn `ffmpeg` and wait for it to die.
|
||||
async fn run(&self, video: &Path, subtitle_stream: usize, temp: &Path) -> Result<()> {
|
||||
let map = format!("0:s:{subtitle_stream}");
|
||||
let mut command = Command::new(&self.binary);
|
||||
command
|
||||
.args(["-nostdin", "-v", "error", "-i"])
|
||||
.arg(video)
|
||||
.args(["-map", &map, "-c:s", "srt", "-f", "srt"])
|
||||
.arg(temp)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.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: video.to_path_buf(),
|
||||
source,
|
||||
})?,
|
||||
Err(_) => {
|
||||
return Err(Error::Timeout {
|
||||
path: video.to_path_buf(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr)
|
||||
.trim()
|
||||
.chars()
|
||||
.take(STDERR_LIMIT)
|
||||
.collect();
|
||||
Err(Error::Extract {
|
||||
path: video.to_path_buf(),
|
||||
stream: subtitle_stream,
|
||||
status: output.status.code(),
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The dot-name `ffmpeg` writes before the rename into place.
|
||||
fn partial_name(destination: &Path) -> PathBuf {
|
||||
let mut name = destination.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(".partial");
|
||||
let mut partial = destination.to_path_buf();
|
||||
partial.set_file_name(name);
|
||||
partial
|
||||
}
|
||||
@@ -53,13 +53,17 @@ impl Stream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream flags. Only `attached_pic` is read: an MP3 or FLAC with embedded
|
||||
/// artwork reports it as a video stream, and taking that for video would make
|
||||
/// an audio file look importable.
|
||||
/// Stream flags. `attached_pic` stops an MP3 or FLAC's embedded artwork
|
||||
/// passing for video; `forced` and `hearing_impaired` are §15's track
|
||||
/// dispositions.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct Disposition {
|
||||
#[serde(default)]
|
||||
pub attached_pic: u8,
|
||||
#[serde(default)]
|
||||
pub forced: u8,
|
||||
#[serde(rename = "hearing_impaired", default)]
|
||||
pub hearing_impaired: u8,
|
||||
}
|
||||
|
||||
/// Stream metadata. Matroska writes `HANDLER_NAME`, MP4 writes `handler_name`,
|
||||
|
||||
@@ -23,11 +23,19 @@ use std::{
|
||||
use tokio::process::Command;
|
||||
|
||||
pub mod error;
|
||||
mod extract;
|
||||
mod ffprobe;
|
||||
mod language;
|
||||
mod model;
|
||||
|
||||
// `unused_crate_dependencies` is a per-target lint and the library's own test
|
||||
// target links the dev-dependencies without using them. The real uses are in
|
||||
// `tests/`.
|
||||
#[cfg(test)]
|
||||
use tempfile as _;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use extract::{Extractor, DEFAULT_EXTRACTION_TIMEOUT};
|
||||
pub use model::{FeatureSelection, ProbedFile, RuntimeMatch};
|
||||
|
||||
/// The binary invoked when nothing else is configured.
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use arr_core::{AudioTrack, DolbyVisionProfile, HdrFormat, ProbedMedia, Resolution, SubtitleTrack};
|
||||
use arr_core::{
|
||||
AudioTrack, DolbyVisionProfile, HdrFormat, ProbedMedia, Resolution, SubtitleCodec,
|
||||
SubtitleTrack,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
@@ -148,6 +151,23 @@ fn subtitle_track(stream: &Stream) -> SubtitleTrack {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +268,8 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::timecode;
|
||||
use crate::model::subtitle_codec;
|
||||
use arr_core::SubtitleCodec;
|
||||
|
||||
#[test]
|
||||
fn matroska_timecodes_parse() {
|
||||
@@ -257,4 +279,25 @@ mod tests {
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
@@ -7,12 +7,12 @@
|
||||
|
||||
// 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 _, thiserror as _, tracing as _};
|
||||
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};
|
||||
use arr_probe::{Error, Prober, RuntimeMatch};
|
||||
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"))
|
||||
@@ -70,8 +70,88 @@ async fn a_feature_reports_every_track_it_carries() {
|
||||
&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]
|
||||
|
||||
@@ -19,19 +19,21 @@ trap 'rm -rf "$work"' EXIT
|
||||
|
||||
mkdir -p "$out"
|
||||
|
||||
# 1080p feature: three audio tracks covering all three §5.2 signals, plus two
|
||||
# subtitle tracks. Three seconds, so the runtime sanity check has something to
|
||||
# 1080p feature: three audio tracks covering all three §5.2 signals, plus
|
||||
# three subtitle tracks — two plain text, one carrying §15's forced
|
||||
# disposition. Three seconds, so the runtime sanity check has something to
|
||||
# compare against.
|
||||
printf '1\n00:00:00,000 --> 00:00:01,000\nhello\n\n' > "$work/en.srt"
|
||||
printf '1\n00:00:00,000 --> 00:00:01,000\nolá\n\n' > "$work/pt.srt"
|
||||
printf '1\n00:00:00,500 --> 00:00:01,500\nSIGN\n\n' > "$work/forced.srt"
|
||||
|
||||
ffmpeg -hide_banner -loglevel error -y \
|
||||
-f lavfi -i "color=c=black:s=1920x1080:r=24:d=3" \
|
||||
-f lavfi -i "sine=frequency=440:duration=3" \
|
||||
-f lavfi -i "sine=frequency=660:duration=3" \
|
||||
-f lavfi -i "sine=frequency=880:duration=3" \
|
||||
-i "$work/en.srt" -i "$work/pt.srt" \
|
||||
-map 0:v -map 1:a -map 2:a -map 3:a -map 4:s -map 5:s \
|
||||
-i "$work/en.srt" -i "$work/pt.srt" -i "$work/forced.srt" \
|
||||
-map 0:v -map 1:a -map 2:a -map 3:a -map 4:s -map 5:s -map 6:s \
|
||||
-c:v libx264 -preset ultrafast -crf 51 -pix_fmt yuv420p \
|
||||
-c:a aac -b:a 8k -c:s srt \
|
||||
-metadata:s:a:0 language=eng -metadata:s:a:0 title="English" \
|
||||
@@ -39,6 +41,7 @@ ffmpeg -hide_banner -loglevel error -y \
|
||||
-metadata:s:a:1 handler_name="Portuguese (Brazil)" \
|
||||
-metadata:s:a:2 language=pt-PT \
|
||||
-metadata:s:s:0 language=eng -metadata:s:s:1 language=por \
|
||||
-metadata:s:s:2 language=eng -disposition:s:2 forced \
|
||||
"$out/movie-1080p.mkv"
|
||||
|
||||
# The extras reel trap: larger on disk than the feature (noise compresses
|
||||
|
||||
Reference in New Issue
Block a user