Merge #189: extract text subtitle streams to SRT

Closes #189
This commit is contained in:
Miguel Palhas
2026-08-24 22:36:12 +01:00
14 changed files with 423 additions and 12 deletions
Generated
+1
View File
@@ -193,6 +193,7 @@ dependencies = [
"arr-core",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tracing",
+29
View File
@@ -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() {
+48
View File
@@ -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)]
+3
View File
@@ -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)),
+8 -1
View File
@@ -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<_>>(),
})
}
+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
}
+7 -3
View File
@@ -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`,
+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.
+44 -1
View File
@@ -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());
}
}
Binary file not shown.
+83 -3
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};
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]
+7 -4
View File
@@ -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