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.
This commit is contained in:
Miguel Palhas
2026-08-24 22:32:26 +01:00
parent e852f42a9c
commit 0bdf0103bd
6 changed files with 255 additions and 2 deletions
+1
View File
@@ -15,6 +15,7 @@ tokio.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
[lints]
+15
View File
@@ -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 {
+169
View File
@@ -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
}
+8
View File
@@ -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.
+61 -2
View File
@@ -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, SubtitleCodec};
use arr_probe::{Error, Prober, RuntimeMatch};
use arr_probe::{Error, Extractor, Prober, RuntimeMatch};
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -95,6 +95,65 @@ async fn a_feature_reports_every_track_it_carries() {
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()