use crate::language::{Language, iso_languages};
use once_cell::sync::Lazy;
use regex::Regex;
const TAG_VOCAB: &[&str] = &["forced", "foreign", "default", "cc", "psdh", "sdh"];
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SubtitleTitleInfo {
pub language_tags: Vec<String>,
pub language: Language,
pub raw_title: Option<String>,
pub title: Option<String>,
pub copy: i32,
pub title_first: bool,
}
pub mod regexes {
use super::{Lazy, Regex};
pub static SUBTITLE_LANGUAGE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)^.+?(?:[-_. ](?:forced|foreign|default|cc|psdh|sdh))*[-_. ](?P<iso_code>[a-z]{2,3})(?:[-_. ](?:forced|foreign|default|cc|psdh|sdh))*$",
)
.expect("SUBTITLE_LANGUAGE_REGEX must compile")
});
pub static SUBTITLE_LANGUAGE_TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)^.+?(?:\.(?:forced|foreign|default|cc|psdh|sdh|[a-z]{2,3}))*[-_. ](?P<title>[^.]*)(?:\.(?:forced|foreign|default|cc|psdh|sdh|[a-z]{2,3}))*$",
)
.expect("SUBTITLE_LANGUAGE_TITLE_REGEX must compile")
});
pub static SUBTITLE_TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?:(?P<title>.+) - )?(?P<copy>\d{1,3})$")
.expect("SUBTITLE_TITLE_REGEX must compile")
});
}
pub fn parse_subtitle_language(file_name: &str) -> Language {
let simple = strip_last_extension(file_name);
if let Some(caps) = regexes::SUBTITLE_LANGUAGE_REGEX.captures(simple)
&& let Some(iso) = caps.name("iso_code")
{
return iso_languages::find(&iso.as_str().to_ascii_lowercase())
.unwrap_or(Language::Unknown);
}
for language in ALL_LANGUAGES_FOR_ENDS_WITH {
let name = language.name();
if ends_with_ignore_ascii_case(simple, name) {
return *language;
}
}
Language::Unknown
}
pub fn parse_basic_subtitle(file_name: &str) -> SubtitleTitleInfo {
SubtitleTitleInfo {
title_first: false,
language_tags: parse_language_tags(file_name),
language: parse_subtitle_language(file_name),
..Default::default()
}
}
pub fn parse_subtitle_language_information(file_name: &str) -> SubtitleTitleInfo {
let simple = strip_last_extension(file_name);
let Some(caps) = regexes::SUBTITLE_LANGUAGE_TITLE_REGEX.captures(simple) else {
return parse_basic_subtitle(file_name);
};
let title_match = match caps.name("title") {
Some(m) => m,
None => return parse_basic_subtitle(file_name),
};
let title_start = title_match.start();
let title_end = title_match.end();
let title_sep_idx = title_start.saturating_sub(1);
let pre_section = &simple[..title_sep_idx];
let post_section = &simple[title_end..];
let (pre_iso_codes, pre_tags) = scan_dot_segments_from_right(pre_section);
let (post_iso_codes, post_tags) = scan_dot_segments_from_left(post_section);
let iso_count = pre_iso_codes.len() + post_iso_codes.len();
if iso_count != 1 {
return parse_basic_subtitle(file_name);
}
let iso_code = pre_iso_codes
.first()
.or_else(|| post_iso_codes.first())
.expect("iso_count==1 guarantees at least one iso_code");
let language = iso_languages::find(&iso_code.to_ascii_lowercase()).unwrap_or(Language::Unknown);
let language_tags: Vec<String> = pre_tags
.iter()
.chain(post_tags.iter())
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase())
.collect();
let raw_title = caps.name("title").map(|m| m.as_str().to_string());
let title_first = pre_tags.is_empty();
let mut info = SubtitleTitleInfo {
title_first,
language_tags,
raw_title,
language,
..Default::default()
};
update_title_and_copy_from_title(&mut info);
info
}
pub fn parse_language_tags(file_name: &str) -> Vec<String> {
let simple = strip_last_extension(file_name);
let Some(caps) = regexes::SUBTITLE_LANGUAGE_REGEX.captures(simple) else {
return Vec::new();
};
let Some(iso) = caps.name("iso_code") else {
return Vec::new();
};
collect_tags_around(simple, iso.start(), iso.end())
}
fn update_title_and_copy_from_title(info: &mut SubtitleTitleInfo) {
let Some(raw) = info.raw_title.as_deref() else {
info.title = None;
info.copy = 0;
return;
};
if let Some(caps) = regexes::SUBTITLE_TITLE_REGEX.captures(raw)
&& let Some(copy) = caps.name("copy")
{
if !is_valid_copy_match(raw, copy.start(), copy.end()) {
info.title = Some(raw.to_string());
info.copy = 0;
return;
}
info.title = caps.name("title").map(|m| m.as_str().to_string());
info.copy = copy.as_str().parse().unwrap_or(0);
return;
}
info.title = Some(raw.to_string());
info.copy = 0;
}
fn is_valid_copy_match(raw: &str, copy_start: usize, copy_end: usize) -> bool {
let bytes = raw.as_bytes();
if copy_start > 0 && bytes[copy_start - 1].is_ascii_digit() {
return false;
}
if copy_end < bytes.len() && bytes[copy_end].is_ascii_digit() {
return false;
}
true
}
fn strip_last_extension(file_name: &str) -> &str {
let bare = match file_name.rfind(['/', '\\']) {
Some(idx) => &file_name[idx + 1..],
None => file_name,
};
match bare.rfind('.') {
None => bare,
Some(idx) => &bare[..idx],
}
}
fn ends_with_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
if needle.len() > haystack.len() {
return false;
}
let tail = &haystack.as_bytes()[haystack.len() - needle.len()..];
tail.eq_ignore_ascii_case(needle.as_bytes())
}
fn collect_tags_around(simple: &str, iso_start: usize, iso_end: usize) -> Vec<String> {
let mut left_tags = Vec::new();
let mut cursor = iso_start;
while cursor > 0 {
let Some((tag, new_cursor)) = pop_left_tag_segment(simple, cursor) else {
break;
};
left_tags.push(tag);
cursor = new_cursor;
}
left_tags.reverse();
let mut right_tags = Vec::new();
let mut cursor = iso_end;
while cursor < simple.len() {
let Some((tag, new_cursor)) = pop_right_tag_segment(simple, cursor) else {
break;
};
right_tags.push(tag);
cursor = new_cursor;
}
left_tags.append(&mut right_tags);
left_tags
}
fn pop_left_tag_segment(simple: &str, cursor: usize) -> Option<(String, usize)> {
let bytes = simple.as_bytes();
if cursor == 0 {
return None;
}
let sep_idx = cursor.checked_sub(1)?;
let sep = bytes[sep_idx];
if !is_subtitle_separator(sep) {
return None;
}
let mut tok_start = sep_idx;
while tok_start > 0 && !is_subtitle_separator(bytes[tok_start - 1]) {
tok_start -= 1;
}
let token = &simple[tok_start..sep_idx];
if !is_tag_token(token) {
return None;
}
Some((token.to_ascii_lowercase(), tok_start))
}
fn pop_right_tag_segment(simple: &str, cursor: usize) -> Option<(String, usize)> {
let bytes = simple.as_bytes();
if cursor >= bytes.len() {
return None;
}
let sep = bytes[cursor];
if !is_subtitle_separator(sep) {
return None;
}
let tok_start = cursor + 1;
let mut tok_end = tok_start;
while tok_end < bytes.len() && !is_subtitle_separator(bytes[tok_end]) {
tok_end += 1;
}
let token = &simple[tok_start..tok_end];
if !is_tag_token(token) {
return None;
}
Some((token.to_ascii_lowercase(), tok_end))
}
fn scan_dot_segments_from_right(section: &str) -> (Vec<String>, Vec<String>) {
let mut iso_codes = Vec::new();
let mut tags = Vec::new();
if section.is_empty() {
return (iso_codes, tags);
}
let bytes = section.as_bytes();
let mut consumed: Vec<(String, bool)> = Vec::new();
let mut cursor = bytes.len();
while cursor > 0 {
let Some(dot) = section[..cursor].rfind('.') else {
break;
};
let token = §ion[dot + 1..cursor];
if token.is_empty() {
break;
}
let is_iso = is_iso_code_token(token);
let is_tag = is_tag_token(token);
if !is_iso && !is_tag {
break;
}
if is_tag {
consumed.push((token.to_ascii_lowercase(), false));
} else {
consumed.push((token.to_string(), true));
}
cursor = dot;
}
consumed.reverse();
for (tok, is_iso_flag) in consumed {
if is_iso_flag {
iso_codes.push(tok);
} else {
tags.push(tok);
}
}
(iso_codes, tags)
}
fn scan_dot_segments_from_left(section: &str) -> (Vec<String>, Vec<String>) {
let mut iso_codes = Vec::new();
let mut tags = Vec::new();
if section.is_empty() {
return (iso_codes, tags);
}
if !section.starts_with('.') {
return (iso_codes, tags);
}
for token in section.split('.').skip(1) {
if token.is_empty() {
break;
}
let is_iso = is_iso_code_token(token);
let is_tag = is_tag_token(token);
if !is_iso && !is_tag {
break;
}
if is_tag {
tags.push(token.to_ascii_lowercase());
} else {
iso_codes.push(token.to_string());
}
}
(iso_codes, tags)
}
fn is_iso_code_token(token: &str) -> bool {
let n = token.len();
if n != 2 && n != 3 {
return false;
}
token.bytes().all(|b| b.is_ascii_alphabetic())
}
fn is_tag_token(token: &str) -> bool {
TAG_VOCAB
.iter()
.any(|t| t.len() == token.len() && t.eq_ignore_ascii_case(token))
}
fn is_subtitle_separator(b: u8) -> bool {
matches!(b, b'-' | b'_' | b'.' | b' ')
}
const ALL_LANGUAGES_FOR_ENDS_WITH: &[Language] = &[
Language::English,
Language::French,
Language::Spanish,
Language::German,
Language::Italian,
Language::Danish,
Language::Dutch,
Language::Japanese,
Language::Icelandic,
Language::Chinese,
Language::Russian,
Language::Polish,
Language::Vietnamese,
Language::Swedish,
Language::Norwegian,
Language::Finnish,
Language::Turkish,
Language::Portuguese,
Language::Flemish,
Language::Greek,
Language::Korean,
Language::Hungarian,
Language::Hebrew,
Language::Lithuanian,
Language::Czech,
Language::Arabic,
Language::Hindi,
Language::Bulgarian,
Language::Malayalam,
Language::Ukrainian,
Language::Slovak,
Language::Thai,
Language::PortugueseBrazil,
Language::SpanishLatino,
Language::Romanian,
Language::Latvian,
Language::Persian,
Language::Catalan,
Language::Croatian,
Language::Serbian,
Language::Bosnian,
Language::Estonian,
Language::Tamil,
Language::Indonesian,
Language::Macedonian,
Language::Slovenian,
Language::Original,
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_subtitle_extracts_eng_iso_code() {
let lang = parse_subtitle_language("Show.S01E01.eng.srt");
assert_eq!(lang, Language::English);
}
#[test]
fn parse_subtitle_extracts_fre_iso_code() {
assert_eq!(
parse_subtitle_language("Show.S01E01.fre.srt"),
Language::French
);
}
#[test]
fn parse_basic_subtitle_returns_lang_and_tags() {
let r = parse_basic_subtitle("Show.S01E01.eng.forced.srt");
assert_eq!(r.language, Language::English);
assert!(r.language_tags.contains(&"forced".to_string()));
}
#[test]
fn parse_language_tags_extracts_sdh() {
let tags = parse_language_tags("Show.S01E01.eng.sdh.srt");
assert!(tags.contains(&"sdh".to_string()));
}
#[test]
fn fixture_subtitle_unknown_when_no_iso_code() {
assert_eq!(
parse_subtitle_language("Series Title - S01E01 - Pilot.sub"),
Language::Unknown
);
}
#[test]
fn fixture_subtitle_english_via_iso_codes_and_endswith() {
let cases = [
"Series Title - S01E01 - Pilot.en.sub",
"Series Title - S01E01 - Pilot.EN.sub",
"Series Title - S01E01 - Pilot.eng.sub",
"Series Title - S01E01 - Pilot.ENG.sub",
"Series Title - S01E01 - Pilot.English.sub",
"Series Title - S01E01 - Pilot.english.sub",
"Series Title - S01E01 - Pilot.en.cc.sub",
"Series Title - S01E01 - Pilot.en.sdh.sub",
"Series Title - S01E01 - Pilot.en.forced.sub",
"Series Title - S01E01 - Pilot.en.sdh.forced.sub",
];
for case in cases {
assert_eq!(
parse_subtitle_language(case),
Language::English,
"case = {case}"
);
}
}
#[test]
fn fixture_parse_subtitle_language_information_eng_with_default_forced() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].testtitle.default.eng.forced.ass",
);
assert_eq!(info.language, Language::English);
assert_eq!(
info.language_tags,
vec!["default".to_string(), "forced".to_string()]
);
assert_eq!(info.title.as_deref(), Some("testtitle"));
}
#[test]
fn fixture_parse_subtitle_language_information_fra_with_default_forced() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].testtitle.default.fra.forced.ass",
);
assert_eq!(info.language, Language::French);
assert_eq!(
info.language_tags,
vec!["default".to_string(), "forced".to_string()]
);
assert_eq!(info.title.as_deref(), Some("testtitle"));
}
#[test]
fn fixture_parse_subtitle_language_information_ru_dashed_title() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].ru-something-else.srt",
);
assert_eq!(info.language, Language::Russian);
assert!(
info.language_tags.is_empty(),
"got {:?}",
info.language_tags
);
assert_eq!(info.title.as_deref(), Some("something-else"));
}
#[test]
fn fixture_parse_subtitle_language_information_full_subtitles_title() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].Full Subtitles.eng.ass",
);
assert_eq!(info.language, Language::English);
assert!(
info.language_tags.is_empty(),
"got {:?}",
info.language_tags
);
assert_eq!(info.title.as_deref(), Some("Full Subtitles"));
}
#[test]
fn fixture_parse_subtitle_language_information_dash_one_copy_strip() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].mytitle - 1.en.ass",
);
assert_eq!(info.language, Language::English);
assert_eq!(info.title.as_deref(), Some("mytitle"));
assert_eq!(info.copy, 1);
}
#[test]
fn fixture_parse_subtitle_language_information_space_one_no_strip() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].mytitle 1.en.ass",
);
assert_eq!(info.language, Language::English);
assert_eq!(info.title.as_deref(), Some("mytitle 1"));
assert_eq!(info.copy, 0);
}
#[test]
fn fixture_parse_subtitle_language_information_no_copy() {
let info =
parse_subtitle_language_information("Name (2020) - S01E20 - [AAC 2.0].mytitle.en.ass");
assert_eq!(info.language, Language::English);
assert_eq!(info.title.as_deref(), Some("mytitle"));
assert_eq!(info.copy, 0);
}
#[test]
fn fixture_parse_subtitle_language_information_no_iso_code_falls_back_to_basic() {
let cases = [
"Name (2020) - S01E20 - [AAC 2.0].default.forced.ass",
"Name (2020) - S01E20 - [AAC 2.0].default.ass",
"Name (2020) - S01E20 - [AAC 2.0].ass",
"Name (2020) - S01E20 - [AAC 2.0].testtitle.ass",
];
for case in cases {
let info = parse_subtitle_language_information(case);
assert_eq!(info.language, Language::Unknown, "case = {case}");
assert!(info.language_tags.is_empty(), "case = {case}");
assert!(info.raw_title.is_none(), "case = {case}");
}
}
#[test]
fn parse_subtitle_endswith_after_extension_strip() {
assert_eq!(
parse_subtitle_language("Show - Pilot.German.sub"),
Language::German
);
assert_eq!(
parse_subtitle_language("Show - Pilot.NotALanguage.sub"),
Language::Unknown
);
}
#[test]
fn parse_subtitle_endswith_portuguese_brazil() {
assert_eq!(
parse_subtitle_language("Show - Pilot.Portuguese (Brazil).sub"),
Language::PortugueseBrazil
);
}
#[test]
fn parse_language_tags_empty_when_no_match() {
assert!(parse_language_tags("Pilot.sub").is_empty());
assert!(parse_language_tags("file_with_no_extension").is_empty());
}
#[test]
fn parse_language_tags_handles_pre_iso_tags() {
let tags = parse_language_tags("Pilot.forced.eng.srt");
assert_eq!(tags, vec!["forced".to_string()]);
}
#[test]
fn parse_language_tags_handles_post_iso_tags() {
let tags = parse_language_tags("Pilot.eng.sdh.forced.srt");
assert_eq!(tags, vec!["sdh".to_string(), "forced".to_string()]);
}
#[test]
fn parse_language_tags_drops_non_vocab_words() {
let tags = parse_language_tags("Pilot.junk.eng.srt");
assert!(tags.is_empty(), "got {tags:?}");
}
#[test]
fn parse_basic_subtitle_title_first_is_false() {
let r = parse_basic_subtitle("Show.S01E01.eng.srt");
assert!(!r.title_first);
}
#[test]
fn parse_subtitle_language_information_title_first_true_when_no_pre_segments() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].testtitle.eng.forced.ass",
);
assert!(info.title_first, "got {info:?}");
}
#[test]
fn parse_subtitle_language_information_title_first_true_with_pre_iso_only() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].eng.testtitle.forced.ass",
);
assert!(info.title_first, "got {info:?}");
}
#[test]
fn parse_subtitle_language_information_title_first_false_with_pre_tag() {
let info = parse_subtitle_language_information(
"Name (2020) - S01E20 - [AAC 2.0].default.eng.testtitle.forced.ass",
);
assert!(!info.title_first, "got {info:?}");
}
#[test]
fn parse_subtitle_unknown_for_pure_garbage() {
assert_eq!(
parse_subtitle_language("garbage_no_match"),
Language::Unknown
);
}
#[test]
fn strip_last_extension_handles_empty_and_dotfiles() {
assert_eq!(strip_last_extension(""), "");
assert_eq!(strip_last_extension(".bashrc"), "");
assert_eq!(strip_last_extension("a.b.c"), "a.b");
assert_eq!(strip_last_extension("foo"), "foo");
assert_eq!(strip_last_extension("foo.bar"), "foo");
}
#[test]
fn strip_last_extension_strips_directories() {
assert_eq!(strip_last_extension("/foo/bar.zip/baz.eng.srt"), "baz.eng");
assert_eq!(strip_last_extension("/tmp/Pilot.eng.srt"), "Pilot.eng");
assert_eq!(strip_last_extension(r"C:\tmp\Pilot.eng.srt"), "Pilot.eng");
}
#[test]
fn ends_with_ignore_ascii_case_basic() {
assert!(ends_with_ignore_ascii_case("FooEnglish", "english"));
assert!(ends_with_ignore_ascii_case("FooEnglish", "ENGLISH"));
assert!(!ends_with_ignore_ascii_case("Foo", "FooBar"));
}
}