Skip to main content

avatarr_parser/language/
parser.rs

1// Ported from Sonarr v4.0.17.2952 (97e85a90):
2//   src/NzbDrone.Core/Parser/LanguageParser.cs (lines 18-215, plus regex
3//   declarations 23-30).
4//
5// Public entry point: [`parse_languages`], mirroring C# `ParseLanguages`.
6//
7// **Plan-spec divergences resolved in favour of C# (per standing rule #1):**
8//
9// 1. Plan-spec said "English fallback when nothing matched". Wrong: C# adds
10//    `Language.Unknown` (not `English`) when `!languages.Any()` after the
11//    regex pass and the late `lowerTitle.Contains("english")` check. The
12//    English substring check is its own line (LanguageParser.cs:189-192) and
13//    only fires when the literal word "english" appears.
14// 2. Plan-spec didn't mention `CleanSeriesTitleRegex`. C# applies it at the
15//    very top of `ParseLanguages` (lines 40-46) to strip any show-title prefix
16//    before the substring/regex passes. Without it, fixtures like
17//    `"Spanish Killroy was Here S02E02 ..."` parse Spanish; with it, they
18//    parse Unknown (the prefix is stripped, "Spanish" disappears). The C#
19//    `LanguageParserFixture.should_parse_language_unknown` test pins this.
20// 3. Rust's `regex` crate is RE2-based and supports neither lookahead nor
21//    lookbehind. Three C# patterns use them:
22//    - `LanguageRegex` spanish branch: `spa(?!\(Latino\))`. Workaround:
23//      relax the regex to `spa\b`; `spanish_should_fire` rejects the match
24//      when the captured span is "spa" / "Spa" / "SPA" and is immediately
25//      followed (case-insensitively) by `(latino)`.
26//    - `CaseSensitiveLanguageRegex` LT/CZ/PL/BG/SK: paired SUB lookbehind
27//      and lookahead `(?<!SUB[\W|_|^])...(?![\W|_|^]SUB)`, both wrapped in
28//      `(?i)` mode-modifier groups. Workaround: relax the regex to drop
29//      both gates; helper checks the 3-or-4 chars on either side of each
30//      capture for the `SUB` + word-boundary rejection. The character class
31//      `[\W|_|^]` is C# regex syntax meaning literal-`|`-or-`_`-or-`^`-or-
32//      `\W`; `\W` already includes `|` and `^` (both non-word), and the
33//      `_` makes the class equivalent to `[\W_]`. Ported faithfully (the
34//      `^` in the class is literal caret, not start-of-string anchor).
35//    - `GermanDualLanguageRegex`: `(?<!WEB[-_. ]?)\bDL\b`. Variable-length
36//      lookbehind. Workaround: relax to `\bDL\b`; helper rejects when the
37//      preceding chars form `WEB` or `WEB[-_. ]`.
38// 4. C# `RegexLanguage` checks `match.Groups["dutch"].Success` (line 409-412)
39//    but `LanguageRegex` declares no `dutch` group. The check is dead in C#;
40//    Dutch is detected via the `lowerTitle.Contains("dutch")` substring path
41//    only. Port faithfully by simply not emitting the dead branch.
42
43use crate::language::Language;
44use once_cell::sync::Lazy;
45use regex::Regex;
46use std::collections::HashSet;
47
48/// Regex constants ported from `LanguageParser.cs:18-30`.
49///
50/// Each `Lazy<Regex>` here corresponds to a `private static readonly Regex`
51/// in the C# source. Where the C# pattern uses lookarounds, the Rust regex
52/// is a relaxed superset and a sibling helper function applies the missing
53/// constraint post-match. See file header for the complete list.
54pub mod regexes {
55    use super::{Lazy, Regex};
56
57    /// `CleanSeriesTitleRegex` from `LanguageParser.cs:18-21`.
58    ///
59    /// Ported verbatim. The non-greedy `.*?[_. ]` plus the captured
60    /// `(S\d{2}(?:E\d{2,4})*[_. ].*)` strips any show-title prefix up to
61    /// (and including) the first `[_. ]` separator before an `S\d{2}`
62    /// season tag. `parse_languages` runs the replace at the top of the
63    /// cascade so the substring/regex passes operate on the trimmed title.
64    pub static CLEAN_SERIES_TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
65        Regex::new(r"(?i).*?[_. ](S\d{2}(?:E\d{2,4})*[_. ].*)")
66            .expect("CLEAN_SERIES_TITLE_REGEX must compile")
67    });
68
69    /// `LanguageRegex` from `LanguageParser.cs:23-24`.
70    ///
71    /// Case-insensitive. Twenty named branches (no `dutch` branch; Dutch
72    /// is detected via the substring path only, see file header note 4).
73    /// One negative lookahead removed: the spanish branch in C# is
74    /// `\b(?:español|castellano|esp|spa(?!\(Latino\)))\b`; the Rust port
75    /// drops `(?!\(Latino\))` and applies it post-match in
76    /// [`super::spanish_should_fire`].
77    pub static LANGUAGE_REGEX: Lazy<Regex> = Lazy::new(|| {
78        // Anchor: this string mirrors the C# regex pattern at LanguageParser.cs:23
79        // verbatim except for the spanish-branch lookahead removal documented above.
80        Regex::new(
81            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)",
82        )
83        .expect("LANGUAGE_REGEX must compile")
84    });
85
86    /// `CaseSensitiveLanguageRegex` from `LanguageParser.cs:26-27`.
87    ///
88    /// C# pattern: `(?:(?i)(?<!SUB[\W|_|^]))(?:(?<lithuanian>\bLT\b)|...)(?:(?i)(?![\W|_|^]SUB))`.
89    /// Rust regex supports neither lookahead nor lookbehind, so the SUB
90    /// gates are dropped here and reapplied post-match by
91    /// [`super::sub_gate_passes`]. The middle alternation is case-sensitive
92    /// (uppercase only, since the surrounding C# regex has no flags).
93    pub static CASE_SENSITIVE_LANGUAGE_REGEX: Lazy<Regex> = Lazy::new(|| {
94        Regex::new(
95            r"(?:(?P<lithuanian>\bLT\b)|(?P<czech>\bCZ\b)|(?P<polish>\bPL\b)|(?P<bulgarian>\bBG\b)|(?P<slovak>\bSK\b))",
96        )
97        .expect("CASE_SENSITIVE_LANGUAGE_REGEX must compile")
98    });
99
100    /// `GermanDualLanguageRegex` from `LanguageParser.cs:29`.
101    ///
102    /// C# pattern: `(?<!WEB[-_. ]?)\bDL\b` (case-insensitive). Variable-length
103    /// lookbehind dropped here; reapplied post-match in
104    /// [`super::german_dual_language_matches`].
105    pub static GERMAN_DUAL_LANGUAGE_REGEX: Lazy<Regex> =
106        Lazy::new(|| Regex::new(r"(?i)\bDL\b").expect("GERMAN_DUAL_LANGUAGE_REGEX must compile"));
107
108    /// `GermanMultiLanguageRegex` from `LanguageParser.cs:30`.
109    ///
110    /// Ported verbatim, no lookarounds.
111    pub static GERMAN_MULTI_LANGUAGE_REGEX: Lazy<Regex> =
112        Lazy::new(|| Regex::new(r"(?i)\bML\b").expect("GERMAN_MULTI_LANGUAGE_REGEX must compile"));
113}
114
115/// Public entry point. Mirrors C# `LanguageParser.ParseLanguages`
116/// (lines 38-215).
117///
118/// Algorithm (read top-to-bottom in the C# source):
119/// 1. Apply `CleanSeriesTitleRegex` to strip any leading show-title.
120/// 2. Twenty-six substring checks against the lowercased title, in
121///    source order. Each hit appends a `Language` to the list.
122/// 3. Append the regex-pass results (case-sensitive LT/CZ/PL/BG/SK first,
123///    then case-insensitive `LanguageRegex`).
124/// 4. If the lowercased title contains "english", append `Language::English`.
125/// 5. If the list is empty, append `Language::Unknown`.
126/// 6. If the list now contains exactly `[Language::German]`, apply the
127///    DL/ML special handling (DL adds `Original`; ML adds `Original` and
128///    `English`).
129/// 7. Dedup by `Language as i32`, preserving first-seen order. C# uses
130///    `DistinctBy(l => (int)l)` which is order-preserving; Rust mirrors
131///    this with a `HashSet<i32>` discriminator and an in-place filter.
132pub fn parse_languages(title: &str) -> Vec<Language> {
133    // Step 1: strip show-title prefix via CleanSeriesTitleRegex.
134    // C# `RegexReplace.TryReplace` runs the replace unconditionally and
135    // also returns whether the regex matched; `parse_languages` only
136    // needs the replaced string, so we mirror the replace via
137    // `Regex::replace` (returns Cow). The C# loop has a single regex,
138    // so the `break` after the first match is moot.
139    let cleaned: String = regexes::CLEAN_SERIES_TITLE_REGEX
140        .replace(title, "$1")
141        .into_owned();
142    let title = cleaned.as_str();
143
144    let lower_title = title.to_lowercase();
145    let mut languages: Vec<Language> = Vec::new();
146
147    // Step 2: 26 substring checks ported verbatim from LanguageParser.cs:52-180,
148    // in C# source order.
149
150    if lower_title.contains("spanish") {
151        languages.push(Language::Spanish);
152    }
153    if lower_title.contains("danish") {
154        languages.push(Language::Danish);
155    }
156    if lower_title.contains("dutch") {
157        languages.push(Language::Dutch);
158    }
159    if lower_title.contains("japanese") {
160        languages.push(Language::Japanese);
161    }
162    if lower_title.contains("icelandic") {
163        languages.push(Language::Icelandic);
164    }
165    if lower_title.contains("mandarin")
166        || lower_title.contains("cantonese")
167        || lower_title.contains("chinese")
168    {
169        languages.push(Language::Chinese);
170    }
171    if lower_title.contains("korean") {
172        languages.push(Language::Korean);
173    }
174    if lower_title.contains("russian") {
175        languages.push(Language::Russian);
176    }
177    if lower_title.contains("polish") {
178        languages.push(Language::Polish);
179    }
180    if lower_title.contains("vietnamese") {
181        languages.push(Language::Vietnamese);
182    }
183    if lower_title.contains("swedish") {
184        languages.push(Language::Swedish);
185    }
186    if lower_title.contains("norwegian") {
187        languages.push(Language::Norwegian);
188    }
189    if lower_title.contains("finnish") {
190        languages.push(Language::Finnish);
191    }
192    if lower_title.contains("turkish") {
193        languages.push(Language::Turkish);
194    }
195    if lower_title.contains("portuguese") {
196        languages.push(Language::Portuguese);
197    }
198    if lower_title.contains("hungarian") {
199        languages.push(Language::Hungarian);
200    }
201    if lower_title.contains("hebrew") {
202        languages.push(Language::Hebrew);
203    }
204    if lower_title.contains("arabic") {
205        languages.push(Language::Arabic);
206    }
207    if lower_title.contains("hindi") {
208        languages.push(Language::Hindi);
209    }
210    if lower_title.contains("malayalam") {
211        languages.push(Language::Malayalam);
212    }
213    if lower_title.contains("ukrainian") {
214        languages.push(Language::Ukrainian);
215    }
216    if lower_title.contains("bulgarian") {
217        languages.push(Language::Bulgarian);
218    }
219    if lower_title.contains("slovak") {
220        languages.push(Language::Slovak);
221    }
222    if lower_title.contains("brazilian") || lower_title.contains("dublado") {
223        languages.push(Language::PortugueseBrazil);
224    }
225    if lower_title.contains("latino") {
226        languages.push(Language::SpanishLatino);
227    }
228    if lower_title.contains("latvian") {
229        languages.push(Language::Latvian);
230    }
231
232    // Step 3: regex pass (LanguageParser.cs:182-187 -> RegexLanguage 337-481).
233    languages.extend(regex_language(title));
234
235    // Step 4: late English substring check (LanguageParser.cs:189-192). Note
236    // this fires AFTER the regex pass, so a release with "English" in the
237    // title gets English appended even if the regex pass already added other
238    // languages.
239    if lower_title.contains("english") {
240        languages.push(Language::English);
241    }
242
243    // Step 5: Unknown fallback when nothing matched (LanguageParser.cs:194-197).
244    // Plan-spec said "English fallback"; C# uses Unknown. Standing rule 1
245    // says C# wins.
246    if languages.is_empty() {
247        languages.push(Language::Unknown);
248    }
249
250    // Step 6: German DL/ML special handling (LanguageParser.cs:199-212).
251    // Predicate is "exactly one language and it is German".
252    if languages.len() == 1 && languages[0] == Language::German {
253        if german_dual_language_matches(title) {
254            languages.push(Language::Original);
255        } else if regexes::GERMAN_MULTI_LANGUAGE_REGEX.is_match(title) {
256            languages.push(Language::Original);
257            languages.push(Language::English);
258        }
259    }
260
261    // Step 7: dedup by enum int value, preserving first-seen order
262    // (LanguageParser.cs:214 -> `DistinctBy(l => (int)l)`).
263    let mut seen: HashSet<i32> = HashSet::new();
264    languages
265        .into_iter()
266        .filter(|l| seen.insert(*l as i32))
267        .collect()
268}
269
270/// Run the case-sensitive (LT/CZ/PL/BG/SK) and case-insensitive
271/// `LanguageRegex` passes. Mirrors C# `RegexLanguage` (lines 337-481).
272///
273/// C# uses `Regex.Match` (single match) for `CaseSensitiveLanguageRegex`
274/// and `Regex.Matches` (all matches) for `LanguageRegex`. We mirror that:
275/// the case-sensitive scan reports each LT/CZ/PL/BG/SK group once if any
276/// captures hit, while `LanguageRegex` walks every match and emits a
277/// `Language` for each successful named group. Dedup happens at the
278/// caller (Step 7).
279pub(crate) fn regex_language(title: &str) -> Vec<Language> {
280    let mut languages: Vec<Language> = Vec::new();
281
282    // Case-sensitive LT/CZ/PL/BG/SK pass with SUB lookaround filter.
283    // C# uses `Regex.Match` (first match) here, but the alternation only
284    // names one group per branch; iterating captures gives us each branch
285    // independently while applying the SUB gate per match span.
286    for caps in regexes::CASE_SENSITIVE_LANGUAGE_REGEX.captures_iter(title) {
287        // The whole-match span is what the SUB gate looks left/right of.
288        // We use the named-group span (which equals the whole match for
289        // alternation branches with no surrounding groups), so left/right
290        // checks line up with C#'s `(?<!SUB[\W|_|^])...(?![\W|_|^]SUB)`.
291        let m = caps.get(0).expect("regex match always has group 0");
292        if !sub_gate_passes(title, m.start(), m.end()) {
293            continue;
294        }
295        if caps.name("lithuanian").is_some() {
296            languages.push(Language::Lithuanian);
297        }
298        if caps.name("czech").is_some() {
299            languages.push(Language::Czech);
300        }
301        if caps.name("polish").is_some() {
302            languages.push(Language::Polish);
303        }
304        if caps.name("bulgarian").is_some() {
305            languages.push(Language::Bulgarian);
306        }
307        if caps.name("slovak").is_some() {
308            languages.push(Language::Slovak);
309        }
310    }
311
312    // Case-insensitive `LanguageRegex` pass.
313    for caps in regexes::LANGUAGE_REGEX.captures_iter(title) {
314        // C# emits one `Language` per successful named group per match.
315        // Each branch in the alternation can have at most one group set
316        // per match because the alternation is mutually exclusive, but
317        // the `Matches` walk yields multiple matches across the title.
318        if caps.name("english").is_some() {
319            languages.push(Language::English);
320        }
321        if caps.name("italian").is_some() {
322            languages.push(Language::Italian);
323        }
324        if caps.name("german").is_some() {
325            languages.push(Language::German);
326        }
327        if caps.name("flemish").is_some() {
328            languages.push(Language::Flemish);
329        }
330        if caps.name("greek").is_some() {
331            languages.push(Language::Greek);
332        }
333        if caps.name("french").is_some() {
334            languages.push(Language::French);
335        }
336        if caps.name("russian").is_some() {
337            languages.push(Language::Russian);
338        }
339        // C# also checks `match.Groups["dutch"].Success` (line 409-412),
340        // but `LanguageRegex` declares no `dutch` group; that branch is
341        // unreachable in C# and we omit it (see file header note 4).
342        if caps.name("hungarian").is_some() {
343            languages.push(Language::Hungarian);
344        }
345        if caps.name("hebrew").is_some() {
346            languages.push(Language::Hebrew);
347        }
348        if caps.name("polish").is_some() {
349            languages.push(Language::Polish);
350        }
351        if caps.name("chinese").is_some() {
352            languages.push(Language::Chinese);
353        }
354        if caps.name("bulgarian").is_some() {
355            languages.push(Language::Bulgarian);
356        }
357        if caps.name("ukrainian").is_some() {
358            languages.push(Language::Ukrainian);
359        }
360        if let Some(spa) = caps.name("spanish") {
361            // Apply the `(?!\(Latino\))` post-match filter dropped from the
362            // regex above.
363            if spanish_should_fire(title, spa.end()) {
364                languages.push(Language::Spanish);
365            }
366        }
367        if caps.name("thai").is_some() {
368            languages.push(Language::Thai);
369        }
370        if caps.name("romanian").is_some() {
371            languages.push(Language::Romanian);
372        }
373        if caps.name("catalan").is_some() {
374            languages.push(Language::Catalan);
375        }
376        if caps.name("latvian").is_some() {
377            languages.push(Language::Latvian);
378        }
379        if caps.name("turkish").is_some() {
380            languages.push(Language::Turkish);
381        }
382        if caps.name("original").is_some() {
383            languages.push(Language::Original);
384        }
385    }
386
387    languages
388}
389
390/// Reapplies the `(?!\(Latino\))` lookahead from the C# spanish branch.
391///
392/// C# pattern: `\b(?:español|castellano|esp|spa(?!\(Latino\)))\b`. Note
393/// the lookahead is on the `spa` alternative only; `español`, `castellano`,
394/// and `esp` always pass. The Rust regex captures the matched text but
395/// not which alternative it took, so we approximate by checking if the
396/// captured text is exactly `spa` (case-insensitive). If yes, gate on
397/// the chars after the match; otherwise pass.
398///
399/// `spa_end` is the byte offset of the end of the spanish capture; the
400/// lookahead reads from there.
401pub(crate) fn spanish_should_fire(title: &str, spa_end: usize) -> bool {
402    // The lookahead in C# fires regardless of which alternative matched
403    // because the regex backtracker would only enter the lookahead on
404    // the `spa` branch (the others don't have one), so a successful
405    // match against `español` / `castellano` / `esp` always reaches the
406    // closing `\b` without consulting the lookahead. We need to know
407    // which alternative the Rust regex took. The simplest signal is the
408    // length of the captured text minus the start-anchor: `spa` is the
409    // shortest 3-letter form, so look at the 3 chars before `spa_end`.
410    //
411    // We don't have the start offset here (only the end), so peek at
412    // the 6 chars before `spa_end` to disambiguate `spa` from `espa`
413    // (the second-shortest). C# `español` / `castellano` / `esp` all
414    // have a non-`spa` byte at offset `spa_end - 4` (i.e. `e`, `n`, or
415    // start-of-class), while bare `spa` has `\b` (a non-word boundary)
416    // at `spa_end - 4` -- distinguishable by checking whether the
417    // captured tail is `spa` AND the byte before it is a non-word.
418    let bytes = title.as_bytes();
419    if spa_end < 3 {
420        return true;
421    }
422    let tail = &bytes[spa_end - 3..spa_end];
423    let is_bare_spa =
424        tail.eq_ignore_ascii_case(b"spa") && (spa_end == 3 || !is_word_byte(bytes[spa_end - 4]));
425    if !is_bare_spa {
426        return true;
427    }
428    // Bare `spa` matched: enforce the negative lookahead.
429    let after = &bytes[spa_end..];
430    !starts_with_latino(after)
431}
432
433/// `(latino)` literal, case-insensitive against ASCII bytes. Mirrors the
434/// `\(Latino\)` literal in the C# spanish branch's negative lookahead.
435fn starts_with_latino(after: &[u8]) -> bool {
436    const LATINO: &[u8] = b"(latino)";
437    if after.len() < LATINO.len() {
438        return false;
439    }
440    after[..LATINO.len()].eq_ignore_ascii_case(LATINO)
441}
442
443/// Reapplies the SUB negative lookbehind/lookahead from
444/// `CaseSensitiveLanguageRegex`. Returns `true` if the match passes
445/// both gates.
446///
447/// C# pattern (lookarounds only): `(?<!SUB[\W|_|^])` before the alternation
448/// and `(?![\W|_|^]SUB)` after. Inside the `[...]` class:
449/// - `\W` = non-word
450/// - `|` = literal pipe (not alternation, since we're in a class)
451/// - `_` = literal underscore
452/// - `^` = literal caret (not start-of-string anchor, since not first char)
453///
454/// `\W` already covers `|` and `^` (both non-word ASCII), so the class is
455/// functionally `[\W_]`: any char that is non-word OR underscore. The `(?i)`
456/// mode-modifier groups in C# only affect the SUB literal -- which is
457/// already uppercase ASCII, so case-insensitivity matters for cases like
458/// `sub.LT`. We apply the same case-insensitive `SUB` match here.
459pub(crate) fn sub_gate_passes(title: &str, match_start: usize, match_end: usize) -> bool {
460    let bytes = title.as_bytes();
461
462    // Lookbehind: `(?<!SUB[\W|_|^])`. The 4 chars immediately before
463    // match_start must NOT be `SUB` (case-insensitive) followed by a char
464    // in `[\W_]`.
465    if match_start >= 4 {
466        let prefix = &bytes[match_start - 4..match_start];
467        let sub_part = &prefix[..3];
468        let sep = prefix[3];
469        if sub_part.eq_ignore_ascii_case(b"sub") && (!is_word_byte(sep) || sep == b'_') {
470            return false;
471        }
472    }
473
474    // Lookahead: `(?![\W|_|^]SUB)`. The 4 chars immediately after
475    // match_end must NOT be a char in `[\W_]` followed by `SUB`
476    // (case-insensitive).
477    if match_end + 4 <= bytes.len() {
478        let suffix = &bytes[match_end..match_end + 4];
479        let sep = suffix[0];
480        let sub_part = &suffix[1..4];
481        if sub_part.eq_ignore_ascii_case(b"sub") && (!is_word_byte(sep) || sep == b'_') {
482            return false;
483        }
484    }
485
486    true
487}
488
489/// Reapplies the variable-length lookbehind from
490/// `GermanDualLanguageRegex`. Returns `true` if the title contains a
491/// `\bDL\b` whose preceding chars do NOT form `WEB` or `WEB[-_. ]`.
492///
493/// C# pattern: `(?<!WEB[-_. ]?)\bDL\b` (case-insensitive). The `[-_. ]?`
494/// is optional, so the lookbehind is 3 or 4 chars wide. Rust regex
495/// supports neither variable-length nor any lookbehind, so we walk every
496/// `\bDL\b` candidate and apply the rejection in code.
497pub(crate) fn german_dual_language_matches(title: &str) -> bool {
498    let bytes = title.as_bytes();
499    for m in regexes::GERMAN_DUAL_LANGUAGE_REGEX.find_iter(title) {
500        let start = m.start();
501        // 4-char lookbehind: `WEB[-_. ]` immediately before `DL`.
502        if start >= 4 {
503            let prefix = &bytes[start - 4..start];
504            let web_part = &prefix[..3];
505            let sep = prefix[3];
506            if web_part.eq_ignore_ascii_case(b"web") && matches!(sep, b'-' | b'_' | b'.' | b' ') {
507                continue;
508            }
509        }
510        // 3-char lookbehind: `WEB` immediately before `DL` (no separator).
511        if start >= 3 {
512            let prefix = &bytes[start - 3..start];
513            if prefix.eq_ignore_ascii_case(b"web") {
514                continue;
515            }
516        }
517        // Lookbehind passed: this `\bDL\b` survives.
518        return true;
519    }
520    false
521}
522
523/// ASCII word-byte test mirroring regex `\w`: `[A-Za-z0-9_]`.
524///
525/// The byte form is sound because the helpers only consult byte offsets
526/// returned by the `regex` crate (always at UTF-8 char boundaries) and
527/// only compare against ASCII separators that fit in one byte.
528/// Continuation bytes of a multi-byte char are in `0x80..=0xBF`, which
529/// `is_word_byte` correctly classifies as non-word.
530fn is_word_byte(b: u8) -> bool {
531    b.is_ascii_alphanumeric() || b == b'_'
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    // ----- Plan-required tests -------------------------------------------
539
540    #[test]
541    fn detects_spanish_substring() {
542        // After CleanSeriesTitleRegex strips `Show.`, the title becomes
543        // `S01.SPANISH.1080p`... actually `Show.SPANISH.1080p` has no
544        // S\d{2} season tag, so the cleaner does NOT fire and the full
545        // title is scanned. "spanish" substring fires.
546        assert_eq!(
547            parse_languages("Show.SPANISH.1080p"),
548            vec![Language::Spanish]
549        );
550    }
551
552    #[test]
553    fn detects_japanese_substring() {
554        assert_eq!(
555            parse_languages("Show.JAPANESE.1080p"),
556            vec![Language::Japanese]
557        );
558    }
559
560    #[test]
561    fn detects_chs_bracket_chinese() {
562        assert_eq!(
563            parse_languages("[abc] Show - 01 [CHS]"),
564            vec![Language::Chinese]
565        );
566    }
567
568    #[test]
569    fn detects_german() {
570        assert_eq!(parse_languages("Show.GERMAN.1080p"), vec![Language::German]);
571    }
572
573    #[test]
574    fn detects_german_dl_adds_original() {
575        let l = parse_languages("Show.German.DL.1080p");
576        assert!(l.contains(&Language::German));
577        assert!(l.contains(&Language::Original));
578    }
579
580    #[test]
581    fn detects_german_ml_adds_original_and_english() {
582        let l = parse_languages("Show.German.ML.1080p");
583        assert!(l.contains(&Language::German));
584        assert!(l.contains(&Language::Original));
585        assert!(l.contains(&Language::English));
586    }
587
588    #[test]
589    fn lt_isolated_detects_lithuanian() {
590        // "Show.LT.1080p" has no S\d{2} season tag so the cleaner does
591        // not fire. The case-sensitive `LT` regex matches between two
592        // non-word chars; SUB gate passes (no SUB nearby).
593        let l = parse_languages("Show.LT.1080p");
594        assert!(l.contains(&Language::Lithuanian));
595    }
596
597    #[test]
598    fn lt_with_sub_does_not_detect_lithuanian() {
599        // "Show.LT.SUB.1080p": after `LT` comes `.SUB`. The C# negative
600        // lookahead `(?![\W|_|^]SUB)` rejects this match.
601        let l = parse_languages("Show.LT.SUB.1080p");
602        assert!(!l.contains(&Language::Lithuanian));
603    }
604
605    #[test]
606    fn defaults_to_unknown() {
607        // C# adds Language.Unknown when nothing else matched; the plan-spec
608        // wrongly said English. Test mirrors C# truth.
609        assert_eq!(
610            parse_languages("Show.S01E01.1080p"),
611            vec![Language::Unknown]
612        );
613    }
614
615    #[test]
616    fn dedupes_languages() {
617        // "Show.GERMAN.German.DL.1080p": "german" substring + LanguageRegex
618        // both fire. Dedup leaves one Language::German.
619        let l = parse_languages("Show.GERMAN.German.DL.1080p");
620        assert_eq!(l.iter().filter(|&&x| x == Language::German).count(), 1);
621    }
622
623    // ----- C# LanguageParserFixture parity (the substring branches) --------
624
625    #[test]
626    fn fixture_unknown_after_clean_series_title_strip() {
627        // C# fixture line 36-50: "spanish" appears in the show prefix only;
628        // CleanSeriesTitleRegex strips it, leaving a release that matches
629        // no language => Unknown.
630        let l = parse_languages(
631            "Spanish Killroy was Here S02E02 Flodden 720p AMZN WEB-DL DDP5 1 H 264-NTb",
632        );
633        assert!(l.contains(&Language::Unknown), "got {l:?}");
634    }
635
636    #[test]
637    fn fixture_subfrench_is_unknown() {
638        // C# fixture line 44: "Series.Title.S01E01.SUBFRENCH.1080p.WEB.x264-GROUP"
639        // returns Unknown. The french regex requires `(?:\W|_)FRENCH(?:\W|_)`,
640        // so `SUBFRENCH` (with `B` directly preceding) does not match.
641        let l = parse_languages("Series.Title.S01E01.SUBFRENCH.1080p.WEB.x264-GROUP");
642        assert!(l.contains(&Language::Unknown), "got {l:?}");
643    }
644
645    #[test]
646    fn fixture_french_branches() {
647        // C# fixture lines 62-69: French detected via FR/VF/VF2/VFF/VFI/VFQ/TRUEFRENCH/FRENCH.
648        for title in [
649            "Title.the.Series.2009.S01E14.French.HDTV.XviD-LOL",
650            "Title.the.Series.The.1x13.Tueurs.De.Flics.FR.DVDRip.XviD",
651            "Title.S01.720p.VF.WEB-DL.AAC2.0.H.264-BTN",
652            "Title.S01.720p.VFF.WEB-DL.AAC2.0.H.264-BTN",
653            "Title.S01.720p.TRUEFRENCH.WEB-DL.AAC2.0.H.264-BTN",
654        ] {
655            let l = parse_languages(title);
656            assert!(l.contains(&Language::French), "title={title} got {l:?}");
657        }
658    }
659
660    #[test]
661    fn fixture_german_with_dl_adds_original_but_webdl_does_not() {
662        // C# fixture lines 403-405 (German.DL.1080p.BluRay) -> Original added.
663        for title in [
664            "Series.Title.S01E01.German.DL.1080p.BluRay.x264-RlsGrp",
665            "Series.Title.S01E01.GERMAN.DL.1080P.WEB.H264-RlsGrp",
666            "Series.Title.2023.S01E01.German.DL.EAC3.1080p.DSNP.WEB.H264-RlsGrp",
667        ] {
668            let l = parse_languages(title);
669            assert!(l.contains(&Language::German), "title={title} got {l:?}");
670            assert!(l.contains(&Language::Original), "title={title} got {l:?}");
671        }
672        // C# fixture lines 414-417 (German + WEB-DL/WEBDL) -> Original NOT added.
673        for title in [
674            "Series.Title.2023.S01E01.GERMAN.1080P.WEB-DL.H264-RlsGrp",
675            "Series.Title.2023.S01E01.GERMAN.1080P.WEB.DL.H264-RlsGrp",
676            "Series Title 2023 S01E01 GERMAN 1080P WEB DL H264-RlsGrp",
677            "Series.Title.2023.S01E01.GERMAN.1080P.WEBDL.H264-RlsGrp",
678        ] {
679            let l = parse_languages(title);
680            assert!(l.contains(&Language::German), "title={title} got {l:?}");
681            assert_eq!(l.len(), 1, "title={title} expected German only, got {l:?}");
682        }
683    }
684
685    #[test]
686    fn fixture_german_ml_adds_original_and_english() {
687        // C# fixture line 425.
688        let l = parse_languages("Series.Title.2023.S01.German.ML.EAC3.1080p.NF.WEB.H264-RlsGrp");
689        assert!(l.contains(&Language::German), "got {l:?}");
690        assert!(l.contains(&Language::Original), "got {l:?}");
691        assert!(l.contains(&Language::English), "got {l:?}");
692        assert_eq!(l.len(), 3, "got {l:?}");
693    }
694
695    #[test]
696    fn fixture_polish_via_regex_branches() {
697        // C# fixture lines 177-186: Polish detected via PL/LEK combos.
698        for title in [
699            "Title.the.Series.2009.S01E14.Polish.HDTV.XviD-LOL",
700            "Title.the.Series.2009.S01E14.PL.HDTV.XviD-LOL",
701            "Title.the.Series.2009.S01E14.PLLEK.HDTV.XviD-LOL",
702            "Title.the.Series.2009.S01E14.PL-LEK.HDTV.XviD-LOL",
703            "Title.the.Series.2009.S01E14.PLDUB.HDTV.XviD-LOL",
704            "Title.the.Series.2009.S01E14.LEK-PL.HDTV.XviD-LOL",
705        ] {
706            let l = parse_languages(title);
707            assert!(l.contains(&Language::Polish), "title={title} got {l:?}");
708        }
709    }
710
711    #[test]
712    fn fixture_russian_lt_lv_ru_combo() {
713        // C# fixture line 159: LT.LV.RU triggers Lithuanian + Latvian + Russian.
714        let l = parse_languages(
715            "Title.the.Series.S01.COMPLETE.2009.1080p.WEB-DL.x264.AVC.AAC.LT.LV.RU",
716        );
717        assert!(l.contains(&Language::Lithuanian), "got {l:?}");
718        assert!(l.contains(&Language::Latvian), "got {l:?}");
719        assert!(l.contains(&Language::Russian), "got {l:?}");
720    }
721
722    #[test]
723    fn fixture_slovak_eng_sk() {
724        // C# fixture line 322: "ENG.SK-LOL" -> Slovak + English.
725        let l = parse_languages("Title.the.Series.2021.S01E11.HDTV.XviD.ENG.SK-LOL");
726        assert!(l.contains(&Language::Slovak), "got {l:?}");
727        assert!(l.contains(&Language::English), "got {l:?}");
728    }
729
730    #[test]
731    fn fixture_czech_via_regex() {
732        // C# fixture line 272 only asserts Contains(Czech). The english
733        // branch in C# is `(?:\W|_)\b(?:ing|eng)\b`, so the bare `EN`
734        // tail does not match -- and the Unknown fallback is suppressed
735        // because Czech already populated the list.
736        let l = parse_languages("Title.the.Series.S07E11.WEB Rip.XviD.Louige-CZ.EN.5.1");
737        assert!(l.contains(&Language::Czech), "got {l:?}");
738    }
739
740    #[test]
741    fn fixture_chinese_chs_cht_big5_gb() {
742        for title in [
743            "[abc] My Series - 01 [CHS]",
744            "[abc] My Series - 01 [CHT]",
745            "[abc] My Series - 01 [BIG5]",
746            "[abc] My Series - 01 [GB]",
747        ] {
748            let l = parse_languages(title);
749            assert!(l.contains(&Language::Chinese), "title={title} got {l:?}");
750        }
751    }
752
753    #[test]
754    fn fixture_chinese_unicode_branches() {
755        // The chinese branch in C# matches the literal CJK chars 简, 繁, 字幕.
756        for title in [
757            "[abc] My Series - 01 [繁中]",
758            "[abc] My Series - 01 [简繁外挂]",
759            "[ABC字幕组] My Series - 01 [HDTV]",
760        ] {
761            let l = parse_languages(title);
762            assert!(l.contains(&Language::Chinese), "title={title} got {l:?}");
763        }
764    }
765
766    #[test]
767    fn fixture_brazilian_dublado_to_portuguese_brazil() {
768        for title in [
769            "Title.the.Series.2009.S01E14.Brazilian.HDTV.XviD-LOL",
770            "Title.the.Series.2009.S01E14.Dublado.HDTV.XviD-LOL",
771        ] {
772            let l = parse_languages(title);
773            assert!(
774                l.contains(&Language::PortugueseBrazil),
775                "title={title} got {l:?}"
776            );
777        }
778    }
779
780    #[test]
781    fn fixture_spanish_latino() {
782        // C# fixture lines 346-351.
783        for title in [
784            "Series.Title.S01.2019.720p_Eng-Spa(Latino)_MovieClubMx",
785            "Series.Title.1.WEB-DL.720p.Complete.Latino.YG",
786            "Series Title latino",
787        ] {
788            let l = parse_languages(title);
789            assert!(
790                l.contains(&Language::SpanishLatino),
791                "title={title} got {l:?}"
792            );
793        }
794    }
795
796    #[test]
797    fn fixture_spa_latino_does_not_double_count_spanish() {
798        // The C# spanish branch's `(?!\(Latino\))` lookahead prevents
799        // `Spa(Latino)` from matching as Spanish.
800        let l = parse_languages("Series.Title.S01.2019.720p_Eng-Spa(Latino)_MovieClubMx");
801        assert!(l.contains(&Language::SpanishLatino), "got {l:?}");
802        assert!(!l.contains(&Language::Spanish), "got {l:?}");
803    }
804
805    #[test]
806    fn fixture_hindi_and_english_pair() {
807        // C# fixture line 378-379.
808        let l = parse_languages(
809            "The Shadow Series S01 E01-08 WebRip Dual Audio [Hindi 5.1 + English 5.1] 720p x264 AAC ESub",
810        );
811        assert!(l.contains(&Language::Hindi), "got {l:?}");
812        assert!(l.contains(&Language::English), "got {l:?}");
813    }
814
815    #[test]
816    fn fixture_ukrainian_cyrillic_with_x() {
817        // C# fixture line 312-314: Ukrainian via "ukr" (and the (?:\dx?)?
818        // optional 1x prefix).
819        for title in [
820            "Гало(Сезон 1, серії 1-5) / SeriesTitle(Season 1, episodes 1-5) (2022) WEBRip-AVC Ukr/Eng",
821            "Книга Боби Фетта(Сезон 1) / Series Title(Season 1) (2021) WEB-DLRip Ukr/Eng",
822        ] {
823            let l = parse_languages(title);
824            assert!(l.contains(&Language::Ukrainian), "title={title} got {l:?}");
825            assert!(l.contains(&Language::English), "title={title} got {l:?}");
826        }
827    }
828
829    #[test]
830    fn fixture_original_branch() {
831        // C# fixture lines 435-437.
832        for title in [
833            "Series.Title.S01E01.Original.1080P.WEB.H264-RlsGrp",
834            "Series.Title.S01E01.Orig.1080P.WEB.H264-RlsGrp",
835        ] {
836            let l = parse_languages(title);
837            assert_eq!(l.len(), 1, "title={title} got {l:?}");
838            assert!(l.contains(&Language::Original), "title={title} got {l:?}");
839        }
840    }
841
842    #[test]
843    fn fixture_videomann_german() {
844        // C# fixture line 87: the german branch has `german\b|videomann|ger[. ]dub`.
845        let l = parse_languages("The Series Title - S02E16 - Kampfhaehne - mkv - by Videomann");
846        assert!(l.contains(&Language::German), "got {l:?}");
847    }
848
849    #[test]
850    fn fixture_ger_dub() {
851        // C# fixture line 88: "Ger.Dub" matches the german branch.
852        let l = parse_languages("Series.Title.S01E03.Ger.Dub.AAC.1080p.WebDL.x264-TKP21");
853        assert!(l.contains(&Language::German), "got {l:?}");
854    }
855
856    // ----- Edge cases that pin our helper-function logic -------------------
857
858    #[test]
859    fn sub_gate_rejects_subfrench_style_left_side_for_lt() {
860        // Case `SUB.LT` (left-side SUB before LT).
861        let l = parse_languages("Show.SUB.LT.1080p");
862        assert!(!l.contains(&Language::Lithuanian), "got {l:?}");
863    }
864
865    #[test]
866    fn sub_gate_rejects_lt_followed_by_sub_with_underscore() {
867        // The class `[\W_]` matches `_`, so `LT_SUB` should also reject.
868        let l = parse_languages("Show.LT_SUB.1080p");
869        assert!(!l.contains(&Language::Lithuanian), "got {l:?}");
870    }
871
872    #[test]
873    fn german_dl_after_webdash_alt_separator_underscore() {
874        // C# pattern is `WEB[-_. ]?` -- the `_` separator should also reject.
875        let l = parse_languages("Show.S01E01.German.WEB_DL.1080p");
876        assert_eq!(l, vec![Language::German], "got {l:?}");
877    }
878
879    #[test]
880    fn empty_title_produces_unknown() {
881        assert_eq!(parse_languages(""), vec![Language::Unknown]);
882    }
883
884    #[test]
885    fn english_substring_path_fires_on_lowercase_english() {
886        // "english" substring (case-insensitive) at the late check.
887        let l = parse_languages("Show.S01E01.english.subtitles.1080p");
888        assert!(l.contains(&Language::English), "got {l:?}");
889    }
890
891    #[test]
892    fn dedup_preserves_first_seen_order() {
893        // Multiple paths add the same language; dedup should keep the
894        // first occurrence's position. Build a release that adds Polish
895        // via substring first, then via the LanguageRegex `polish` branch
896        // (`PLDUB`), then via case-sensitive `PL`. The input has no
897        // `S\d{2}` season tag, so `CleanSeriesTitleRegex` does NOT strip
898        // the leading "Polish" token — meaning the substring path fires
899        // alongside the two regex paths. The result should have Polish
900        // in the Polish-substring slot (first) only.
901        let l = parse_languages("Polish.PLDUB.PL.1080p");
902        let pl_count = l.iter().filter(|&&x| x == Language::Polish).count();
903        assert_eq!(pl_count, 1, "got {l:?}");
904    }
905}