feat(arr): name subtitle codecs and dispositions
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.
This commit is contained in:
@@ -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<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
@@ -11,7 +11,7 @@ use {serde as _, serde_json as _, thiserror as _, tracing as _};
|
||||
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use arr_core::{DolbyVisionProfile, HdrFormat, Language, Resolution};
|
||||
use arr_core::{DolbyVisionProfile, HdrFormat, Language, Resolution, SubtitleCodec};
|
||||
use arr_probe::{Error, Prober, RuntimeMatch};
|
||||
|
||||
fn fixture(name: &str) -> 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<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));
|
||||
}
|
||||
|
||||
#[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