From e852f42a9c0c99e714f0018728c89fa7186fef52 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 22:31:41 +0100 Subject: [PATCH 1/2] feat(arr): name subtitle codecs and dispositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/arr-core/src/layout.rs | 29 +++++++++++ crates/arr-core/src/lib.rs | 48 ++++++++++++++++++ crates/arr-core/src/policy.rs | 3 ++ crates/arr-daemon/src/import.rs | 9 +++- crates/arr-probe/src/ffprobe.rs | 10 ++-- crates/arr-probe/src/model.rs | 45 +++++++++++++++- .../arr-probe/tests/fixtures/movie-1080p.mkv | Bin 29346 -> 29536 bytes crates/arr-probe/tests/probe.rs | 23 ++++++++- scripts/make-probe-fixtures.sh | 11 ++-- 9 files changed, 168 insertions(+), 10 deletions(-) diff --git a/crates/arr-core/src/layout.rs b/crates/arr-core/src/layout.rs index 68a30a1..0defad8 100644 --- a/crates/arr-core/src/layout.rs +++ b/crates/arr-core/src/layout.rs @@ -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() { diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 9e7b011..2d3e948 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -315,9 +315,57 @@ pub struct AudioTrack { pub handler_name: Option, } +/// 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)] diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index f9ab8bb..1b592a4 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -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)), diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index c26dcd0..06a1f82 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -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::>(), }) } diff --git a/crates/arr-probe/src/ffprobe.rs b/crates/arr-probe/src/ffprobe.rs index 8de0b46..88f9486 100644 --- a/crates/arr-probe/src/ffprobe.rs +++ b/crates/arr-probe/src/ffprobe.rs @@ -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`, diff --git a/crates/arr-probe/src/model.rs b/crates/arr-probe/src/model.rs index ad6097b..967d703 100644 --- a/crates/arr-probe/src/model.rs +++ b/crates/arr-probe/src/model.rs @@ -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()); + } } diff --git a/crates/arr-probe/tests/fixtures/movie-1080p.mkv b/crates/arr-probe/tests/fixtures/movie-1080p.mkv index 95297db1f4728a18c341b62fdf90fa9af8e5a334..75f0d066e33ac00e665774f604cd1631a929eb95 100644 GIT binary patch delta 558 zcmZ4Vl<~nc#tBBe#X5q%vr7)_Z<)_7Z$HsqOCZO0cTe!@7MbAP*}-d?imp$rOlHZg z5WGC`fref3dQIo;E1jcc=SJ?#n&Um+rL)6f`8);?5DQtC?Nq+MW!JP@G3yw?LJV!! z8ySm_b`<}qTiwf$x_h!4V}o++^+qP3*lVFzS#l<~AJ1uI2+nJF%}dY8EY48cI;S}` zFMVZ8?Jd%O+beWpQLJe`*~U&-rHZDkgs!{p*daKm|pUXH-19 zA@~qz5LA0dBg14NW@QfMb~mxjc3Zwowq;iIfH}$ddLuhf*V0?Y57t?fH=^hYUDe3g z)*T!l;u;a69~$Cj(cCB)`mT{dDCBUu)AIc-XB+YvCucEhbHF_`mpPCF=99^?tg@4z zvS{-CNFl(Epz=FPo9qo)?7=k_B{bn~Yzu4@lFYg?9VSfuJ=SkPy8#gyHZ14Ks x$oR2|P4YwI+Iq!-8d1bO2V}o+k^+qP3m}sZ1WisPJpE->T!FlbjdFeTs#TiOl=QOA0 zrB9Aya^TOr-pCA8H~(|h!`*$Sw@$WZ%HpVbXE!AyC-?Q_wM_n!I@cRnfeN~-tTY48 zuX_hH2&O%NS%zQ#dLtW9_LJj+Zt;+~Ws{wm6(=uXW@QiB-{N%qg~sGH%-S4qcim?W zxxZ!OHa>^l8#gyHZ14Ks$oR2|wdq6S PathBuf { @@ -70,8 +70,29 @@ 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 = 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 = 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)); } #[tokio::test] diff --git a/scripts/make-probe-fixtures.sh b/scripts/make-probe-fixtures.sh index 66636c2..e500837 100755 --- a/scripts/make-probe-fixtures.sh +++ b/scripts/make-probe-fixtures.sh @@ -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 From 0bdf0103bd18ecc16f21aa69fd1eb1290d5df744 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 22:32:26 +0100 Subject: [PATCH 2/2] feat(arr): extract text subtitle tracks to srt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + crates/arr-probe/Cargo.toml | 1 + crates/arr-probe/src/error.rs | 15 +++ crates/arr-probe/src/extract.rs | 169 ++++++++++++++++++++++++++++++++ crates/arr-probe/src/lib.rs | 8 ++ crates/arr-probe/tests/probe.rs | 63 +++++++++++- 6 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 crates/arr-probe/src/extract.rs diff --git a/Cargo.lock b/Cargo.lock index ec3a232..5e50d70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,7 @@ dependencies = [ "arr-core", "serde", "serde_json", + "tempfile", "thiserror", "tokio", "tracing", diff --git a/crates/arr-probe/Cargo.toml b/crates/arr-probe/Cargo.toml index c06fbfb..e412700 100644 --- a/crates/arr-probe/Cargo.toml +++ b/crates/arr-probe/Cargo.toml @@ -15,6 +15,7 @@ tokio.workspace = true tracing.workspace = true [dev-dependencies] +tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] diff --git a/crates/arr-probe/src/error.rs b/crates/arr-probe/src/error.rs index d1bc4c8..84f7ffc 100644 --- a/crates/arr-probe/src/error.rs +++ b/crates/arr-probe/src/error.rs @@ -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, + /// `ffmpeg`'s own diagnostics, truncated to something loggable. + stderr: String, + }, } impl Error { diff --git a/crates/arr-probe/src/extract.rs b/crates/arr-probe/src/extract.rs new file mode 100644 index 0000000..f368a14 --- /dev/null +++ b/crates/arr-probe/src/extract.rs @@ -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) -> 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 +} diff --git a/crates/arr-probe/src/lib.rs b/crates/arr-probe/src/lib.rs index 42cd143..a71eaea 100644 --- a/crates/arr-probe/src/lib.rs +++ b/crates/arr-probe/src/lib.rs @@ -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. diff --git a/crates/arr-probe/tests/probe.rs b/crates/arr-probe/tests/probe.rs index 66924f9..af88157 100644 --- a/crates/arr-probe/tests/probe.rs +++ b/crates/arr-probe/tests/probe.rs @@ -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()