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}