Skip to main content

avatarr_parser/episode/
parser.rs

1// Ported from Sonarr v4.0.17.2952 (97e85a90):
2//   src/NzbDrone.Core/Parser/Parser.cs — ParseTitle + ParseMatchCollection
3
4use once_cell::sync::Lazy;
5use regex::Regex;
6
7use super::ParsedEpisodeInfo;
8use super::regexes;
9use crate::normalize;
10
11/// Parse a release title for episode/season information.
12/// Returns `None` when no regex matches (mirrors Sonarr returning null).
13pub fn parse_title(title: &str) -> Option<ParsedEpisodeInfo> {
14    let simple_title = normalize::preprocess_title(title)?;
15
16    for entry in regexes::REPORT_TITLE_REGEXES.iter() {
17        if let Some(mut info) = try_regex(entry.index, entry.regex, &simple_title) {
18            // Post-cascade: populate release_group from original title
19            // (Sonarr Parser.cs:787 — ParseReleaseGroup(releaseTitle))
20            info.release_group = crate::release_group::parse_release_group(title);
21
22            // Anime subgroup override: if cascade captured <subgroup>,
23            // use it instead (Sonarr Parser.cs:789-793)
24            if let Some(caps) = entry.regex.captures(&simple_title)
25                && let Some(sg) = caps.name("subgroup")
26            {
27                let sg_val = sg.as_str().trim();
28                if !sg_val.is_empty() {
29                    info.release_group = Some(sg_val.to_string());
30                }
31            }
32
33            return Some(info);
34        }
35    }
36
37    None
38}
39
40/// Sonarr `(?!-[a-z]+)` lookahead emulation.
41///
42/// Returns true when the byte position `end` in `title` is followed by `-`
43/// then one or more ASCII lowercase letters. Used to reject absolute episode
44/// captures where the number is part of a token like `300-nen` or `100-jin`
45/// rather than a true episode number. Returns false when `end >= title.len()`
46/// (no panic — `get` returns `None`, the `unwrap_or(&[])` falls through to an
47/// empty slice that fails the slice-pattern match).
48fn followed_by_hyphen_lowercase_word(title: &str, end: usize) -> bool {
49    let after = title.as_bytes().get(end..).unwrap_or(&[]);
50    matches!(after, [b'-', rest @ ..] if rest.first().is_some_and(|c| c.is_ascii_lowercase()))
51}
52
53/// Sonarr `.Captures.Last()` emulation for broad absolute regexes.
54///
55/// Returns true when the byte range `[end, ..)` in `title` has the shape
56/// ` <words> - \d+` — i.e. one-or-more space-separated alpha-words, then a
57/// space-dash-space, then digits. When this matches, the captured absolute
58/// episode is *not* the last episode token in the title; the C# engine
59/// would have iterated `+` and captured the trailing digits instead.
60fn followed_by_word_chain_then_dash_episode(title: &str, end: usize) -> bool {
61    static TAIL_RE: Lazy<Regex> = Lazy::new(|| {
62        Regex::new(r"^\s+[A-Za-z][A-Za-z'\-]*(?:\s+[A-Za-z][A-Za-z'\-]*)*\s*-\s*\d+")
63            .expect("TAIL_RE")
64    });
65    let after = title.get(end..).unwrap_or("");
66    TAIL_RE.is_match(after)
67}
68
69/// Mask digit runs at the given byte ranges with ASCII 'X'. Preserves byte
70/// lengths so capture positions on the masked string align 1:1 with the
71/// original — title-text re-slicing therefore needs no offset bookkeeping.
72fn mask_digit_ranges(input: &str, ranges: &[(usize, usize)]) -> String {
73    let mut out = String::with_capacity(input.len());
74    let mut last = 0;
75    for &(s, e) in ranges {
76        out.push_str(&input[last..s]);
77        out.extend(std::iter::repeat_n('X', e - s));
78        last = e;
79    }
80    out.push_str(&input[last..]);
81    out
82}
83
84fn try_regex(index: u8, regex: &Regex, title: &str) -> Option<ParsedEpisodeInfo> {
85    // Sonarr `(?!-[a-z]+)` lookahead emulation via mask-and-retry.
86    //
87    // For absolute-pattern indices, when the matched `absoluteepisode` capture
88    // is followed by `-[a-z]+` (e.g. "300-nen"), mask those digits in a
89    // working copy of the title and re-run the regex. This emulates C#'s
90    // engine backtracking the absolute quantifier to a later position when
91    // the lookahead fails, yielding the trailing real episode number ("02"
92    // in `300-nen, ... 02`). Mask preserves byte lengths, so capture
93    // byte-positions on the working string align 1:1 with the original;
94    // `series_title` is patched post-hoc by re-slicing from the original.
95    //
96    // Other indices use `regex.captures(title)` directly — non-absolute
97    // patterns don't carry `absoluteepisode` captures so the retry would
98    // be a no-op, and we'd rather skip the per-call ownership cost.
99    let absolute_index = matches!(
100        index,
101        7 | 8 | 11..=21 | 26 | 28..=32 | 65..=67 | 77 | 81 | 87..=94
102    );
103    let working: String;
104    let title_for_caps: &str = if absolute_index {
105        // Iteratively mask digit runs that fail the `(?!-[a-z]+)` lookahead
106        // until the regex either no longer matches or matches with a clean
107        // absolute capture. MAX_RETRIES caps work cheaply — each iteration
108        // strictly reduces the number of digit runs in the working string.
109        const MAX_RETRIES: usize = 8;
110        let mut current = std::borrow::Cow::Borrowed(title);
111        let mut iters = 0;
112        loop {
113            let caps = regex.captures(current.as_ref())?;
114            let bad_ranges: Vec<(usize, usize)> = ["absoluteepisode", "absoluteepisode2"]
115                .iter()
116                .filter_map(|name| caps.name(name))
117                .filter(|m| {
118                    followed_by_hyphen_lowercase_word(current.as_ref(), m.end())
119                        || followed_by_word_chain_then_dash_episode(current.as_ref(), m.end())
120                })
121                .map(|m| (m.start(), m.end()))
122                .collect();
123            if bad_ranges.is_empty() {
124                break;
125            }
126            iters += 1;
127            if iters > MAX_RETRIES {
128                return None;
129            }
130            current = std::borrow::Cow::Owned(mask_digit_ranges(current.as_ref(), &bad_ranges));
131        }
132        match current {
133            std::borrow::Cow::Borrowed(s) => s,
134            std::borrow::Cow::Owned(s) => {
135                working = s;
136                working.as_str()
137            }
138        }
139    } else {
140        title
141    };
142    let caps = regex.captures(title_for_caps)?;
143    let full_match = caps.get(0)?.as_str();
144    // Capture byte ranges align with both `title_for_caps` (for downstream
145    // parser fns that read via `caps.name(...).as_str()`) AND with the
146    // original `title` (for post-hoc series_title patching). The `original`
147    // alias makes that intent explicit at the patch site.
148    let original = title;
149    // `title` from here forward is the working/masked variant — caps reference
150    // it directly. Retain `original` for post-process series_title patching.
151    let title = title_for_caps;
152
153    // m53b: opt-in diagnostic for matching-index discovery. Zero cost in
154    // release builds when the env var is unset (one syscall per match in
155    // debug builds, used during fixture-gap triage).
156    #[cfg(debug_assertions)]
157    if std::env::var("AVATARR_DEBUG_REGEX_INDEX").is_ok() {
158        eprintln!("regex matched: index={index} title={title:?}");
159    }
160
161    // Post-match: backreference validation for patterns using dual sep captures.
162    // C# `\k<sep>` requires the same separator character in both positions.
163    if matches!(index, 0 | 1)
164        && let (Some(s1), Some(s2)) = (caps.name("sep1"), caps.name("sep2"))
165        && s1.as_str() != s2.as_str()
166    {
167        return None;
168    }
169
170    // Post-match: for REGEX_01 (daily without title), reject if airday is
171    // followed by a digit — replaces C# `(?!\d)`.
172    if index == 1
173        && let Some(m) = caps.name("airday")
174        && m.end() < title.len()
175        && title.as_bytes()[m.end()].is_ascii_digit()
176    {
177        return None;
178    }
179
180    // Post-match: reject when ep/season captures are embedded in longer numbers.
181    // Skip for fused-digit formats, daily-only patterns, and absolute patterns
182    // (which use absoluteepisode, not ep/season).
183    if !matches!(
184        index,
185        0 | 1 | 7 | 8 | 10..=21 | 26 | 28..=32 | 34 | 35
186            | 45 | 46 | 49 | 65..=68 | 72 | 73 | 76..=82 | 87..=95
187    ) {
188        for name in ["ep", "ep1", "ep2", "season", "seasonpart"] {
189            if let Some(m) = caps.name(name)
190                && !normalize::digit_boundary_ok(title, m.start(), m.end())
191            {
192                return None;
193            }
194        }
195    }
196
197    // Post-match: multi-season pack 43 — reject when season2 capture is
198    // adjacent to digits (C# `(?!\d+)` after season2).
199    if index == 43 {
200        for name in ["season1", "season2"] {
201            if let Some(m) = caps.name(name)
202                && !normalize::digit_boundary_ok(title, m.start(), m.end())
203            {
204                return None;
205            }
206        }
207    }
208
209    // Post-match: season-only patterns 70/71 — reject when season number
210    // is followed by optional separator + digits.
211    // C# uses `(?![-_. ]?\d+)` which we can't express directly in Rust.
212    if matches!(index, 70 | 71)
213        && let Some(m) = caps.name("season")
214    {
215        let after = &title[m.end()..];
216        let skip_sep = after
217            .strip_prefix(|c: char| "-_. ".contains(c))
218            .unwrap_or(after);
219        if skip_sep.starts_with(|c: char| c.is_ascii_digit()) {
220            return None;
221        }
222    }
223
224    // Post-match: regex 82 (1103/1113 naming) — reject when the episode
225    // capture is followed by `\W\d+` or `\W(e|ep|x)\d+` or `)` or `]`.
226    // C# negative lookahead: `(?!p|i|\d+|\)|\]|\W\d+|\W(?:e|ep|x)\d+)`
227    if index == 82
228        && let Some(m) = caps.name("ep")
229    {
230        static EP82_REJECT_RE: Lazy<Regex> = Lazy::new(|| {
231            Regex::new(r"(?i)^(?:[pi]|\d+|[)\]]|\W\d+|\W(?:ep|e|x)\d+)").expect("EP82_REJECT_RE")
232        });
233        let after = &title[m.end()..];
234        if EP82_REJECT_RE.is_match(after) {
235            return None;
236        }
237    }
238
239    // Post-match: regex 75 (4-digit episode with title) — reject when the
240    // character before the season capture is preceded by `\d{1,2}-`.
241    // C# `(?<![()\[!]|\d{1,2}-)` on the separator prevents `30-04-2024` from
242    // being parsed as season=04, episode=2024.
243    if index == 75
244        && let Some(m) = caps.name("season")
245    {
246        let before = &title[..m.start()];
247        if let Some(prefix) = before.strip_suffix('-')
248            && prefix.ends_with(|c: char| c.is_ascii_digit())
249        {
250            return None;
251        }
252    }
253
254    // Post-match: REGEX_10 (anime S+E without absolute) — reject when the
255    // trailing `[_. ]` is followed by more digits, since that indicates
256    // a trailing absolute episode number which REGEX_13 should handle.
257    // C# has `(?:[_. ](?!\d+))` — we check the character after the
258    // `[_. ]` separator.
259    if index == 10
260        && let Some(ep_m) = caps.name("episode")
261    {
262        let after_ep = &title[ep_m.end()..];
263        // Skip one separator char and check if digit follows
264        if let Some(rest) = after_ep.strip_prefix(|c: char| "-_. ".contains(c))
265            && rest.starts_with(|c: char| c.is_ascii_digit())
266        {
267            return None;
268        }
269    }
270
271    // Post-match: boundary-check absoluteepisode captures for absolute patterns.
272    // Skip for 4-digit absolute (45/46) where adjacent digits are by design.
273    // Skip for index 10 (no absoluteepisode group).
274    //
275    // The Sonarr `(?!-[a-z]+)` lookahead is emulated up-front by the
276    // mask-and-retry loop at the top of `try_regex`, so by the time we reach
277    // here every absolute capture is guaranteed to NOT be followed by
278    // `-[a-z]+`. Only `digit_boundary_ok` remains as a residual check.
279    if matches!(
280        index,
281        7 | 8 | 11..=21 | 26 | 28..=32 | 65..=67 | 77 | 81 | 87..=94
282    ) {
283        for name in ["absoluteepisode", "absoluteepisode2"] {
284            if let Some(m) = caps.name(name)
285                && !normalize::digit_boundary_ok(title, m.start(), m.end())
286            {
287                return None;
288            }
289        }
290    }
291
292    // Post-match: all absolute patterns — reject when the absolute episode
293    // number is followed by `:` (it's part of the title, e.g. "Series 100:
294    // Bucket List"), because a colon after digits strongly indicates the
295    // number is part of the series title, not an episode number.
296    if matches!(
297        index,
298        7 | 8 | 11..=21 | 26 | 28..=32 | 45 | 46 | 65..=67 | 77 | 81 | 87..=94
299    ) && let Some(m) = caps.name("absoluteepisode")
300        && m.end() < title.len()
301        && title.as_bytes()[m.end()] == b':'
302    {
303        return None;
304    }
305
306    let mut result = match index {
307        // -- Daily leading-date patterns --
308        0 => parse_daily(&caps),
309        1 => parse_daily(&caps),
310
311        // -- Anime pattern with subgroup but season+episode only (no absolute) --
312        10 => parse_anime_season_episode(&caps),
313
314        // -- Absolute/anime patterns --
315        7 | 8 | 11..=21 | 26 | 28..=32 | 45 | 46 | 65..=67 | 77 | 81 | 87..=94 => {
316            parse_absolute(&caps, index, title)
317        }
318
319        // -- Daily with decomposed date + season/episode --
320        33 | 34 => parse_daily_with_episode(&caps),
321
322        // -- TJET wrestling daily --
323        35 => parse_daily(&caps),
324
325        // -- Season-only patterns (no episodes) --
326        43 => parse_multi_season(&caps),
327        44 => parse_partial_season(&caps),
328        69..=71 | 96 => parse_season_only(&caps),
329
330        // -- Daily with part --
331        49 => parse_daily_with_part(&caps),
332
333        // -- Mini-series: Part One/Two --
334        51 => parse_mini_series_word(&caps),
335
336        // -- Mini-series: XofY, Part N --
337        47 | 50 | 52 => parse_mini_series_part(&caps),
338
339        // -- Mini-series: E1-E2 --
340        48 => parse_mini_dual(&caps),
341
342        // -- Japanese variety shows: 2-digit year, handled as standard episode --
343        // C# treats airYear < 1900 as standard (not daily), defaults season=1.
344        68 => parse_generic(&caps, full_match, index, original),
345
346        // -- Spanish Cap.xxx (season+ep fused) --
347        72 => parse_spanish_cap(&caps, full_match),
348
349        // -- Short format 103/113 --
350        73 => parse_short_format(&caps, full_match),
351
352        // -- Main daily (YYYY.MM.DD) --
353        76 => parse_daily(&caps),
354
355        // -- Ambiguous dates --
356        78 | 79 => parse_ambiguous_date(&caps),
357
358        // -- Compact YYYYMMDD --
359        80 => parse_daily(&caps),
360
361        // -- 4-digit short 1103/1113 --
362        82 => parse_four_digit_short(&caps),
363
364        // -- Terrible multi: extant.10708 --
365        95 => parse_terrible_multi(&caps),
366
367        // -- Patterns with explicit ep1/ep2 named groups --
368        27 | 36..=38 | 64 | 83 => parse_explicit_dual(&caps, index, original),
369
370        // -- Standard patterns using generic extraction --
371        _ => parse_generic(&caps, full_match, index, original),
372    }?;
373
374    // Mask-and-retry post-process: when the working `title` differs from the
375    // `original` (we ran the regex against masked digits), `series_title`
376    // contains the masked text. Re-slice the title-capture's byte range from
377    // `original` and re-run the same `clean_series_title` normalisation so
378    // the masked 'X's are replaced with the real digits the parser saw.
379    //
380    // Byte positions align across `title` and `original` because masking
381    // preserves byte lengths (ASCII digit → ASCII 'X', 1 byte → 1 byte).
382    if title.as_ptr() != original.as_ptr()
383        && let Some(m) = caps.name("title")
384    {
385        result.series_title = normalize::clean_series_title(&original[m.start()..m.end()]);
386    }
387    Some(result)
388}
389
390// ---------------------------------------------------------------------------
391// Helpers
392// ---------------------------------------------------------------------------
393
394fn cap_i32(caps: &regex::Captures, name: &str) -> Option<i32> {
395    caps.name(name)?.as_str().parse::<i32>().ok()
396}
397
398fn cap_str<'a>(caps: &'a regex::Captures, name: &str) -> Option<&'a str> {
399    Some(caps.name(name)?.as_str())
400}
401
402fn title_from_caps(caps: &regex::Captures) -> String {
403    normalize::clean_series_title(cap_str(caps, "title").unwrap_or(""))
404}
405
406fn word_to_number(word: &str) -> Option<i32> {
407    match word.to_ascii_lowercase().as_str() {
408        "one" => Some(1),
409        "two" => Some(2),
410        "three" => Some(3),
411        "four" => Some(4),
412        "five" => Some(5),
413        "six" => Some(6),
414        "seven" => Some(7),
415        "eight" => Some(8),
416        "nine" => Some(9),
417        _ => None,
418    }
419}
420
421fn episode_range(first: i32, last: i32) -> Vec<i32> {
422    if first > last {
423        return Vec::new();
424    }
425    (first..=last).collect()
426}
427
428/// Extract ALL episode numbers from the full regex match string.
429static EPISODE_SCAN_RE: Lazy<Regex> = Lazy::new(|| {
430    Regex::new(r"(?i)(?:Episode\s+|[Ee][Pp]?|[Xx])(\d{1,5})").expect("EPISODE_SCAN_RE")
431});
432
433/// Matches bare numbers after dashes (for range continuations like E03-04-05).
434static DASH_CONTINUATION_RE: Lazy<Regex> =
435    Lazy::new(|| Regex::new(r"[-_](\d{1,5})").expect("DASH_CONTINUATION_RE"));
436
437fn extract_episodes_from_match(matched: &str) -> Vec<i32> {
438    let mut eps: Vec<i32> = Vec::new();
439    let mut last_ep_end: usize = 0;
440
441    for cap in EPISODE_SCAN_RE.captures_iter(matched) {
442        if let Ok(n) = cap[1].parse::<i32>()
443            && !eps.contains(&n)
444        {
445            eps.push(n);
446        }
447        if let Some(m) = cap.get(0) {
448            last_ep_end = m.end();
449        }
450    }
451
452    if !eps.is_empty() {
453        let mut pos = last_ep_end;
454        while pos < matched.len() {
455            let remaining = &matched[pos..];
456            match DASH_CONTINUATION_RE.find(remaining) {
457                Some(dm) if dm.start() == 0 => {
458                    if let Some(dcap) = DASH_CONTINUATION_RE.captures(remaining)
459                        && let Ok(n) = dcap[1].parse::<i32>()
460                        && !eps.contains(&n)
461                    {
462                        eps.push(n);
463                    }
464                    pos += dm.end();
465                }
466                _ => break,
467            }
468        }
469    }
470
471    // Sonarr range expansion: first..=last
472    if eps.len() >= 2 {
473        let first = eps[0];
474        let last = *eps.last().unwrap();
475        if last > first && (last - first + 1) as usize > eps.len() && (last - first) < 100 {
476            eps = episode_range(first, last);
477        }
478    }
479
480    eps
481}
482
483/// Extract season from matched text, looking for S## or ##x patterns.
484static SEASON_SCAN_RE: Lazy<Regex> =
485    Lazy::new(|| Regex::new(r"(?i)(?:S(\d{1,4})|(\d{1,4})x)").expect("SEASON_SCAN_RE"));
486
487fn extract_season_from_match(matched: &str) -> Option<i32> {
488    let cap = SEASON_SCAN_RE.captures(matched)?;
489    if let Some(m) = cap.get(1) {
490        return m.as_str().parse().ok();
491    }
492    if let Some(m) = cap.get(2) {
493        return m.as_str().parse().ok();
494    }
495    None
496}
497
498// ---------------------------------------------------------------------------
499// Generic episode extraction
500// ---------------------------------------------------------------------------
501
502fn parse_generic(
503    caps: &regex::Captures,
504    full_match: &str,
505    index: u8,
506    input: &str,
507) -> Option<ParsedEpisodeInfo> {
508    let title = title_from_caps(caps);
509
510    let season = cap_i32(caps, "season");
511    let ep = cap_i32(caps, "ep");
512    let ep1 = cap_i32(caps, "ep1");
513    let ep2 = cap_i32(caps, "ep2");
514
515    let season_number = match season {
516        Some(s) => s,
517        None => {
518            if index <= 6 || index == 97 {
519                extract_season_from_match(full_match)?
520            } else {
521                1
522            }
523        }
524    };
525
526    let episodes = if let Some(e) = ep {
527        let re_scanned = extract_episodes_from_match(full_match);
528        if re_scanned.len() > 1 {
529            re_scanned
530        } else {
531            vec![e]
532        }
533    } else if let Some(e1) = ep1 {
534        if let Some(e2) = ep2 {
535            let range = episode_range(e1, e2);
536            if range.is_empty() {
537                return None;
538            }
539            range
540        } else {
541            vec![e1]
542        }
543    } else {
544        let scanned = extract_episodes_from_match(full_match);
545        if scanned.is_empty() {
546            return None;
547        }
548        scanned
549    };
550
551    let is_split = cap_str(caps, "splitepisode").is_some();
552    let special = cap_str(caps, "special").is_some();
553
554    let mut info = ParsedEpisodeInfo {
555        series_title: title,
556        season_number,
557        episode_numbers: episodes,
558        is_split_episode: is_split,
559        special,
560        ..Default::default()
561    };
562
563    // m53b Failure 5: same tail-enrichment as parse_explicit_dual, gated on a
564    // multi-episode standard match. Index 25 (the F5 fixture's matching
565    // cascade index) routes here via the catch-all `_` arm in try_regex.
566    if info.episode_numbers.len() >= 2
567        && let Some(scan_start) = absolute_scan_start(caps)
568        && let Some(abs_range) =
569            enrich_absolute_from_tail(input, scan_start, info.episode_numbers.len())
570    {
571        info.absolute_episode_numbers = abs_range;
572    }
573
574    Some(info)
575}
576
577// ---------------------------------------------------------------------------
578// Specialized parsers
579// ---------------------------------------------------------------------------
580
581fn parse_explicit_dual(
582    caps: &regex::Captures,
583    index: u8,
584    input: &str,
585) -> Option<ParsedEpisodeInfo> {
586    let title = title_from_caps(caps);
587    let season = cap_i32(caps, "season")?;
588    let ep1 = cap_i32(caps, "ep1")?;
589    let ep2 = cap_i32(caps, "ep2");
590
591    let episodes = if let Some(e2) = ep2 {
592        episode_range(ep1, e2)
593    } else {
594        vec![ep1]
595    };
596
597    if episodes.is_empty() {
598        return None;
599    }
600
601    let full_season = if index == 36 {
602        if let (Some(count), Some(&last)) = (cap_i32(caps, "episodecount"), episodes.last()) {
603            last == count
604        } else {
605            false
606        }
607    } else {
608        false
609    };
610
611    let mut info = ParsedEpisodeInfo {
612        series_title: title,
613        season_number: season,
614        episode_numbers: if full_season { vec![] } else { episodes },
615        full_season,
616        ..Default::default()
617    };
618
619    // m53b Failure 5: tail-enrichment when the standard match has multi-episode
620    // semantics and the input continues with a length-matched absolute chain.
621    // Scan from after the LAST captured ep (ep2 if present, else ep1) so the
622    // chain we find is between the standard episode tokens and any trailing
623    // brackets/quality.
624    if info.episode_numbers.len() >= 2
625        && let Some(scan_start) = absolute_scan_start(caps)
626        && let Some(abs_range) =
627            enrich_absolute_from_tail(input, scan_start, info.episode_numbers.len())
628    {
629        info.absolute_episode_numbers = abs_range;
630    }
631
632    Some(info)
633}
634
635fn parse_multi_season(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
636    let title = title_from_caps(caps);
637    let s1 = cap_i32(caps, "season1")?;
638    let s2 = cap_i32(caps, "season2")?;
639
640    if s1 < 1 || s2 <= s1 {
641        return None;
642    }
643
644    Some(ParsedEpisodeInfo {
645        series_title: title,
646        season_number: s1,
647        full_season: true,
648        is_multi_season: true,
649        ..Default::default()
650    })
651}
652
653fn parse_partial_season(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
654    let title = title_from_caps(caps);
655    let season = cap_i32(caps, "season")?;
656    let season_part = cap_i32(caps, "seasonpart").unwrap_or(0);
657
658    Some(ParsedEpisodeInfo {
659        series_title: title,
660        season_number: season,
661        is_partial_season: true,
662        season_part,
663        ..Default::default()
664    })
665}
666
667fn parse_season_only(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
668    let title = title_from_caps(caps);
669    let season = cap_i32(caps, "season")?;
670    let extras = cap_str(caps, "extras");
671
672    Some(ParsedEpisodeInfo {
673        series_title: title,
674        season_number: season,
675        full_season: extras.is_none(),
676        is_season_extra: extras.is_some(),
677        ..Default::default()
678    })
679}
680
681fn parse_mini_series_part(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
682    let title = title_from_caps(caps);
683    let ep = cap_i32(caps, "ep")?;
684
685    Some(ParsedEpisodeInfo {
686        series_title: title,
687        season_number: 1,
688        episode_numbers: vec![ep],
689        ..Default::default()
690    })
691}
692
693fn parse_mini_series_word(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
694    let title = title_from_caps(caps);
695    let word = cap_str(caps, "ep")?;
696    let ep = word_to_number(word)?;
697
698    Some(ParsedEpisodeInfo {
699        series_title: title,
700        season_number: 1,
701        episode_numbers: vec![ep],
702        ..Default::default()
703    })
704}
705
706fn parse_mini_dual(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
707    let title = title_from_caps(caps);
708    let ep1 = cap_i32(caps, "ep1")?;
709    let ep2 = cap_i32(caps, "ep2");
710
711    let episodes = if let Some(e2) = ep2 {
712        episode_range(ep1, e2)
713    } else {
714        vec![ep1]
715    };
716
717    Some(ParsedEpisodeInfo {
718        series_title: title,
719        season_number: 1,
720        episode_numbers: episodes,
721        ..Default::default()
722    })
723}
724
725fn parse_spanish_cap(caps: &regex::Captures, full_match: &str) -> Option<ParsedEpisodeInfo> {
726    let title = title_from_caps(caps);
727    let season = cap_i32(caps, "season")?;
728    let ep = cap_i32(caps, "ep")?;
729
730    static CAP_RANGE_RE: Lazy<Regex> = Lazy::new(|| {
731        Regex::new(r"(?i)Cap[_. ]+(\d{1,2})(\d{2})[_](\d{1,2})(\d{2})").expect("CAP_RANGE")
732    });
733
734    if let Some(range_caps) = CAP_RANGE_RE.captures(full_match) {
735        let s1: i32 = range_caps[1].parse().ok()?;
736        let e1: i32 = range_caps[2].parse().ok()?;
737        let _s2: i32 = range_caps[3].parse().ok()?;
738        let e2: i32 = range_caps[4].parse().ok()?;
739        return Some(ParsedEpisodeInfo {
740            series_title: title,
741            season_number: s1,
742            episode_numbers: episode_range(e1, e2),
743            ..Default::default()
744        });
745    }
746
747    Some(ParsedEpisodeInfo {
748        series_title: title,
749        season_number: season,
750        episode_numbers: vec![ep],
751        ..Default::default()
752    })
753}
754
755static SHORT_FORMAT_SCAN_RE: Lazy<Regex> =
756    Lazy::new(|| Regex::new(r"([1-9])([1-9][0-9]|0[1-9])").expect("SHORT_FORMAT_SCAN"));
757
758fn parse_short_format(caps: &regex::Captures, full_match: &str) -> Option<ParsedEpisodeInfo> {
759    let title = title_from_caps(caps);
760    let title_end = caps.name("title").map(|m| m.end()).unwrap_or(0);
761    let numbers_text = &full_match[title_end..];
762
763    let mut season: Option<i32> = None;
764    let mut episodes = Vec::new();
765
766    for scan_cap in SHORT_FORMAT_SCAN_RE.captures_iter(numbers_text) {
767        let s: i32 = scan_cap[1].parse().ok()?;
768        let e: i32 = scan_cap[2].parse().ok()?;
769        if season.is_none() {
770            season = Some(s);
771        }
772        if season == Some(s) && !episodes.contains(&e) {
773            episodes.push(e);
774        }
775    }
776
777    if episodes.is_empty() {
778        return None;
779    }
780
781    Some(ParsedEpisodeInfo {
782        series_title: title,
783        season_number: season?,
784        episode_numbers: episodes,
785        ..Default::default()
786    })
787}
788
789fn parse_four_digit_short(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
790    let title = title_from_caps(caps);
791    let season = cap_i32(caps, "season")?;
792    let ep = cap_i32(caps, "ep")?;
793
794    Some(ParsedEpisodeInfo {
795        series_title: title,
796        season_number: season,
797        episode_numbers: vec![ep],
798        ..Default::default()
799    })
800}
801
802fn parse_terrible_multi(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
803    let title = title_from_caps(caps);
804    let season = cap_i32(caps, "season").unwrap_or(0);
805    let ep1 = cap_i32(caps, "ep1")?;
806    let ep2 = cap_i32(caps, "ep2")?;
807
808    Some(ParsedEpisodeInfo {
809        series_title: title,
810        season_number: season,
811        episode_numbers: vec![ep1, ep2],
812        ..Default::default()
813    })
814}
815
816// ---------------------------------------------------------------------------
817// Daily episode parsers (m53 Phase 1)
818// ---------------------------------------------------------------------------
819
820/// Parse a daily episode with explicit `airyear`, `airmonth`, `airday` groups.
821///
822/// Sonarr Parser.cs:1196-1201: swaps day and month if month > 12 ("scene fail").
823fn parse_daily(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
824    let title = title_from_caps(caps);
825    let year = cap_i32(caps, "airyear")?;
826    let mut month = cap_i32(caps, "airmonth")?;
827    let mut day = cap_i32(caps, "airday")?;
828
829    // Sonarr: swap day and month if month > 12 (scene fail)
830    if month > 12 {
831        std::mem::swap(&mut month, &mut day);
832    }
833
834    Some(ParsedEpisodeInfo {
835        series_title: title,
836        air_year: year,
837        air_month: month,
838        air_day: day,
839        ..Default::default()
840    })
841}
842
843/// Parse daily with decomposed airdate AND season/episode captures.
844/// Sonarr indices 33 and 34: populate both airdate fields and season/episode.
845fn parse_daily_with_episode(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
846    let title = title_from_caps(caps);
847    let year = cap_i32(caps, "airyear")?;
848    let mut month = cap_i32(caps, "airmonth")?;
849    let mut day = cap_i32(caps, "airday")?;
850    let season = cap_i32(caps, "season").unwrap_or(0);
851    let ep = cap_i32(caps, "ep")
852        .or_else(|| cap_i32(caps, "episode"))
853        .unwrap_or(0);
854
855    if month > 12 {
856        std::mem::swap(&mut month, &mut day);
857    }
858
859    Some(ParsedEpisodeInfo {
860        series_title: title,
861        air_year: year,
862        air_month: month,
863        air_day: day,
864        season_number: season,
865        episode_numbers: if ep > 0 { vec![ep] } else { Vec::new() },
866        ..Default::default()
867    })
868}
869
870/// Parse daily with Part number (index 49).
871fn parse_daily_with_part(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
872    let title = title_from_caps(caps);
873    let year = cap_i32(caps, "airyear")?;
874    let mut month = cap_i32(caps, "airmonth")?;
875    let mut day = cap_i32(caps, "airday")?;
876    let part = cap_i32(caps, "part")?;
877
878    if month > 12 {
879        std::mem::swap(&mut month, &mut day);
880    }
881
882    Some(ParsedEpisodeInfo {
883        series_title: title,
884        air_year: year,
885        air_month: month,
886        air_day: day,
887        daily_part: Some(part),
888        ..Default::default()
889    })
890}
891
892/// Parse ambiguous date (index 78 = US MM.DD.YYYY, index 79 = UK DD.MM.YYYY).
893///
894/// Sonarr disambiguation logic: if BOTH `ambiguousairmonth` and `ambiguousairday`
895/// are <= 12, it's truly ambiguous — cannot determine which is month vs day,
896/// so return None. If one is > 12, swap to make it valid.
897fn parse_ambiguous_date(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
898    let title = title_from_caps(caps);
899    let year = cap_i32(caps, "airyear")?;
900    let raw_month = cap_i32(caps, "ambiguousairmonth")?;
901    let raw_day = cap_i32(caps, "ambiguousairday")?;
902
903    let (month, day) = disambiguate_date(raw_month, raw_day)?;
904
905    Some(ParsedEpisodeInfo {
906        series_title: title,
907        air_year: year,
908        air_month: month,
909        air_day: day,
910        ..Default::default()
911    })
912}
913
914/// Sonarr disambiguation: if both values <= 12, it's ambiguous (None).
915/// If month > 12, swap — it must be the day.
916/// If day > 12, keep as-is — it's definitely a day.
917fn disambiguate_date(raw_month: i32, raw_day: i32) -> Option<(i32, i32)> {
918    if raw_month > 12 {
919        // Month value too large to be a month — must be the day. Swap.
920        Some((raw_day, raw_month))
921    } else if raw_day > 12 {
922        // Day is unambiguously a day, month is unambiguously a month.
923        Some((raw_month, raw_day))
924    } else {
925        // Both <= 12 — truly ambiguous, cannot determine.
926        None
927    }
928}
929
930// ---------------------------------------------------------------------------
931// Absolute/anime episode parser (m53)
932// ---------------------------------------------------------------------------
933
934/// Parse anime pattern with subgroup, season, and episode but no absolute number (index 10).
935fn parse_anime_season_episode(caps: &regex::Captures) -> Option<ParsedEpisodeInfo> {
936    let title = title_from_caps(caps);
937    let season = cap_i32(caps, "season")?;
938    let ep = cap_i32(caps, "episode")?;
939    let release_hash = extract_hash(caps);
940
941    Some(ParsedEpisodeInfo {
942        series_title: title,
943        season_number: season,
944        episode_numbers: vec![ep],
945        release_hash,
946        ..Default::default()
947    })
948}
949
950/// Parse an absolute-episode capture string. Handles decimal episodes:
951/// integer -> returns the integer; decimal (07.5) -> truncates, sets special flag.
952fn parse_absolute_number(s: &str) -> Option<(i32, bool)> {
953    if let Some(dot_pos) = s.find('.') {
954        let int_part = &s[..dot_pos];
955        let n: i32 = int_part.parse().ok()?;
956        Some((n, true))
957    } else {
958        let n: i32 = s.parse().ok()?;
959        Some((n, false))
960    }
961}
962
963/// Extract release hash from `<hash>` capture. Strips brackets/parens.
964fn extract_hash(caps: &regex::Captures) -> Option<String> {
965    let raw = cap_str(caps, "hash")?;
966    let trimmed = raw
967        .trim_start_matches(['[', '('])
968        .trim_end_matches([']', ')']);
969    if trimmed.is_empty() {
970        None
971    } else {
972        Some(trimmed.to_string())
973    }
974}
975
976/// Look backward from the `absoluteepisode` capture position in the original
977/// input to find the FIRST number in a dash-separated range chain.
978///
979/// Scans recursively backward through patterns like `NNN-NNN-NNN-` to find
980/// the leftmost number, which is the range start. The captured number is
981/// the range end.
982///
983/// Returns `Some(first)` if a valid range start was found, `None` otherwise.
984fn find_range_start(caps: &regex::Captures, abs_start: usize, abs_ep: i32) -> Option<i32> {
985    let full_match = caps.get(0)?;
986    let title_end = caps.name("title").map(|m| m.end()).unwrap_or(0);
987
988    if abs_start <= title_end {
989        return None;
990    }
991
992    let between = full_match.as_str().get(
993        title_end.saturating_sub(full_match.start())..abs_start.saturating_sub(full_match.start()),
994    )?;
995
996    // Look for a dash-separated number chain ending at the capture position.
997    // The chain must be contiguous: only dash/underscore (or space-dash-space)
998    // separators between numbers (no words, parens, or other text). This
999    // prevents false matches from "(Season 2) - 33" being treated as a range
1000    // start.
1001    //
1002    // Walk backward from the end of `between` to find the start of the chain.
1003    static TRAILING_CHAIN_RE: Lazy<Regex> =
1004        Lazy::new(|| Regex::new(r"(?:(\d{1,4})(?:[-_]| - ))+$").expect("TRAILING_CHAIN_RE"));
1005
1006    // The text must end with "NNN-" (number + dash) to form a range.
1007    // Extract the FIRST number in the contiguous dash chain.
1008    let m = TRAILING_CHAIN_RE.find(between)?;
1009    let chain = &between[m.start()..];
1010
1011    // Extract all numbers from the chain
1012    static CHAIN_NUM_RE: Lazy<Regex> =
1013        Lazy::new(|| Regex::new(r"(\d{1,4})").expect("CHAIN_NUM_RE"));
1014
1015    let mut first: Option<i32> = None;
1016    for cap in CHAIN_NUM_RE.captures_iter(chain) {
1017        if let Ok(n) = cap[1].parse::<i32>()
1018            && n < abs_ep
1019            && first.is_none()
1020        {
1021            first = Some(n);
1022        }
1023    }
1024
1025    first
1026}
1027
1028/// Detect a Sonarr-style batch range (`0NN - NNN`) where the first number
1029/// was absorbed into the title's lazy capture (e.g. REGEX_18's `[^-]+?`
1030/// consuming `01` in `Some Anime Show 01 - 119`).
1031///
1032/// Returns `Some((first, trim_at))` where `first` is the leading episode
1033/// number and `trim_at` is the byte offset (in the original input) where
1034/// the title should be truncated.
1035///
1036/// Returns `None` when:
1037/// - The captured title doesn't end with ` 0N` (a leading-zero 2+ digit
1038///   number after whitespace — the leading zero distinguishes an episode
1039///   token from a sequel/season number like `Series Title 21`).
1040/// - The gap between title.end and abs.start isn't exactly ` - `.
1041///
1042/// The leading-zero requirement matches Sonarr's
1043/// `(?<!\b[0]\d+) - ` lookbehind family — without a leading zero on the
1044/// first number, the engine refuses to treat it as a range start.
1045fn find_batch_range_in_title(
1046    caps: &regex::Captures,
1047    abs_start: usize,
1048    abs_ep: i32,
1049    input: &str,
1050) -> Option<(i32, usize)> {
1051    let title_match = caps.name("title")?;
1052    let title_start = title_match.start();
1053    let title_end = title_match.end();
1054
1055    // Gap between title.end and abs.start must be exactly ` - ` (the
1056    // space-dash-space separator marks an explicit batch shape — Sonarr's
1057    // repeated `(?:[-_. ]?(?P<absoluteepisode>\d{2,3}))+` capturing both
1058    // numbers via `.First()`/`.Last()`).
1059    let gap = input.get(title_end..abs_start)?;
1060    if gap != " - " {
1061        return None;
1062    }
1063
1064    // Title must end with whitespace + leading-zero number (`0N`, `0NN`,
1065    // `0NNN`). The leading zero is what signals "episode token", not
1066    // "sequel number" — `Series Title 21 - 101` is title=`Series Title 21`
1067    // ep=101, not range [21..101].
1068    static TITLE_TAIL_RE: Lazy<Regex> =
1069        Lazy::new(|| Regex::new(r"(?:^|\s)(0\d{1,3})\s*$").expect("TITLE_TAIL_RE"));
1070    let title_text = input.get(title_start..title_end)?;
1071    let m = TITLE_TAIL_RE.captures(title_text)?;
1072    let num = m.get(1)?;
1073    let first: i32 = num.as_str().parse().ok()?;
1074    if first >= abs_ep || first <= 0 {
1075        return None;
1076    }
1077    // Position where the chain head digit begins (relative to input).
1078    let trim_at = title_start + num.start();
1079    Some((first, trim_at))
1080}
1081
1082/// Look forward from the `absoluteepisode` capture end position in the
1083/// original input for a dash-separated following number that forms a range
1084/// end. Scans for patterns like `-NNN` or ` - NNN` immediately after.
1085fn find_range_end(input: &str, abs_end: usize, _abs_ep: i32) -> Option<i32> {
1086    if abs_end >= input.len() {
1087        return None;
1088    }
1089
1090    let after = &input[abs_end..];
1091
1092    // Match: optional whitespace, dash, optional whitespace, digits
1093    // Also match multiple dash-separated numbers and take the last
1094    static RANGE_END_RE: Lazy<Regex> =
1095        Lazy::new(|| Regex::new(r"^(?:[-_. ]+(\d{1,4}))+").expect("RANGE_END_RE"));
1096
1097    let m = RANGE_END_RE.captures(after)?;
1098    // The `+` repetition keeps the LAST capture
1099    let n: i32 = m[1].parse().ok()?;
1100    Some(n)
1101}
1102
1103/// When a broad absolute regex captured `title=X` and `absoluteepisode=N`,
1104/// check the original `input` for a `<year>` token between the title's end
1105/// and the absolute's start. Returns `<title> <year>` if exactly one
1106/// 4-digit year separates them; otherwise returns the title unchanged.
1107fn reattach_trailing_year(
1108    caps: &regex::Captures,
1109    title: String,
1110    abs_ep: i32,
1111    input: &str,
1112) -> String {
1113    if !(1..=999).contains(&abs_ep) {
1114        return title;
1115    }
1116    let title_end = match caps.name("title") {
1117        Some(m) => m.end(),
1118        None => return title,
1119    };
1120    let abs_start = match caps.name("absoluteepisode") {
1121        Some(m) => m.start(),
1122        None => return title,
1123    };
1124    let between = match input.get(title_end..abs_start) {
1125        Some(s) => s,
1126        None => return title,
1127    };
1128    static YEAR_BETWEEN_RE: Lazy<Regex> = Lazy::new(|| {
1129        Regex::new(r"^[ ._-](?P<year>(?:19|20)\d{2})[ ._-]$").expect("YEAR_BETWEEN_RE")
1130    });
1131    match YEAR_BETWEEN_RE.captures(between) {
1132        Some(c) => format!("{title} {}", &c["year"]),
1133        None => title,
1134    }
1135}
1136
1137/// Compute the byte offset to start scanning for a trailing absolute
1138/// chain after a standard match. Prefers the LAST captured ep position
1139/// (`ep2` if present, else `ep1`, else `ep`) so the scan window starts
1140/// after the standard episode tokens. Returns None when no episode
1141/// capture is present (callers should not invoke the post-handler).
1142fn absolute_scan_start(caps: &regex::Captures) -> Option<usize> {
1143    if let Some(m) = caps.name("ep2") {
1144        return Some(m.end());
1145    }
1146    if let Some(m) = caps.name("ep1") {
1147        return Some(m.end());
1148    }
1149    if let Some(m) = caps.name("ep") {
1150        return Some(m.end());
1151    }
1152    None
1153}
1154
1155/// Post-handler: when a standard regex matched and produced
1156/// `episode_numbers` of length N, scan the input starting from
1157/// `scan_start` (typically the position immediately after the standard
1158/// match's last `ep`-style capture, or after the chain it consumed) for
1159/// an absolute-range chain. Accept the chain ONLY when its length
1160/// equals N (the length-match precondition).
1161///
1162/// Sonarr-equivalent: composite indices capture both standard and
1163/// absolute groups simultaneously via repeated named groups in the SAME
1164/// regex. Where the standard match succeeded against a non-composite
1165/// regex but the input has a parallel absolute chain, we recover it
1166/// post-match. The length-match precondition emulates the C# .NET
1167/// regex behaviour where `(?<absoluteepisode>...)+` and
1168/// `(?<episode>...)+` repetitions in the same composite regex always
1169/// yield equal-length capture collections by construction.
1170///
1171/// Accepts both parenthesized `(NNN-NNN[-NNN]*)` and dash-prefixed
1172/// ` - NNN-NNN[-NNN]*` tail shapes — fixtures use both forms.
1173///
1174/// Note: regex 25 (`S01E01.+?\[.+?\]`) absorbs through `[RlsGrp]`, so
1175/// the post-match tail is empty. We instead scan from after the `ep`
1176/// capture (its trailing chain consumption is bounded by the lazy `.+?`
1177/// before `\[`), which is guaranteed to lie inside the full match for
1178/// every dispatched index. The first `(NNN-NNN)` or ` - NNN-NNN` chain
1179/// encountered in that span is the absolute range; the regex's
1180/// non-greedy `.+?` ensures at most one such chain appears between the
1181/// `ep` capture and the closing `\[...]`.
1182fn enrich_absolute_from_tail(
1183    input: &str,
1184    scan_start: usize,
1185    expected_len: usize,
1186) -> Option<Vec<i32>> {
1187    if expected_len < 2 || scan_start >= input.len() {
1188        return None;
1189    }
1190    let tail = &input[scan_start..];
1191
1192    // Find the FIRST occurrence of a parenthesized or dash-prefixed
1193    // chain anywhere in the scan window. The lazy `.+?` in the calling
1194    // regex bounds where this chain can land relative to the literal
1195    // bracket terminator, so we don't need to worry about picking up a
1196    // chain after `[RlsGrp]`.
1197    // Paren form: `(NNN-NNN[-NNN]*)` — closing `)` is its own boundary.
1198    // Dash form:  ` - NNN-NNN[-NNN]*` — explicit `\b` after the chain
1199    // prevents picking up partial chains embedded in longer numeric runs.
1200    static TAIL_RANGE_RE: Lazy<Regex> = Lazy::new(|| {
1201        Regex::new(
1202            r"(?:\((?P<paren>\d{1,4}(?:-\d{1,4})+)\)|\s-\s*(?P<dash>\d{1,4}(?:-\d{1,4})+)\b)",
1203        )
1204        .expect("TAIL_RANGE_RE")
1205    });
1206    let m = TAIL_RANGE_RE.captures(tail)?;
1207    let chain = m.name("paren").or_else(|| m.name("dash"))?.as_str();
1208
1209    let nums: Vec<i32> = chain.split('-').filter_map(|s| s.parse().ok()).collect();
1210
1211    // Length-match precondition: chain must align with the standard match's
1212    // episode_numbers count. Rejects stray double-pairs in single-episode tails.
1213    if nums.len() != expected_len {
1214        return None;
1215    }
1216
1217    let lo = *nums.first()?;
1218    let hi = *nums.last()?;
1219    if lo <= 0 || hi <= lo || (hi - lo) >= 100 {
1220        return None;
1221    }
1222    Some(episode_range(lo, hi))
1223}
1224
1225/// Parse absolute (anime) episode patterns.
1226fn parse_absolute(caps: &regex::Captures, index: u8, input: &str) -> Option<ParsedEpisodeInfo> {
1227    let mut title = title_from_caps(caps);
1228
1229    let abs_str = cap_str(caps, "absoluteepisode")?;
1230    let (abs_ep, mut is_special) = parse_absolute_number(abs_str)?;
1231
1232    // Reject episode 0 -- not a valid absolute episode
1233    if abs_ep <= 0 && !matches!(index, 45 | 46) {
1234        return None;
1235    }
1236
1237    if cap_str(caps, "special").is_some() {
1238        is_special = true;
1239    }
1240
1241    // m53b Failure 1: when the matched broad absolute regex (87..=94) leaves
1242    // a 4-digit year between the captured title and the captured absolute,
1243    // re-attach the year as part of the title. C# Sonarr's lazy/greedy
1244    // interplay handles this implicitly via .Captures iteration; we patch it
1245    // post-match.
1246    title = if matches!(index, 87..=94) {
1247        reattach_trailing_year(caps, title, abs_ep, input)
1248    } else {
1249        title
1250    };
1251
1252    let mut absolute_episodes = vec![abs_ep];
1253
1254    // Handle dual absoluteepisode captures (batch ranges)
1255    if let Some(abs2_str) = cap_str(caps, "absoluteepisode2")
1256        && let Some((abs_ep2, special2)) = parse_absolute_number(abs2_str)
1257    {
1258        if special2 {
1259            is_special = true;
1260        }
1261        if abs_ep2 > abs_ep && (abs_ep2 - abs_ep) < 100 {
1262            absolute_episodes = episode_range(abs_ep, abs_ep2);
1263
1264            // Bidirectional chain extension: even with a dual capture, the
1265            // chain may extend further in either direction. REGEX_90's
1266            // `(?P<absoluteepisode>\d{1,2})-(?P<absoluteepisode2>\d{1,2})`
1267            // anchors on only the first two digits of `01-02-03`; the
1268            // trailing `03` is consumed by `.*?` and lost. Run forward and
1269            // backward scanners to recover the full chain.
1270            let abs1_match = caps.name("absoluteepisode");
1271            let abs2_match = caps.name("absoluteepisode2");
1272            let backward = abs1_match
1273                .and_then(|m| find_range_start(caps, m.start(), abs_ep))
1274                .filter(|&n| n < abs_ep);
1275            let forward = abs2_match
1276                .and_then(|m| find_range_end(input, m.end(), abs_ep2))
1277                .filter(|&n| n > abs_ep2);
1278            let lo = backward.unwrap_or(abs_ep);
1279            let hi = forward.unwrap_or(abs_ep2);
1280            if lo < hi && (hi - lo) < 100 {
1281                absolute_episodes = episode_range(lo, hi);
1282            }
1283        } else if abs_ep2 != abs_ep {
1284            absolute_episodes.push(abs_ep2);
1285        }
1286    } else if absolute_episodes.len() == 1 {
1287        // No explicit absoluteepisode2: check the original input text for a
1288        // range pattern (NNN-NNN or NNN - NNN) around the captured absolute
1289        // episode number. This handles the C# repeating capture semantics
1290        // where Sonarr takes .First() and .Last() from all repetitions.
1291        if let Some(abs_match) = caps.name("absoluteepisode") {
1292            // Run BOTH directional scanners and take the union. The captured
1293            // absolute may be an interior element of a chain (`01-02-03`
1294            // captures `02` as the regex repetition's last successful
1295            // iteration); only running one direction would miss the other
1296            // half of the chain.
1297            let backward = find_range_start(caps, abs_match.start(), abs_ep);
1298            let forward = find_range_end(input, abs_match.end(), abs_ep);
1299            let backward_ok = backward.filter(|&n| n < abs_ep);
1300            let forward_ok = forward.filter(|&n| n > abs_ep);
1301
1302            match (backward_ok, forward_ok) {
1303                (Some(first), Some(last)) => {
1304                    // Both directions yielded a chain — union into [first..=last].
1305                    if (last - first) < 100 {
1306                        absolute_episodes = episode_range(first, last);
1307                    }
1308                }
1309                (Some(first), None) => {
1310                    // Backward only — chain `NN-abs` (e.g. `01-02`).
1311                    if (abs_ep - first) < 100 {
1312                        absolute_episodes = episode_range(first, abs_ep);
1313                    }
1314                }
1315                (None, Some(last)) => {
1316                    // Forward only — chain `abs-NN` (e.g. `01-02`).
1317                    if (last - abs_ep) < 100 {
1318                        absolute_episodes = episode_range(abs_ep, last);
1319                    }
1320                }
1321                (None, None) => {
1322                    // Neither directional scan caught a chain — fall back to
1323                    // Task 3's title-absorbed batch detection. Did the title's
1324                    // lazy capture absorb the chain head digit (REGEX_18
1325                    // `[^-]+?` etc.)? Handles batch shapes like
1326                    // `Title NNN - NNN` where the gap is exactly ` - ` and
1327                    // the leading number was pulled into title.
1328                    if let Some((first, trim_at)) =
1329                        find_batch_range_in_title(caps, abs_match.start(), abs_ep, input)
1330                    {
1331                        absolute_episodes = episode_range(first, abs_ep);
1332                        let title_start = caps.name("title").map(|m| m.start()).unwrap_or(0);
1333                        title = normalize::clean_series_title(&input[title_start..trim_at]);
1334                    }
1335                }
1336            }
1337        }
1338    }
1339
1340    // Composite patterns carry season+episode alongside absolute
1341    let season_number = match index {
1342        7 | 8 | 11..=15 | 26 => cap_i32(caps, "season").unwrap_or(0),
1343        _ => 0,
1344    };
1345    let episode_numbers = match index {
1346        7 | 8 | 11..=13 | 26 => {
1347            if let Some(ep) = cap_i32(caps, "episode") {
1348                vec![ep]
1349            } else {
1350                Vec::new()
1351            }
1352        }
1353        _ => Vec::new(),
1354    };
1355
1356    let release_hash = extract_hash(caps);
1357
1358    Some(ParsedEpisodeInfo {
1359        series_title: title,
1360        season_number,
1361        episode_numbers,
1362        absolute_episode_numbers: absolute_episodes,
1363        special: is_special,
1364        release_hash,
1365        ..Default::default()
1366    })
1367}
1368
1369#[cfg(test)]
1370mod tests {
1371    use super::*;
1372
1373    #[test]
1374    fn rejects_absolute_capture_followed_by_hyphen_lowercase_word() {
1375        // Sonarr Parser.cs:138 — `(?!-[a-z]+)` lookahead drops "300-nen" so the
1376        // cascade falls through to a later regex that captures "02".
1377        let input =
1378            "[Chihiro] Anime Title 300-nen, With Even More Title 02 [720p Hi10P AAC][031FA533]";
1379        let info = parse_title(input).expect("must match a regex");
1380        assert_eq!(
1381            info.series_title,
1382            "Anime Title 300-nen, With Even More Title"
1383        );
1384        assert_eq!(info.absolute_episode_numbers, vec![2]);
1385    }
1386
1387    #[test]
1388    fn rejects_absolute_when_later_dash_episode_follows_words() {
1389        // Sonarr captures both "100" and "01" via repeated (?<absoluteepisode>...)
1390        // and .Captures.Last() returns "01". Rust regex returns only the last
1391        // *positional* capture — which is "100" because the engine can't span
1392        // the words. Reject 100 to let cascade fall through.
1393        let input = "[SubsPlease] Series Title - 100 Years Quest - 01 (1080p) [1107F3A9].mkv";
1394        let info = parse_title(input).expect("must match a regex");
1395        assert_eq!(info.series_title, "Series Title - 100 Years Quest");
1396        assert_eq!(info.absolute_episode_numbers, vec![1]);
1397    }
1398
1399    #[test]
1400    fn detects_space_dash_space_range_start() {
1401        // Sonarr's repeated (?<absoluteepisode>...) captures both 01 and 119;
1402        // .First()/.Last() yield the range. Our find_range_start must accept
1403        // " - " between the chained numbers, not only "-" or "_".
1404        let input = "[HorribleSubs] Some Anime Show 01 - 119 [1080p] [Batch]";
1405        let info = parse_title(input).expect("must match a regex");
1406        assert_eq!(info.series_title, "Some Anime Show");
1407        assert_eq!(info.absolute_episode_numbers.first().copied(), Some(1));
1408        assert_eq!(info.absolute_episode_numbers.last().copied(), Some(119));
1409    }
1410
1411    #[test]
1412    fn extracts_triple_dash_range_to_third_element() {
1413        // Sonarr captures all three via repeated (?<absoluteepisode>...) and
1414        // .First()/.Last() yield 1..=3. Our scanner must continue forward past
1415        // the captured absolute to pick up trailing chain elements when the
1416        // regex stopped before the final digit.
1417        let input = "Series Title (2010) - 01-02-03 - Episode Title (1) HDTV-720p";
1418        let info = parse_title(input).expect("must match a regex");
1419        assert_eq!(info.series_title, "Series Title (2010)");
1420        assert_eq!(info.absolute_episode_numbers, vec![1, 2, 3]);
1421    }
1422
1423    #[test]
1424    fn carries_trailing_year_into_title_for_broad_absolute() {
1425        // 'Series Title 2018' is the canonical title (a year-branded show); '06'
1426        // is the absolute episode. Broad absolute regexes (87..=94) lazily stop
1427        // title at "Series Title", capturing 2018 as a discarded preliminary
1428        // absolute and 06 as the final. Re-attach 2018 to the title when the
1429        // pattern is "title \d{4} \d{1,3}".
1430        let input = "Series Title 2018 06 720p x265 AOZ.mp4";
1431        let info = parse_title(input).expect("must match a regex");
1432        assert_eq!(info.series_title, "Series Title 2018");
1433        assert_eq!(info.absolute_episode_numbers, vec![6]);
1434    }
1435
1436    #[test]
1437    fn enriches_standard_match_with_trailing_absolute_range() {
1438        // m53b Failure 5: composite S+E+absolute. The standard regex matches
1439        // season=1, episodes=[1,2]; the trailing parenthesized "(001-002)" is
1440        // the absolute range. C# Sonarr captures both via repeated named
1441        // groups in the same composite regex; we recover it post-match by
1442        // scanning the input after the standard match's end for an
1443        // absolute-range chain whose length matches episode_numbers.len().
1444        let input =
1445            "Series Title (2010) - S01E01-02 (001-002) - Episode Title (1) HDTV-720p v2 [RlsGrp]";
1446        let info = parse_title(input).expect("must match a regex");
1447        assert_eq!(info.series_title, "Series Title (2010)");
1448        assert_eq!(info.season_number, 1);
1449        assert_eq!(info.episode_numbers, vec![1, 2]);
1450        assert_eq!(info.absolute_episode_numbers.first().copied(), Some(1));
1451        assert_eq!(info.absolute_episode_numbers.last().copied(), Some(2));
1452    }
1453}