diff --git a/crates/pstr-core/src/language.rs b/crates/pstr-core/src/language.rs new file mode 100644 index 0000000..f373ef4 --- /dev/null +++ b/crates/pstr-core/src/language.rs @@ -0,0 +1,126 @@ +//! Language tags: what a container or a file name calls a language, and what a +//! viewer calls it. + +/// The languages offered by name where a preference is chosen ahead of any +/// file, as the three-letter tag a Matroska file carries. The same list +/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two- +/// and three-letter forms as one. +pub const LANGUAGES: [&str; 29] = [ + "jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor", + "fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum", + "gre", "ind", "may", +]; + +/// Each language's name and the tags it goes by, the [`LANGUAGES`] form first. +/// +/// Deliberately a short list rather than a full ISO 639 table: it covers what +/// the shares this app is pointed at actually contain, and an unrecognised tag +/// still shows as something a viewer can pick between — `"por-BR"` reads fine +/// on its own. +const TABLE: [(&str, &[&str]); 29] = [ + ("English", &["eng", "en"]), + ("Japanese", &["jpn", "ja"]), + ("German", &["ger", "de", "deu"]), + ("French", &["fre", "fr", "fra"]), + ("Spanish", &["spa", "es"]), + ("Italian", &["ita", "it"]), + ("Portuguese", &["por", "pt"]), + ("Dutch", &["dut", "nl", "nld"]), + ("Russian", &["rus", "ru"]), + ("Polish", &["pol", "pl"]), + ("Swedish", &["swe", "sv"]), + ("Danish", &["dan", "da"]), + ("Norwegian", &["nor", "no"]), + ("Finnish", &["fin", "fi"]), + ("Czech", &["cze", "cs", "ces"]), + ("Hungarian", &["hun", "hu"]), + ("Turkish", &["tur", "tr"]), + ("Arabic", &["ara", "ar"]), + ("Hebrew", &["heb", "he"]), + ("Hindi", &["hin", "hi"]), + ("Korean", &["kor", "ko"]), + ("Chinese", &["chi", "zh", "zho"]), + ("Thai", &["tha", "th"]), + ("Vietnamese", &["vie", "vi"]), + ("Ukrainian", &["ukr", "uk"]), + ("Romanian", &["rum", "ro", "ron"]), + ("Greek", &["gre", "el", "ell"]), + ("Indonesian", &["ind", "id"]), + ("Malay", &["may", "ms", "msa"]), +]; + +/// A readable name for a language tag, falling back to the tag itself. +pub fn language_name(tag: &str) -> String { + // Container tags come in both the two- and three-letter forms, and often + // with a region suffix: "en", "eng", "en-US", "pt-BR". + let base = tag.split(['-', '_']).next().unwrap_or(tag); + let Some((name, _)) = TABLE + .iter() + .find(|(_, tags)| tags.iter().any(|known| known.eq_ignore_ascii_case(base))) + else { + return tag.to_string(); + }; + + // The region matters when there is one — Brazilian Portuguese is not + // Portuguese to anyone choosing between the two. + match tag.split_once(['-', '_']) { + Some((_, region)) if !region.is_empty() => { + format!("{name} ({})", region.to_ascii_uppercase()) + } + _ => (*name).to_string(), + } +} + +/// Whether two container tags name the same language: "en" and "eng" do. +pub fn same_language(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b) +} + +/// The [`LANGUAGES`] tag for one word of a file name, if it names a language: +/// "en", "eng" and "English" are all `"eng"`. +/// +/// For the words around a subtitle file's name — `Episode 01.en.srt`, +/// `2_English.srt` — where a two-letter word is far more often a language than +/// anything else. +pub fn language_tag(word: &str) -> Option<&'static str> { + TABLE + .iter() + .find(|(name, tags)| { + name.eq_ignore_ascii_case(word) || tags.iter().any(|tag| tag.eq_ignore_ascii_case(word)) + }) + .map(|(_, tags)| tags[0]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() { + assert_eq!(language_name("jpn"), "Japanese"); + assert_eq!(language_name("ja"), "Japanese"); + assert_eq!(language_name("pt-BR"), "Portuguese (BR)"); + // An unknown tag is shown as it stands rather than as "Unknown", which + // would make two of them indistinguishable. + assert_eq!(language_name("mis"), "mis"); + } + + #[test] + fn a_language_named_by_either_tag_or_by_its_name_has_the_tag_a_preference_uses() { + assert_eq!(language_tag("en"), Some("eng")); + assert_eq!(language_tag("ENG"), Some("eng")); + assert_eq!(language_tag("German"), Some("ger")); + assert_eq!(language_tag("deu"), Some("ger")); + assert_eq!(language_tag("forced"), None); + } + + #[test] + fn every_language_offered_by_name_is_one_the_table_knows() { + let firsts: Vec<&str> = TABLE.iter().map(|(_, tags)| tags[0]).collect(); + let mut offered = LANGUAGES.to_vec(); + let mut known = firsts.clone(); + offered.sort_unstable(); + known.sort_unstable(); + assert_eq!(offered, known); + } +} diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index 5b9245f..c998b35 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod chapters; pub mod config; pub mod error; pub mod franchise; +pub mod language; pub mod library; pub mod metadata; pub mod naming; diff --git a/crates/pstr-player/src/tracks.rs b/crates/pstr-player/src/tracks.rs index 7f2dcf0..ea1121a 100644 --- a/crates/pstr-player/src/tracks.rs +++ b/crates/pstr-player/src/tracks.rs @@ -152,11 +152,6 @@ pub fn pick_track( ) } -/// Whether two container tags name the same language: "en" and "eng" do. -fn same_language(a: &str, b: &str) -> bool { - a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b) -} - fn read_one(mpv: &Mpv, index: i64) -> Option { let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?; let id: i64 = mpv @@ -190,73 +185,7 @@ fn flag(mpv: &Mpv, index: i64, field: &str) -> bool { .unwrap_or(false) } -/// The languages offered by name where a preference is chosen ahead of any -/// file, as the three-letter tag a Matroska file carries. The same list -/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two- -/// and three-letter forms as one. -pub const LANGUAGES: [&str; 29] = [ - "jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor", - "fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum", - "gre", "ind", "may", -]; - -/// A readable name for a language tag, falling back to the tag itself. -/// -/// Deliberately a short list rather than a full ISO 639 table: it covers what -/// the shares this app is pointed at actually contain, and an unrecognised tag -/// still shows as something a viewer can pick between — `"por-BR"` reads fine -/// on its own. -pub fn language_name(tag: &str) -> String { - // Container tags come in both the two- and three-letter forms, and often - // with a region suffix: "en", "eng", "en-US", "pt-BR". - let base = tag - .split(['-', '_']) - .next() - .unwrap_or(tag) - .to_ascii_lowercase(); - - let name = match base.as_str() { - "en" | "eng" => "English", - "ja" | "jpn" => "Japanese", - "de" | "ger" | "deu" => "German", - "fr" | "fre" | "fra" => "French", - "es" | "spa" => "Spanish", - "it" | "ita" => "Italian", - "pt" | "por" => "Portuguese", - "nl" | "dut" | "nld" => "Dutch", - "ru" | "rus" => "Russian", - "pl" | "pol" => "Polish", - "sv" | "swe" => "Swedish", - "da" | "dan" => "Danish", - "no" | "nor" => "Norwegian", - "fi" | "fin" => "Finnish", - "cs" | "cze" | "ces" => "Czech", - "hu" | "hun" => "Hungarian", - "tr" | "tur" => "Turkish", - "ar" | "ara" => "Arabic", - "he" | "heb" => "Hebrew", - "hi" | "hin" => "Hindi", - "ko" | "kor" => "Korean", - "zh" | "chi" | "zho" => "Chinese", - "th" | "tha" => "Thai", - "vi" | "vie" => "Vietnamese", - "uk" | "ukr" => "Ukrainian", - "ro" | "rum" | "ron" => "Romanian", - "el" | "gre" | "ell" => "Greek", - "id" | "ind" => "Indonesian", - "ms" | "may" | "msa" => "Malay", - _ => return tag.to_string(), - }; - - // The region matters when there is one — Brazilian Portuguese is not - // Portuguese to anyone choosing between the two. - match tag.split_once(['-', '_']) { - Some((_, region)) if !region.is_empty() => { - format!("{name} ({})", region.to_ascii_uppercase()) - } - _ => name.to_string(), - } -} +pub use pstr_core::language::{LANGUAGES, language_name, same_language}; #[cfg(test)] mod tests { @@ -292,16 +221,6 @@ mod tests { assert_eq!(sub.label(), "English — Signs & Songs [ass]"); } - #[test] - fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() { - assert_eq!(language_name("jpn"), "Japanese"); - assert_eq!(language_name("ja"), "Japanese"); - assert_eq!(language_name("pt-BR"), "Portuguese (BR)"); - // An unknown tag is shown as it stands rather than as "Unknown", which - // would make two of them indistinguishable. - assert_eq!(language_name("mis"), "mis"); - } - fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track { Track { language: language.map(Into::into),