use std::collections::BTreeMap;
use std::env;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use tempfile::TempDir;
use tokio::process::Command;
use crate::config::ToolConfig;
use crate::{PgsOcrConfig, SubtitleFormat, TranslatorError, ocr::ocr_pgs_file_to_srt};
const DEFAULT_FFMPEG_BIN: &str = "ffmpeg";
const DEFAULT_FFPROBE_BIN: &str = "ffprobe";
const FFMPEG_ENV_VAR: &str = "SHINKAI_TRANSLATOR_FFMPEG_BIN";
const FFPROBE_ENV_VAR: &str = "SHINKAI_TRANSLATOR_FFPROBE_BIN";
const KNOWN_VIDEO_EXTENSIONS: &[&str] = &["avi", "m2ts", "m4v", "mkv", "mov", "mp4", "ts", "webm"];
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MediaInputKind {
SubtitleFile { format_hint: Option<SubtitleFormat> },
Video,
}
pub fn detect_input_kind(path: &Path, format_hint: Option<SubtitleFormat>) -> MediaInputKind {
if SubtitleFormat::detect_from_path(path).is_some() {
MediaInputKind::SubtitleFile { format_hint }
} else if is_known_video_path(path) {
MediaInputKind::Video
} else if format_hint.is_some() {
MediaInputKind::SubtitleFile { format_hint }
} else {
MediaInputKind::Video
}
}
pub fn is_supported_input_path(path: &Path) -> bool {
SubtitleFormat::detect_from_path(path).is_some() || is_known_video_path(path)
}
fn is_known_video_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
KNOWN_VIDEO_EXTENSIONS
.iter()
.any(|known| extension.eq_ignore_ascii_case(known))
})
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExternalToolConfig {
ffmpeg_bin: String,
ffprobe_bin: String,
}
impl Default for ExternalToolConfig {
fn default() -> Self {
Self::from_env()
}
}
impl ExternalToolConfig {
pub fn from_env() -> Self {
Self {
ffmpeg_bin: env::var(FFMPEG_ENV_VAR).unwrap_or_else(|_| DEFAULT_FFMPEG_BIN.to_owned()),
ffprobe_bin: env::var(FFPROBE_ENV_VAR)
.unwrap_or_else(|_| DEFAULT_FFPROBE_BIN.to_owned()),
}
}
pub fn from_tool_config(tool_config: &ToolConfig) -> Self {
Self {
ffmpeg_bin: env::var(FFMPEG_ENV_VAR)
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| tool_config.ffmpeg_bin.clone()),
ffprobe_bin: env::var(FFPROBE_ENV_VAR)
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| tool_config.ffprobe_bin.clone()),
}
}
pub fn ffmpeg_bin(&self) -> &str {
&self.ffmpeg_bin
}
pub fn ffprobe_bin(&self) -> &str {
&self.ffprobe_bin
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct StreamDisposition {
pub default: bool,
pub forced: bool,
pub hearing_impaired: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SelectedSubtitleStream {
index: usize,
subtitle_position: usize,
codec_name: String,
format: SubtitleFormat,
requires_ocr: bool,
language: Option<String>,
title: Option<String>,
tags: BTreeMap<String, String>,
disposition: StreamDisposition,
}
impl SelectedSubtitleStream {
pub fn index(&self) -> usize {
self.index
}
pub fn subtitle_position(&self) -> usize {
self.subtitle_position
}
pub fn codec_name(&self) -> &str {
&self.codec_name
}
pub fn format(&self) -> SubtitleFormat {
self.format
}
pub fn requires_ocr(&self) -> bool {
self.requires_ocr
}
pub fn language(&self) -> Option<&str> {
self.language.as_deref()
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn tags(&self) -> &BTreeMap<String, String> {
&self.tags
}
pub fn disposition(&self) -> &StreamDisposition {
&self.disposition
}
}
#[derive(Debug)]
pub struct VideoSubtitleJob {
extracted_subtitle_path: PathBuf,
selected_stream: SelectedSubtitleStream,
original_subtitle_stream_count: usize,
_temp_dir: TempDir,
}
impl VideoSubtitleJob {
pub fn subtitle_path(&self) -> &Path {
&self.extracted_subtitle_path
}
pub fn format(&self) -> SubtitleFormat {
self.selected_stream.format()
}
pub fn selected_stream(&self) -> &SelectedSubtitleStream {
&self.selected_stream
}
pub fn original_subtitle_stream_count(&self) -> usize {
self.original_subtitle_stream_count
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct VideoProbe {
subtitle_streams: Vec<ProbedSubtitleStream>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ProbedSubtitleStream {
index: usize,
subtitle_position: usize,
codec_name: String,
language: Option<String>,
title: Option<String>,
tags: BTreeMap<String, String>,
disposition: StreamDisposition,
}
pub async fn prepare_video_subtitle_job(
video_path: &Path,
tools: &ExternalToolConfig,
subtitle_stream_index: Option<usize>,
ocr_config: &PgsOcrConfig,
source_language_hint: Option<&str>,
) -> Result<VideoSubtitleJob, TranslatorError> {
let probe = probe_video(video_path, tools).await?;
let selected_stream = select_subtitle_stream(&probe, subtitle_stream_index)?;
let temp_dir = TempDir::new()?;
let extraction_plan = extraction_plan(selected_stream.codec_name()).ok_or_else(|| {
TranslatorError::UnsupportedFormat(format!(
"subtitle stream #{} uses unsupported codec {}",
selected_stream.index(),
selected_stream.codec_name()
))
})?;
let extracted_subtitle_path = if selected_stream.requires_ocr() {
let raw_subtitle_path = temp_dir
.path()
.join(format!("extracted.{}", extraction_plan.extracted_extension));
extract_subtitle_stream(video_path, &selected_stream, &raw_subtitle_path, tools).await?;
let ocr_output_path = temp_dir.path().join("extracted.ocr.srt");
let rendered = ocr_pgs_file_to_srt(
&raw_subtitle_path,
selected_stream.language(),
source_language_hint,
ocr_config,
)
.await?;
tokio::fs::write(&ocr_output_path, rendered).await?;
ocr_output_path
} else {
let text_subtitle_path = temp_dir
.path()
.join(format!("extracted.{}", selected_stream.format().extension()));
extract_subtitle_stream(video_path, &selected_stream, &text_subtitle_path, tools).await?;
text_subtitle_path
};
Ok(VideoSubtitleJob {
extracted_subtitle_path,
selected_stream,
original_subtitle_stream_count: probe.subtitle_streams.len(),
_temp_dir: temp_dir,
})
}
pub struct PgsExtractionResult {
pub selected_stream: SelectedSubtitleStream,
sup_path: PathBuf,
_temp_dir: TempDir,
}
impl PgsExtractionResult {
pub fn sup_path(&self) -> &Path {
&self.sup_path
}
pub fn selected_stream(&self) -> &SelectedSubtitleStream {
&self.selected_stream
}
}
pub async fn extract_pgs_stream(
video_path: &Path,
tools: &ExternalToolConfig,
subtitle_stream_index: Option<usize>,
) -> Result<PgsExtractionResult, TranslatorError> {
let probe = probe_video(video_path, tools).await?;
let selected_stream = select_subtitle_stream(&probe, subtitle_stream_index)?;
if !selected_stream.requires_ocr() {
return Err(TranslatorError::UnsupportedFormat(format!(
"subtitle stream #{} (codec: {}) is not an image-based stream requiring OCR",
selected_stream.index(),
selected_stream.codec_name()
)));
}
let temp_dir = TempDir::new()?;
let sup_path = temp_dir.path().join("extracted.sup");
extract_subtitle_stream(video_path, &selected_stream, &sup_path, tools).await?;
Ok(PgsExtractionResult {
selected_stream,
sup_path,
_temp_dir: temp_dir,
})
}
pub async fn count_subtitle_streams(
video_path: &Path,
tools: &ExternalToolConfig,
) -> Result<usize, TranslatorError> {
Ok(probe_video(video_path, tools).await?.subtitle_streams.len())
}
pub async fn mux_translated_subtitle(
video_path: &Path,
translated_subtitle_path: &Path,
output_path: &Path,
translated_format: SubtitleFormat,
selected_stream: Option<&SelectedSubtitleStream>,
original_subtitle_stream_count: usize,
target_language: &str,
tools: &ExternalToolConfig,
overwrite: bool,
) -> Result<(), TranslatorError> {
let new_subtitle_stream_index = original_subtitle_stream_count;
let subtitle_codec = mux_subtitle_codec(output_path, translated_format);
let language_tag = infer_language_tag(target_language);
let title = build_translated_title(selected_stream, target_language);
let mut command = Command::new(tools.ffmpeg_bin());
command
.arg("-v")
.arg("error")
.arg(if overwrite { "-y" } else { "-n" })
.arg("-i")
.arg(video_path)
.arg("-i")
.arg(translated_subtitle_path)
.arg("-map")
.arg("0")
.arg("-map")
.arg("1:0")
.arg("-c")
.arg("copy")
.arg(format!("-c:s:{new_subtitle_stream_index}"))
.arg(subtitle_codec)
.arg(format!("-metadata:s:s:{new_subtitle_stream_index}"))
.arg(format!("language={language_tag}"))
.arg(format!("-metadata:s:s:{new_subtitle_stream_index}"))
.arg(format!("title={title}"))
.arg(format!("-metadata:s:s:{new_subtitle_stream_index}"))
.arg(format!("handler_name={title}"));
for (key, value) in propagated_tags(selected_stream) {
command
.arg(format!("-metadata:s:s:{new_subtitle_stream_index}"))
.arg(format!("{key}={value}"));
}
command.arg(output_path);
run_tool(command, "ffmpeg mux").await?;
Ok(())
}
pub fn default_video_output_path(input: &Path, target_language: &str) -> PathBuf {
let stem = input
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("translated");
let extension = input
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("mkv");
let target = sanitize_language_tag(target_language);
input.with_file_name(format!("{stem}.{target}.{extension}"))
}
pub fn infer_language_tag(target_language: &str) -> String {
let trimmed = target_language.trim();
if trimmed.is_empty() {
return "und".to_owned();
}
let normalized = normalize_words(trimmed);
match normalized.as_str() {
"english" => "eng".to_owned(),
"portuguese" | "portuguese brazil" | "brazilian portuguese" => "por".to_owned(),
"spanish" | "espanol" => "spa".to_owned(),
"french" => "fra".to_owned(),
"german" => "deu".to_owned(),
"italian" => "ita".to_owned(),
"japanese" => "jpn".to_owned(),
"korean" => "kor".to_owned(),
"chinese" | "mandarin" => "zho".to_owned(),
_ if is_language_code(trimmed) => trimmed.to_ascii_lowercase(),
_ => sanitize_language_tag(trimmed),
}
}
async fn probe_video(
video_path: &Path,
tools: &ExternalToolConfig,
) -> Result<VideoProbe, TranslatorError> {
let mut command = Command::new(tools.ffprobe_bin());
command
.arg("-v")
.arg("error")
.arg("-print_format")
.arg("json")
.arg("-show_streams")
.arg(video_path);
let output = run_tool(command, "ffprobe probe").await?;
let payload: FfprobeOutput = serde_json::from_slice(&output.stdout)?;
let mut subtitle_streams = Vec::new();
for stream in payload.streams {
if stream.codec_type.as_deref() != Some("subtitle") {
continue;
}
let codec_name = stream.codec_name.unwrap_or_else(|| "unknown".to_owned());
let tags = stream.tags.unwrap_or_default();
let language = tag_value(&tags, &["language"]);
let title = tag_value(&tags, &["title", "handler_name", "HANDLER_NAME"]);
let disposition = stream.disposition.unwrap_or_default();
subtitle_streams.push(ProbedSubtitleStream {
index: stream.index,
subtitle_position: subtitle_streams.len(),
codec_name,
language,
title,
tags,
disposition: StreamDisposition {
default: disposition.default != 0,
forced: disposition.forced != 0,
hearing_impaired: disposition.hearing_impaired != 0,
},
});
}
if subtitle_streams.is_empty() {
return Err(TranslatorError::UnsupportedFormat(
"video does not contain subtitle streams".to_owned(),
));
}
Ok(VideoProbe { subtitle_streams })
}
fn select_subtitle_stream(
probe: &VideoProbe,
subtitle_stream_index: Option<usize>,
) -> Result<SelectedSubtitleStream, TranslatorError> {
if let Some(index) = subtitle_stream_index {
let stream = probe
.subtitle_streams
.iter()
.find(|stream| stream.index == index)
.ok_or_else(|| {
TranslatorError::InvalidConfig(format!(
"subtitle stream index {index} was not found in the input video"
))
})?;
return build_selected_stream(stream);
}
probe
.subtitle_streams
.iter()
.filter_map(|stream| score_stream(stream).map(|score| (score, stream)))
.max_by(|left, right| left.0.cmp(&right.0).then_with(|| right.1.index.cmp(&left.1.index)))
.map(|(_, stream)| build_selected_stream(stream))
.transpose()?
.ok_or_else(|| {
let codecs = probe
.subtitle_streams
.iter()
.map(describe_unsupported_stream)
.collect::<Vec<_>>()
.join(", ");
TranslatorError::UnsupportedFormat(format!(
"video only contains unsupported subtitle codecs: {codecs}. These subtitle tracks are image-based or otherwise unsupported for direct translation. Provide a text subtitle with --subtitle-input or OCR/extract one first"
))
})
}
fn build_selected_stream(
stream: &ProbedSubtitleStream,
) -> Result<SelectedSubtitleStream, TranslatorError> {
let plan = extraction_plan(&stream.codec_name).ok_or_else(|| {
TranslatorError::UnsupportedFormat(format!(
"subtitle stream #{} uses unsupported codec {}",
stream.index, stream.codec_name
))
})?;
Ok(SelectedSubtitleStream {
index: stream.index,
subtitle_position: stream.subtitle_position,
codec_name: stream.codec_name.clone(),
format: plan.output_format,
requires_ocr: plan.requires_ocr,
language: stream.language.clone(),
title: stream.title.clone(),
tags: stream.tags.clone(),
disposition: stream.disposition.clone(),
})
}
fn score_stream(stream: &ProbedSubtitleStream) -> Option<i64> {
let plan = extraction_plan(&stream.codec_name)?;
let mut score = match (plan.output_format, plan.requires_ocr) {
(SubtitleFormat::Ass, false) => 30,
(SubtitleFormat::Srt, false) => 20,
(SubtitleFormat::Vtt, false) => 10,
(SubtitleFormat::Srt, true) => 4,
_ => 1,
};
if stream.disposition.default {
score += 400;
}
if stream.disposition.forced {
score -= 350;
}
if stream.disposition.hearing_impaired {
score -= 100;
}
let descriptor = searchable_stream_descriptor(stream);
if contains_any(&descriptor, &["commentary", "comment", "director"]) {
score -= 500;
}
if contains_any(&descriptor, &["sign", "song", "lyrics", "lyric", "karaoke"]) {
score -= 250;
}
Some(score)
}
async fn extract_subtitle_stream(
video_path: &Path,
selected_stream: &SelectedSubtitleStream,
output_path: &Path,
tools: &ExternalToolConfig,
) -> Result<(), TranslatorError> {
let plan = extraction_plan(selected_stream.codec_name()).ok_or_else(|| {
TranslatorError::UnsupportedFormat(format!(
"subtitle stream #{} uses unsupported codec {}",
selected_stream.index(),
selected_stream.codec_name()
))
})?;
let mut command = Command::new(tools.ffmpeg_bin());
command
.arg("-v")
.arg("error")
.arg("-y")
.arg("-i")
.arg(video_path)
.arg("-map")
.arg(format!("0:{}", selected_stream.index()))
.arg("-vn")
.arg("-an")
.arg("-dn")
.arg("-c:s")
.arg(plan.extraction_codec)
.arg(output_path);
run_tool(command, "ffmpeg extraction").await?;
Ok(())
}
fn extraction_plan(codec_name: &str) -> Option<ExtractionPlan> {
match codec_name.trim().to_ascii_lowercase().as_str() {
"ass" | "ssa" => Some(ExtractionPlan {
extracted_extension: "ass",
extraction_codec: "ass",
output_format: SubtitleFormat::Ass,
requires_ocr: false,
}),
"subrip" | "srt" => Some(ExtractionPlan {
extracted_extension: "srt",
extraction_codec: "srt",
output_format: SubtitleFormat::Srt,
requires_ocr: false,
}),
"webvtt" => Some(ExtractionPlan {
extracted_extension: "vtt",
extraction_codec: "webvtt",
output_format: SubtitleFormat::Vtt,
requires_ocr: false,
}),
"mov_text" => Some(ExtractionPlan {
extracted_extension: "srt",
extraction_codec: "srt",
output_format: SubtitleFormat::Srt,
requires_ocr: false,
}),
"hdmv_pgs_subtitle" => Some(ExtractionPlan {
extracted_extension: "sup",
extraction_codec: "copy",
output_format: SubtitleFormat::Srt,
requires_ocr: true,
}),
_ => None,
}
}
struct ExtractionPlan {
extracted_extension: &'static str,
extraction_codec: &'static str,
output_format: SubtitleFormat,
requires_ocr: bool,
}
fn mux_subtitle_codec(output_path: &Path, translated_format: SubtitleFormat) -> &'static str {
match output_path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"mp4" | "m4v" | "mov" => "mov_text",
"webm" => "webvtt",
_ => match translated_format {
SubtitleFormat::Ass => "ass",
SubtitleFormat::Srt => "subrip",
SubtitleFormat::Vtt => "webvtt",
},
}
}
fn build_translated_title(
stream: Option<&SelectedSubtitleStream>,
target_language: &str,
) -> String {
let base = stream
.and_then(|stream| {
stream
.title()
.filter(|title| !title.trim().is_empty())
.map(ToOwned::to_owned)
.or_else(|| stream.language().map(|language| format!("subtitle {language}")))
})
.unwrap_or_else(|| "subtitle".to_owned());
format!("{base} [translated {target_language}]")
}
fn propagated_tags(stream: Option<&SelectedSubtitleStream>) -> Vec<(String, String)> {
let Some(stream) = stream else {
return Vec::new();
};
stream
.tags()
.iter()
.filter_map(|(key, value)| {
if value.trim().is_empty() {
return None;
}
let normalized_key = key.trim().to_ascii_lowercase();
if matches!(normalized_key.as_str(), "language" | "title" | "handler_name") {
return None;
}
Some((key.clone(), value.clone()))
})
.collect()
}
fn describe_unsupported_stream(stream: &ProbedSubtitleStream) -> String {
match stream.title.as_deref() {
Some(title) if !title.trim().is_empty() => {
format!("{} ({title})", stream.codec_name)
}
_ => stream.codec_name.clone(),
}
}
async fn run_tool(
mut command: Command,
tool_name: &str,
) -> Result<std::process::Output, TranslatorError> {
let output = command.output().await.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
TranslatorError::ExternalTool(format!(
"{tool_name} is not available. Install ffmpeg/ffprobe or override their paths with {FFMPEG_ENV_VAR} and {FFPROBE_ENV_VAR}"
))
} else {
TranslatorError::Io(error)
}
})?;
if output.status.success() {
return Ok(output);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let details = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("process exited with status {}", output.status)
};
Err(TranslatorError::ExternalTool(format!(
"{tool_name} failed: {details}"
)))
}
fn tag_value(tags: &BTreeMap<String, String>, names: &[&str]) -> Option<String> {
for name in names {
if let Some(value) = tags.iter().find_map(|(key, value)| {
key.eq_ignore_ascii_case(name)
.then(|| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}) {
return Some(value);
}
}
None
}
fn searchable_stream_descriptor(stream: &ProbedSubtitleStream) -> String {
let mut descriptor = String::new();
if let Some(title) = &stream.title {
descriptor.push_str(title);
descriptor.push(' ');
}
if let Some(handler_name) = tag_value(&stream.tags, &["handler_name", "HANDLER_NAME"]) {
descriptor.push_str(&handler_name);
descriptor.push(' ');
}
normalize_words(&descriptor)
}
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
fn is_language_code(value: &str) -> bool {
let trimmed = value.trim();
let bytes = trimmed.as_bytes();
matches!(bytes.len(), 2 | 3 | 5)
&& bytes.iter().all(|byte| byte.is_ascii_alphabetic() || *byte == b'-')
}
fn normalize_words(value: &str) -> String {
let mut normalized = String::new();
let mut previous_was_space = false;
for character in value.chars() {
if character.is_ascii_alphanumeric() {
normalized.push(character.to_ascii_lowercase());
previous_was_space = false;
} else if !previous_was_space && !normalized.is_empty() {
normalized.push(' ');
previous_was_space = true;
}
}
normalized.trim().to_owned()
}
fn sanitize_language_tag(target_language: &str) -> String {
let sanitized = target_language
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let sanitized = sanitized.trim_matches('-');
if sanitized.is_empty() {
"translated".to_owned()
} else {
sanitized.to_owned()
}
}
#[derive(Debug, Deserialize)]
struct FfprobeOutput {
#[serde(default)]
streams: Vec<FfprobeStream>,
}
#[derive(Debug, Deserialize)]
struct FfprobeStream {
index: usize,
codec_name: Option<String>,
codec_type: Option<String>,
#[serde(default)]
disposition: Option<FfprobeDisposition>,
#[serde(default)]
tags: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Default, Deserialize)]
struct FfprobeDisposition {
#[serde(default)]
default: i32,
#[serde(default)]
forced: i32,
#[serde(default)]
hearing_impaired: i32,
}
#[cfg(test)]
mod tests {
use super::{ProbedSubtitleStream, StreamDisposition, VideoProbe, infer_language_tag, select_subtitle_stream};
use std::collections::BTreeMap;
fn stream(
index: usize,
subtitle_position: usize,
codec_name: &str,
title: Option<&str>,
disposition: StreamDisposition,
) -> ProbedSubtitleStream {
let mut tags = BTreeMap::new();
if let Some(title) = title {
tags.insert("title".to_owned(), title.to_owned());
}
ProbedSubtitleStream {
index,
subtitle_position,
codec_name: codec_name.to_owned(),
language: Some("eng".to_owned()),
title: title.map(ToOwned::to_owned),
tags,
disposition,
}
}
#[test]
fn select_subtitle_stream_prefers_default_supported_text_track() {
let probe = VideoProbe {
subtitle_streams: vec![
stream(2, 0, "hdmv_pgs_subtitle", Some("English PGS"), StreamDisposition::default()),
stream(
3,
1,
"subrip",
Some("English"),
StreamDisposition {
default: true,
forced: false,
hearing_impaired: false,
},
),
stream(
4,
2,
"ass",
Some("Signs & Songs"),
StreamDisposition::default(),
),
],
};
let selected = select_subtitle_stream(&probe, None).expect("stream should be selected");
assert_eq!(selected.index(), 3);
assert_eq!(selected.codec_name(), "subrip");
}
#[test]
fn select_subtitle_stream_honors_explicit_index() {
let probe = VideoProbe {
subtitle_streams: vec![stream(2, 0, "subrip", Some("English"), StreamDisposition::default())],
};
let selected = select_subtitle_stream(&probe, Some(2)).expect("stream should be selected");
assert_eq!(selected.index(), 2);
}
#[test]
fn infer_language_tag_maps_common_language_names() {
assert_eq!(infer_language_tag("Portuguese (Brazil)"), "por");
assert_eq!(infer_language_tag("Japanese"), "jpn");
assert_eq!(infer_language_tag("pt-BR"), "pt-br");
}
}