// Ported from Sonarr v4.0.17.2952 (97e85a90):
// src/NzbDrone.Core/Parser/LanguageParser.cs (lines 18-215, plus regex
// declarations 23-30).
//
// Public entry point: [`parse_languages`], mirroring C# `ParseLanguages`.
//
// **Plan-spec divergences resolved in favour of C# (per standing rule #1):**
//
// 1. Plan-spec said "English fallback when nothing matched". Wrong: C# adds
// `Language.Unknown` (not `English`) when `!languages.Any()` after the
// regex pass and the late `lowerTitle.Contains("english")` check. The
// English substring check is its own line (LanguageParser.cs:189-192) and
// only fires when the literal word "english" appears.
// 2. Plan-spec didn't mention `CleanSeriesTitleRegex`. C# applies it at the
// very top of `ParseLanguages` (lines 40-46) to strip any show-title prefix
// before the substring/regex passes. Without it, fixtures like
// `"Spanish Killroy was Here S02E02 ..."` parse Spanish; with it, they
// parse Unknown (the prefix is stripped, "Spanish" disappears). The C#
// `LanguageParserFixture.should_parse_language_unknown` test pins this.
// 3. Rust's `regex` crate is RE2-based and supports neither lookahead nor
// lookbehind. Three C# patterns use them:
// - `LanguageRegex` spanish branch: `spa(?!\(Latino\))`. Workaround:
// relax the regex to `spa\b`; `spanish_should_fire` rejects the match
// when the captured span is "spa" / "Spa" / "SPA" and is immediately
// followed (case-insensitively) by `(latino)`.
// - `CaseSensitiveLanguageRegex` LT/CZ/PL/BG/SK: paired SUB lookbehind
// and lookahead `(?<!SUB[\W|_|^])...(?![\W|_|^]SUB)`, both wrapped in
// `(?i)` mode-modifier groups. Workaround: relax the regex to drop
// both gates; helper checks the 3-or-4 chars on either side of each
// capture for the `SUB` + word-boundary rejection. The character class
// `[\W|_|^]` is C# regex syntax meaning literal-`|`-or-`_`-or-`^`-or-
// `\W`; `\W` already includes `|` and `^` (both non-word), and the
// `_` makes the class equivalent to `[\W_]`. Ported faithfully (the
// `^` in the class is literal caret, not start-of-string anchor).
// - `GermanDualLanguageRegex`: `(?<!WEB[-_. ]?)\bDL\b`. Variable-length
// lookbehind. Workaround: relax to `\bDL\b`; helper rejects when the
// preceding chars form `WEB` or `WEB[-_. ]`.
// 4. C# `RegexLanguage` checks `match.Groups["dutch"].Success` (line 409-412)
// but `LanguageRegex` declares no `dutch` group. The check is dead in C#;
// Dutch is detected via the `lowerTitle.Contains("dutch")` substring path
// only. Port faithfully by simply not emitting the dead branch.
use crate::language::Language;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
/// Regex constants ported from `LanguageParser.cs:18-30`.
///
/// Each `Lazy<Regex>` here corresponds to a `private static readonly Regex`
/// in the C# source. Where the C# pattern uses lookarounds, the Rust regex
/// is a relaxed superset and a sibling helper function applies the missing
/// constraint post-match. See file header for the complete list.
pub mod regexes {
use super::{Lazy, Regex};
/// `CleanSeriesTitleRegex` from `LanguageParser.cs:18-21`.
///
/// Ported verbatim. The non-greedy `.*?[_. ]` plus the captured
/// `(S\d{2}(?:E\d{2,4})*[_. ].*)` strips any show-title prefix up to
/// (and including) the first `[_. ]` separator before an `S\d{2}`
/// season tag. `parse_languages` runs the replace at the top of the
/// cascade so the substring/regex passes operate on the trimmed title.
pub static CLEAN_SERIES_TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i).*?[_. ](S\d{2}(?:E\d{2,4})*[_. ].*)")
.expect("CLEAN_SERIES_TITLE_REGEX must compile")
});
/// `LanguageRegex` from `LanguageParser.cs:23-24`.
///
/// Case-insensitive. Twenty named branches (no `dutch` branch; Dutch
/// is detected via the substring path only, see file header note 4).
/// One negative lookahead removed: the spanish branch in C# is
/// `\b(?:español|castellano|esp|spa(?!\(Latino\)))\b`; the Rust port
/// drops `(?!\(Latino\))` and applies it post-match in
/// [`super::spanish_should_fire`].
pub static LANGUAGE_REGEX: Lazy<Regex> = Lazy::new(|| {
// Anchor: this string mirrors the C# regex pattern at LanguageParser.cs:23
// verbatim except for the spanish-branch lookahead removal documented above.
Regex::new(
r"(?i)(?:\W|_)(?P<english>\b(?:ing|eng)\b)|(?P<italian>\b(?:ita|italian)\b)|(?P<german>german\b|videomann|ger[. ]dub)|(?P<flemish>flemish)|(?P<greek>greek)|(?P<french>(?:\W|_)(?:FR|VF|VF2|VFF|VFI|VFQ|TRUEFRENCH|FRENCH)(?:\W|_))|(?P<russian>\b(?:rus|ru)\b)|(?P<hungarian>\b(?:HUNDUB|HUN)\b)|(?P<hebrew>\bHebDub\b)|(?P<polish>\b(?:PL\W?DUB|DUB\W?PL|LEK\W?PL|PL\W?LEK)\b)|(?P<chinese>\[(?:CH[ST]|BIG5|GB)\]|简|繁|字幕)|(?P<bulgarian>\bbgaudio\b)|(?P<spanish>\b(?:español|castellano|esp|spa)\b)|(?P<ukrainian>\b(?:\dx?)?(?:ukr))|(?P<thai>\b(?:THAI)\b)|(?P<romanian>\b(?:RoDubbed|ROMANIAN)\b)|(?P<catalan>[-,. ]cat[. ](?:DD|subs)|\b(?:catalan|catalán)\b)|(?P<latvian>\b(?:lat|lav|lv)\b)|(?P<turkish>\b(?:tur)\b)|(?P<original>\b(?:orig|original)\b)",
)
.expect("LANGUAGE_REGEX must compile")
});
/// `CaseSensitiveLanguageRegex` from `LanguageParser.cs:26-27`.
///
/// C# pattern: `(?:(?i)(?<!SUB[\W|_|^]))(?:(?<lithuanian>\bLT\b)|...)(?:(?i)(?![\W|_|^]SUB))`.
/// Rust regex supports neither lookahead nor lookbehind, so the SUB
/// gates are dropped here and reapplied post-match by
/// [`super::sub_gate_passes`]. The middle alternation is case-sensitive
/// (uppercase only, since the surrounding C# regex has no flags).
pub static CASE_SENSITIVE_LANGUAGE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?:(?P<lithuanian>\bLT\b)|(?P<czech>\bCZ\b)|(?P<polish>\bPL\b)|(?P<bulgarian>\bBG\b)|(?P<slovak>\bSK\b))",
)
.expect("CASE_SENSITIVE_LANGUAGE_REGEX must compile")
});
/// `GermanDualLanguageRegex` from `LanguageParser.cs:29`.
///
/// C# pattern: `(?<!WEB[-_. ]?)\bDL\b` (case-insensitive). Variable-length
/// lookbehind dropped here; reapplied post-match in
/// [`super::german_dual_language_matches`].
pub static GERMAN_DUAL_LANGUAGE_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\bDL\b").expect("GERMAN_DUAL_LANGUAGE_REGEX must compile"));
/// `GermanMultiLanguageRegex` from `LanguageParser.cs:30`.
///
/// Ported verbatim, no lookarounds.
pub static GERMAN_MULTI_LANGUAGE_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\bML\b").expect("GERMAN_MULTI_LANGUAGE_REGEX must compile"));
}
/// Public entry point. Mirrors C# `LanguageParser.ParseLanguages`
/// (lines 38-215).
///
/// Algorithm (read top-to-bottom in the C# source):
/// 1. Apply `CleanSeriesTitleRegex` to strip any leading show-title.
/// 2. Twenty-six substring checks against the lowercased title, in
/// source order. Each hit appends a `Language` to the list.
/// 3. Append the regex-pass results (case-sensitive LT/CZ/PL/BG/SK first,
/// then case-insensitive `LanguageRegex`).
/// 4. If the lowercased title contains "english", append `Language::English`.
/// 5. If the list is empty, append `Language::Unknown`.
/// 6. If the list now contains exactly `[Language::German]`, apply the
/// DL/ML special handling (DL adds `Original`; ML adds `Original` and
/// `English`).
/// 7. Dedup by `Language as i32`, preserving first-seen order. C# uses
/// `DistinctBy(l => (int)l)` which is order-preserving; Rust mirrors
/// this with a `HashSet<i32>` discriminator and an in-place filter.
pub fn parse_languages(title: &str) -> Vec<Language> {
// Step 1: strip show-title prefix via CleanSeriesTitleRegex.
// C# `RegexReplace.TryReplace` runs the replace unconditionally and
// also returns whether the regex matched; `parse_languages` only
// needs the replaced string, so we mirror the replace via
// `Regex::replace` (returns Cow). The C# loop has a single regex,
// so the `break` after the first match is moot.
let cleaned: String = regexes::CLEAN_SERIES_TITLE_REGEX
.replace(title, "$1")
.into_owned();
let title = cleaned.as_str();
let lower_title = title.to_lowercase();
let mut languages: Vec<Language> = Vec::new();
// Step 2: 26 substring checks ported verbatim from LanguageParser.cs:52-180,
// in C# source order.
if lower_title.contains("spanish") {
languages.push(Language::Spanish);
}
if lower_title.contains("danish") {
languages.push(Language::Danish);
}
if lower_title.contains("dutch") {
languages.push(Language::Dutch);
}
if lower_title.contains("japanese") {
languages.push(Language::Japanese);
}
if lower_title.contains("icelandic") {
languages.push(Language::Icelandic);
}
if lower_title.contains("mandarin")
|| lower_title.contains("cantonese")
|| lower_title.contains("chinese")
{
languages.push(Language::Chinese);
}
if lower_title.contains("korean") {
languages.push(Language::Korean);
}
if lower_title.contains("russian") {
languages.push(Language::Russian);
}
if lower_title.contains("polish") {
languages.push(Language::Polish);
}
if lower_title.contains("vietnamese") {
languages.push(Language::Vietnamese);
}
if lower_title.contains("swedish") {
languages.push(Language::Swedish);
}
if lower_title.contains("norwegian") {
languages.push(Language::Norwegian);
}
if lower_title.contains("finnish") {
languages.push(Language::Finnish);
}
if lower_title.contains("turkish") {
languages.push(Language::Turkish);
}
if lower_title.contains("portuguese") {
languages.push(Language::Portuguese);
}
if lower_title.contains("hungarian") {
languages.push(Language::Hungarian);
}
if lower_title.contains("hebrew") {
languages.push(Language::Hebrew);
}
if lower_title.contains("arabic") {
languages.push(Language::Arabic);
}
if lower_title.contains("hindi") {
languages.push(Language::Hindi);
}
if lower_title.contains("malayalam") {
languages.push(Language::Malayalam);
}
if lower_title.contains("ukrainian") {
languages.push(Language::Ukrainian);
}
if lower_title.contains("bulgarian") {
languages.push(Language::Bulgarian);
}
if lower_title.contains("slovak") {
languages.push(Language::Slovak);
}
if lower_title.contains("brazilian") || lower_title.contains("dublado") {
languages.push(Language::PortugueseBrazil);
}
if lower_title.contains("latino") {
languages.push(Language::SpanishLatino);
}
if lower_title.contains("latvian") {
languages.push(Language::Latvian);
}
// Step 3: regex pass (LanguageParser.cs:182-187 -> RegexLanguage 337-481).
languages.extend(regex_language(title));
// Step 4: late English substring check (LanguageParser.cs:189-192). Note
// this fires AFTER the regex pass, so a release with "English" in the
// title gets English appended even if the regex pass already added other
// languages.
if lower_title.contains("english") {
languages.push(Language::English);
}
// Step 5: Unknown fallback when nothing matched (LanguageParser.cs:194-197).
// Plan-spec said "English fallback"; C# uses Unknown. Standing rule 1
// says C# wins.
if languages.is_empty() {
languages.push(Language::Unknown);
}
// Step 6: German DL/ML special handling (LanguageParser.cs:199-212).
// Predicate is "exactly one language and it is German".
if languages.len() == 1 && languages[0] == Language::German {
if german_dual_language_matches(title) {
languages.push(Language::Original);
} else if regexes::GERMAN_MULTI_LANGUAGE_REGEX.is_match(title) {
languages.push(Language::Original);
languages.push(Language::English);
}
}
// Step 7: dedup by enum int value, preserving first-seen order
// (LanguageParser.cs:214 -> `DistinctBy(l => (int)l)`).
let mut seen: HashSet<i32> = HashSet::new();
languages
.into_iter()
.filter(|l| seen.insert(*l as i32))
.collect()
}
/// Run the case-sensitive (LT/CZ/PL/BG/SK) and case-insensitive
/// `LanguageRegex` passes. Mirrors C# `RegexLanguage` (lines 337-481).
///
/// C# uses `Regex.Match` (single match) for `CaseSensitiveLanguageRegex`
/// and `Regex.Matches` (all matches) for `LanguageRegex`. We mirror that:
/// the case-sensitive scan reports each LT/CZ/PL/BG/SK group once if any
/// captures hit, while `LanguageRegex` walks every match and emits a
/// `Language` for each successful named group. Dedup happens at the
/// caller (Step 7).
pub(crate) fn regex_language(title: &str) -> Vec<Language> {
let mut languages: Vec<Language> = Vec::new();
// Case-sensitive LT/CZ/PL/BG/SK pass with SUB lookaround filter.
// C# uses `Regex.Match` (first match) here, but the alternation only
// names one group per branch; iterating captures gives us each branch
// independently while applying the SUB gate per match span.
for caps in regexes::CASE_SENSITIVE_LANGUAGE_REGEX.captures_iter(title) {
// The whole-match span is what the SUB gate looks left/right of.
// We use the named-group span (which equals the whole match for
// alternation branches with no surrounding groups), so left/right
// checks line up with C#'s `(?<!SUB[\W|_|^])...(?![\W|_|^]SUB)`.
let m = caps.get(0).expect("regex match always has group 0");
if !sub_gate_passes(title, m.start(), m.end()) {
continue;
}
if caps.name("lithuanian").is_some() {
languages.push(Language::Lithuanian);
}
if caps.name("czech").is_some() {
languages.push(Language::Czech);
}
if caps.name("polish").is_some() {
languages.push(Language::Polish);
}
if caps.name("bulgarian").is_some() {
languages.push(Language::Bulgarian);
}
if caps.name("slovak").is_some() {
languages.push(Language::Slovak);
}
}
// Case-insensitive `LanguageRegex` pass.
for caps in regexes::LANGUAGE_REGEX.captures_iter(title) {
// C# emits one `Language` per successful named group per match.
// Each branch in the alternation can have at most one group set
// per match because the alternation is mutually exclusive, but
// the `Matches` walk yields multiple matches across the title.
if caps.name("english").is_some() {
languages.push(Language::English);
}
if caps.name("italian").is_some() {
languages.push(Language::Italian);
}
if caps.name("german").is_some() {
languages.push(Language::German);
}
if caps.name("flemish").is_some() {
languages.push(Language::Flemish);
}
if caps.name("greek").is_some() {
languages.push(Language::Greek);
}
if caps.name("french").is_some() {
languages.push(Language::French);
}
if caps.name("russian").is_some() {
languages.push(Language::Russian);
}
// C# also checks `match.Groups["dutch"].Success` (line 409-412),
// but `LanguageRegex` declares no `dutch` group; that branch is
// unreachable in C# and we omit it (see file header note 4).
if caps.name("hungarian").is_some() {
languages.push(Language::Hungarian);
}
if caps.name("hebrew").is_some() {
languages.push(Language::Hebrew);
}
if caps.name("polish").is_some() {
languages.push(Language::Polish);
}
if caps.name("chinese").is_some() {
languages.push(Language::Chinese);
}
if caps.name("bulgarian").is_some() {
languages.push(Language::Bulgarian);
}
if caps.name("ukrainian").is_some() {
languages.push(Language::Ukrainian);
}
if let Some(spa) = caps.name("spanish") {
// Apply the `(?!\(Latino\))` post-match filter dropped from the
// regex above.
if spanish_should_fire(title, spa.end()) {
languages.push(Language::Spanish);
}
}
if caps.name("thai").is_some() {
languages.push(Language::Thai);
}
if caps.name("romanian").is_some() {
languages.push(Language::Romanian);
}
if caps.name("catalan").is_some() {
languages.push(Language::Catalan);
}
if caps.name("latvian").is_some() {
languages.push(Language::Latvian);
}
if caps.name("turkish").is_some() {
languages.push(Language::Turkish);
}
if caps.name("original").is_some() {
languages.push(Language::Original);
}
}
languages
}
/// Reapplies the `(?!\(Latino\))` lookahead from the C# spanish branch.
///
/// C# pattern: `\b(?:español|castellano|esp|spa(?!\(Latino\)))\b`. Note
/// the lookahead is on the `spa` alternative only; `español`, `castellano`,
/// and `esp` always pass. The Rust regex captures the matched text but
/// not which alternative it took, so we approximate by checking if the
/// captured text is exactly `spa` (case-insensitive). If yes, gate on
/// the chars after the match; otherwise pass.
///
/// `spa_end` is the byte offset of the end of the spanish capture; the
/// lookahead reads from there.
pub(crate) fn spanish_should_fire(title: &str, spa_end: usize) -> bool {
// The lookahead in C# fires regardless of which alternative matched
// because the regex backtracker would only enter the lookahead on
// the `spa` branch (the others don't have one), so a successful
// match against `español` / `castellano` / `esp` always reaches the
// closing `\b` without consulting the lookahead. We need to know
// which alternative the Rust regex took. The simplest signal is the
// length of the captured text minus the start-anchor: `spa` is the
// shortest 3-letter form, so look at the 3 chars before `spa_end`.
//
// We don't have the start offset here (only the end), so peek at
// the 6 chars before `spa_end` to disambiguate `spa` from `espa`
// (the second-shortest). C# `español` / `castellano` / `esp` all
// have a non-`spa` byte at offset `spa_end - 4` (i.e. `e`, `n`, or
// start-of-class), while bare `spa` has `\b` (a non-word boundary)
// at `spa_end - 4` -- distinguishable by checking whether the
// captured tail is `spa` AND the byte before it is a non-word.
let bytes = title.as_bytes();
if spa_end < 3 {
return true;
}
let tail = &bytes[spa_end - 3..spa_end];
let is_bare_spa =
tail.eq_ignore_ascii_case(b"spa") && (spa_end == 3 || !is_word_byte(bytes[spa_end - 4]));
if !is_bare_spa {
return true;
}
// Bare `spa` matched: enforce the negative lookahead.
let after = &bytes[spa_end..];
!starts_with_latino(after)
}
/// `(latino)` literal, case-insensitive against ASCII bytes. Mirrors the
/// `\(Latino\)` literal in the C# spanish branch's negative lookahead.
fn starts_with_latino(after: &[u8]) -> bool {
const LATINO: &[u8] = b"(latino)";
if after.len() < LATINO.len() {
return false;
}
after[..LATINO.len()].eq_ignore_ascii_case(LATINO)
}
/// Reapplies the SUB negative lookbehind/lookahead from
/// `CaseSensitiveLanguageRegex`. Returns `true` if the match passes
/// both gates.
///
/// C# pattern (lookarounds only): `(?<!SUB[\W|_|^])` before the alternation
/// and `(?![\W|_|^]SUB)` after. Inside the `[...]` class:
/// - `\W` = non-word
/// - `|` = literal pipe (not alternation, since we're in a class)
/// - `_` = literal underscore
/// - `^` = literal caret (not start-of-string anchor, since not first char)
///
/// `\W` already covers `|` and `^` (both non-word ASCII), so the class is
/// functionally `[\W_]`: any char that is non-word OR underscore. The `(?i)`
/// mode-modifier groups in C# only affect the SUB literal -- which is
/// already uppercase ASCII, so case-insensitivity matters for cases like
/// `sub.LT`. We apply the same case-insensitive `SUB` match here.
pub(crate) fn sub_gate_passes(title: &str, match_start: usize, match_end: usize) -> bool {
let bytes = title.as_bytes();
// Lookbehind: `(?<!SUB[\W|_|^])`. The 4 chars immediately before
// match_start must NOT be `SUB` (case-insensitive) followed by a char
// in `[\W_]`.
if match_start >= 4 {
let prefix = &bytes[match_start - 4..match_start];
let sub_part = &prefix[..3];
let sep = prefix[3];
if sub_part.eq_ignore_ascii_case(b"sub") && (!is_word_byte(sep) || sep == b'_') {
return false;
}
}
// Lookahead: `(?![\W|_|^]SUB)`. The 4 chars immediately after
// match_end must NOT be a char in `[\W_]` followed by `SUB`
// (case-insensitive).
if match_end + 4 <= bytes.len() {
let suffix = &bytes[match_end..match_end + 4];
let sep = suffix[0];
let sub_part = &suffix[1..4];
if sub_part.eq_ignore_ascii_case(b"sub") && (!is_word_byte(sep) || sep == b'_') {
return false;
}
}
true
}
/// Reapplies the variable-length lookbehind from
/// `GermanDualLanguageRegex`. Returns `true` if the title contains a
/// `\bDL\b` whose preceding chars do NOT form `WEB` or `WEB[-_. ]`.
///
/// C# pattern: `(?<!WEB[-_. ]?)\bDL\b` (case-insensitive). The `[-_. ]?`
/// is optional, so the lookbehind is 3 or 4 chars wide. Rust regex
/// supports neither variable-length nor any lookbehind, so we walk every
/// `\bDL\b` candidate and apply the rejection in code.
pub(crate) fn german_dual_language_matches(title: &str) -> bool {
let bytes = title.as_bytes();
for m in regexes::GERMAN_DUAL_LANGUAGE_REGEX.find_iter(title) {
let start = m.start();
// 4-char lookbehind: `WEB[-_. ]` immediately before `DL`.
if start >= 4 {
let prefix = &bytes[start - 4..start];
let web_part = &prefix[..3];
let sep = prefix[3];
if web_part.eq_ignore_ascii_case(b"web") && matches!(sep, b'-' | b'_' | b'.' | b' ') {
continue;
}
}
// 3-char lookbehind: `WEB` immediately before `DL` (no separator).
if start >= 3 {
let prefix = &bytes[start - 3..start];
if prefix.eq_ignore_ascii_case(b"web") {
continue;
}
}
// Lookbehind passed: this `\bDL\b` survives.
return true;
}
false
}
/// ASCII word-byte test mirroring regex `\w`: `[A-Za-z0-9_]`.
///
/// The byte form is sound because the helpers only consult byte offsets
/// returned by the `regex` crate (always at UTF-8 char boundaries) and
/// only compare against ASCII separators that fit in one byte.
/// Continuation bytes of a multi-byte char are in `0x80..=0xBF`, which
/// `is_word_byte` correctly classifies as non-word.
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
// ----- Plan-required tests -------------------------------------------
#[test]
fn detects_spanish_substring() {
// After CleanSeriesTitleRegex strips `Show.`, the title becomes
// `S01.SPANISH.1080p`... actually `Show.SPANISH.1080p` has no
// S\d{2} season tag, so the cleaner does NOT fire and the full
// title is scanned. "spanish" substring fires.
assert_eq!(
parse_languages("Show.SPANISH.1080p"),
vec![Language::Spanish]
);
}
#[test]
fn detects_japanese_substring() {
assert_eq!(
parse_languages("Show.JAPANESE.1080p"),
vec![Language::Japanese]
);
}
#[test]
fn detects_chs_bracket_chinese() {
assert_eq!(
parse_languages("[abc] Show - 01 [CHS]"),
vec![Language::Chinese]
);
}
#[test]
fn detects_german() {
assert_eq!(parse_languages("Show.GERMAN.1080p"), vec![Language::German]);
}
#[test]
fn detects_german_dl_adds_original() {
let l = parse_languages("Show.German.DL.1080p");
assert!(l.contains(&Language::German));
assert!(l.contains(&Language::Original));
}
#[test]
fn detects_german_ml_adds_original_and_english() {
let l = parse_languages("Show.German.ML.1080p");
assert!(l.contains(&Language::German));
assert!(l.contains(&Language::Original));
assert!(l.contains(&Language::English));
}
#[test]
fn lt_isolated_detects_lithuanian() {
// "Show.LT.1080p" has no S\d{2} season tag so the cleaner does
// not fire. The case-sensitive `LT` regex matches between two
// non-word chars; SUB gate passes (no SUB nearby).
let l = parse_languages("Show.LT.1080p");
assert!(l.contains(&Language::Lithuanian));
}
#[test]
fn lt_with_sub_does_not_detect_lithuanian() {
// "Show.LT.SUB.1080p": after `LT` comes `.SUB`. The C# negative
// lookahead `(?![\W|_|^]SUB)` rejects this match.
let l = parse_languages("Show.LT.SUB.1080p");
assert!(!l.contains(&Language::Lithuanian));
}
#[test]
fn defaults_to_unknown() {
// C# adds Language.Unknown when nothing else matched; the plan-spec
// wrongly said English. Test mirrors C# truth.
assert_eq!(
parse_languages("Show.S01E01.1080p"),
vec![Language::Unknown]
);
}
#[test]
fn dedupes_languages() {
// "Show.GERMAN.German.DL.1080p": "german" substring + LanguageRegex
// both fire. Dedup leaves one Language::German.
let l = parse_languages("Show.GERMAN.German.DL.1080p");
assert_eq!(l.iter().filter(|&&x| x == Language::German).count(), 1);
}
// ----- C# LanguageParserFixture parity (the substring branches) --------
#[test]
fn fixture_unknown_after_clean_series_title_strip() {
// C# fixture line 36-50: "spanish" appears in the show prefix only;
// CleanSeriesTitleRegex strips it, leaving a release that matches
// no language => Unknown.
let l = parse_languages(
"Spanish Killroy was Here S02E02 Flodden 720p AMZN WEB-DL DDP5 1 H 264-NTb",
);
assert!(l.contains(&Language::Unknown), "got {l:?}");
}
#[test]
fn fixture_subfrench_is_unknown() {
// C# fixture line 44: "Series.Title.S01E01.SUBFRENCH.1080p.WEB.x264-GROUP"
// returns Unknown. The french regex requires `(?:\W|_)FRENCH(?:\W|_)`,
// so `SUBFRENCH` (with `B` directly preceding) does not match.
let l = parse_languages("Series.Title.S01E01.SUBFRENCH.1080p.WEB.x264-GROUP");
assert!(l.contains(&Language::Unknown), "got {l:?}");
}
#[test]
fn fixture_french_branches() {
// C# fixture lines 62-69: French detected via FR/VF/VF2/VFF/VFI/VFQ/TRUEFRENCH/FRENCH.
for title in [
"Title.the.Series.2009.S01E14.French.HDTV.XviD-LOL",
"Title.the.Series.The.1x13.Tueurs.De.Flics.FR.DVDRip.XviD",
"Title.S01.720p.VF.WEB-DL.AAC2.0.H.264-BTN",
"Title.S01.720p.VFF.WEB-DL.AAC2.0.H.264-BTN",
"Title.S01.720p.TRUEFRENCH.WEB-DL.AAC2.0.H.264-BTN",
] {
let l = parse_languages(title);
assert!(l.contains(&Language::French), "title={title} got {l:?}");
}
}
#[test]
fn fixture_german_with_dl_adds_original_but_webdl_does_not() {
// C# fixture lines 403-405 (German.DL.1080p.BluRay) -> Original added.
for title in [
"Series.Title.S01E01.German.DL.1080p.BluRay.x264-RlsGrp",
"Series.Title.S01E01.GERMAN.DL.1080P.WEB.H264-RlsGrp",
"Series.Title.2023.S01E01.German.DL.EAC3.1080p.DSNP.WEB.H264-RlsGrp",
] {
let l = parse_languages(title);
assert!(l.contains(&Language::German), "title={title} got {l:?}");
assert!(l.contains(&Language::Original), "title={title} got {l:?}");
}
// C# fixture lines 414-417 (German + WEB-DL/WEBDL) -> Original NOT added.
for title in [
"Series.Title.2023.S01E01.GERMAN.1080P.WEB-DL.H264-RlsGrp",
"Series.Title.2023.S01E01.GERMAN.1080P.WEB.DL.H264-RlsGrp",
"Series Title 2023 S01E01 GERMAN 1080P WEB DL H264-RlsGrp",
"Series.Title.2023.S01E01.GERMAN.1080P.WEBDL.H264-RlsGrp",
] {
let l = parse_languages(title);
assert!(l.contains(&Language::German), "title={title} got {l:?}");
assert_eq!(l.len(), 1, "title={title} expected German only, got {l:?}");
}
}
#[test]
fn fixture_german_ml_adds_original_and_english() {
// C# fixture line 425.
let l = parse_languages("Series.Title.2023.S01.German.ML.EAC3.1080p.NF.WEB.H264-RlsGrp");
assert!(l.contains(&Language::German), "got {l:?}");
assert!(l.contains(&Language::Original), "got {l:?}");
assert!(l.contains(&Language::English), "got {l:?}");
assert_eq!(l.len(), 3, "got {l:?}");
}
#[test]
fn fixture_polish_via_regex_branches() {
// C# fixture lines 177-186: Polish detected via PL/LEK combos.
for title in [
"Title.the.Series.2009.S01E14.Polish.HDTV.XviD-LOL",
"Title.the.Series.2009.S01E14.PL.HDTV.XviD-LOL",
"Title.the.Series.2009.S01E14.PLLEK.HDTV.XviD-LOL",
"Title.the.Series.2009.S01E14.PL-LEK.HDTV.XviD-LOL",
"Title.the.Series.2009.S01E14.PLDUB.HDTV.XviD-LOL",
"Title.the.Series.2009.S01E14.LEK-PL.HDTV.XviD-LOL",
] {
let l = parse_languages(title);
assert!(l.contains(&Language::Polish), "title={title} got {l:?}");
}
}
#[test]
fn fixture_russian_lt_lv_ru_combo() {
// C# fixture line 159: LT.LV.RU triggers Lithuanian + Latvian + Russian.
let l = parse_languages(
"Title.the.Series.S01.COMPLETE.2009.1080p.WEB-DL.x264.AVC.AAC.LT.LV.RU",
);
assert!(l.contains(&Language::Lithuanian), "got {l:?}");
assert!(l.contains(&Language::Latvian), "got {l:?}");
assert!(l.contains(&Language::Russian), "got {l:?}");
}
#[test]
fn fixture_slovak_eng_sk() {
// C# fixture line 322: "ENG.SK-LOL" -> Slovak + English.
let l = parse_languages("Title.the.Series.2021.S01E11.HDTV.XviD.ENG.SK-LOL");
assert!(l.contains(&Language::Slovak), "got {l:?}");
assert!(l.contains(&Language::English), "got {l:?}");
}
#[test]
fn fixture_czech_via_regex() {
// C# fixture line 272 only asserts Contains(Czech). The english
// branch in C# is `(?:\W|_)\b(?:ing|eng)\b`, so the bare `EN`
// tail does not match -- and the Unknown fallback is suppressed
// because Czech already populated the list.
let l = parse_languages("Title.the.Series.S07E11.WEB Rip.XviD.Louige-CZ.EN.5.1");
assert!(l.contains(&Language::Czech), "got {l:?}");
}
#[test]
fn fixture_chinese_chs_cht_big5_gb() {
for title in [
"[abc] My Series - 01 [CHS]",
"[abc] My Series - 01 [CHT]",
"[abc] My Series - 01 [BIG5]",
"[abc] My Series - 01 [GB]",
] {
let l = parse_languages(title);
assert!(l.contains(&Language::Chinese), "title={title} got {l:?}");
}
}
#[test]
fn fixture_chinese_unicode_branches() {
// The chinese branch in C# matches the literal CJK chars 简, 繁, 字幕.
for title in [
"[abc] My Series - 01 [繁中]",
"[abc] My Series - 01 [简繁外挂]",
"[ABC字幕组] My Series - 01 [HDTV]",
] {
let l = parse_languages(title);
assert!(l.contains(&Language::Chinese), "title={title} got {l:?}");
}
}
#[test]
fn fixture_brazilian_dublado_to_portuguese_brazil() {
for title in [
"Title.the.Series.2009.S01E14.Brazilian.HDTV.XviD-LOL",
"Title.the.Series.2009.S01E14.Dublado.HDTV.XviD-LOL",
] {
let l = parse_languages(title);
assert!(
l.contains(&Language::PortugueseBrazil),
"title={title} got {l:?}"
);
}
}
#[test]
fn fixture_spanish_latino() {
// C# fixture lines 346-351.
for title in [
"Series.Title.S01.2019.720p_Eng-Spa(Latino)_MovieClubMx",
"Series.Title.1.WEB-DL.720p.Complete.Latino.YG",
"Series Title latino",
] {
let l = parse_languages(title);
assert!(
l.contains(&Language::SpanishLatino),
"title={title} got {l:?}"
);
}
}
#[test]
fn fixture_spa_latino_does_not_double_count_spanish() {
// The C# spanish branch's `(?!\(Latino\))` lookahead prevents
// `Spa(Latino)` from matching as Spanish.
let l = parse_languages("Series.Title.S01.2019.720p_Eng-Spa(Latino)_MovieClubMx");
assert!(l.contains(&Language::SpanishLatino), "got {l:?}");
assert!(!l.contains(&Language::Spanish), "got {l:?}");
}
#[test]
fn fixture_hindi_and_english_pair() {
// C# fixture line 378-379.
let l = parse_languages(
"The Shadow Series S01 E01-08 WebRip Dual Audio [Hindi 5.1 + English 5.1] 720p x264 AAC ESub",
);
assert!(l.contains(&Language::Hindi), "got {l:?}");
assert!(l.contains(&Language::English), "got {l:?}");
}
#[test]
fn fixture_ukrainian_cyrillic_with_x() {
// C# fixture line 312-314: Ukrainian via "ukr" (and the (?:\dx?)?
// optional 1x prefix).
for title in [
"Гало(Сезон 1, серії 1-5) / SeriesTitle(Season 1, episodes 1-5) (2022) WEBRip-AVC Ukr/Eng",
"Книга Боби Фетта(Сезон 1) / Series Title(Season 1) (2021) WEB-DLRip Ukr/Eng",
] {
let l = parse_languages(title);
assert!(l.contains(&Language::Ukrainian), "title={title} got {l:?}");
assert!(l.contains(&Language::English), "title={title} got {l:?}");
}
}
#[test]
fn fixture_original_branch() {
// C# fixture lines 435-437.
for title in [
"Series.Title.S01E01.Original.1080P.WEB.H264-RlsGrp",
"Series.Title.S01E01.Orig.1080P.WEB.H264-RlsGrp",
] {
let l = parse_languages(title);
assert_eq!(l.len(), 1, "title={title} got {l:?}");
assert!(l.contains(&Language::Original), "title={title} got {l:?}");
}
}
#[test]
fn fixture_videomann_german() {
// C# fixture line 87: the german branch has `german\b|videomann|ger[. ]dub`.
let l = parse_languages("The Series Title - S02E16 - Kampfhaehne - mkv - by Videomann");
assert!(l.contains(&Language::German), "got {l:?}");
}
#[test]
fn fixture_ger_dub() {
// C# fixture line 88: "Ger.Dub" matches the german branch.
let l = parse_languages("Series.Title.S01E03.Ger.Dub.AAC.1080p.WebDL.x264-TKP21");
assert!(l.contains(&Language::German), "got {l:?}");
}
// ----- Edge cases that pin our helper-function logic -------------------
#[test]
fn sub_gate_rejects_subfrench_style_left_side_for_lt() {
// Case `SUB.LT` (left-side SUB before LT).
let l = parse_languages("Show.SUB.LT.1080p");
assert!(!l.contains(&Language::Lithuanian), "got {l:?}");
}
#[test]
fn sub_gate_rejects_lt_followed_by_sub_with_underscore() {
// The class `[\W_]` matches `_`, so `LT_SUB` should also reject.
let l = parse_languages("Show.LT_SUB.1080p");
assert!(!l.contains(&Language::Lithuanian), "got {l:?}");
}
#[test]
fn german_dl_after_webdash_alt_separator_underscore() {
// C# pattern is `WEB[-_. ]?` -- the `_` separator should also reject.
let l = parse_languages("Show.S01E01.German.WEB_DL.1080p");
assert_eq!(l, vec![Language::German], "got {l:?}");
}
#[test]
fn empty_title_produces_unknown() {
assert_eq!(parse_languages(""), vec![Language::Unknown]);
}
#[test]
fn english_substring_path_fires_on_lowercase_english() {
// "english" substring (case-insensitive) at the late check.
let l = parse_languages("Show.S01E01.english.subtitles.1080p");
assert!(l.contains(&Language::English), "got {l:?}");
}
#[test]
fn dedup_preserves_first_seen_order() {
// Multiple paths add the same language; dedup should keep the
// first occurrence's position. Build a release that adds Polish
// via substring first, then via the LanguageRegex `polish` branch
// (`PLDUB`), then via case-sensitive `PL`. The input has no
// `S\d{2}` season tag, so `CleanSeriesTitleRegex` does NOT strip
// the leading "Polish" token — meaning the substring path fires
// alongside the two regex paths. The result should have Polish
// in the Polish-substring slot (first) only.
let l = parse_languages("Polish.PLDUB.PL.1080p");
let pl_count = l.iter().filter(|&&x| x == Language::Polish).count();
assert_eq!(pl_count, 1, "got {l:?}");
}
}