1use once_cell::sync::Lazy;
5use regex::Regex;
6
7use super::ParsedEpisodeInfo;
8use super::regexes;
9use crate::normalize;
10
11pub 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 info.release_group = crate::release_group::parse_release_group(title);
21
22 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
40fn 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
53fn 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
69fn 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 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 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 let original = title;
149 let title = title_for_caps;
152
153 #[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 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 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 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 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 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 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 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 if index == 10
260 && let Some(ep_m) = caps.name("episode")
261 {
262 let after_ep = &title[ep_m.end()..];
263 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 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 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 0 => parse_daily(&caps),
309 1 => parse_daily(&caps),
310
311 10 => parse_anime_season_episode(&caps),
313
314 7 | 8 | 11..=21 | 26 | 28..=32 | 45 | 46 | 65..=67 | 77 | 81 | 87..=94 => {
316 parse_absolute(&caps, index, title)
317 }
318
319 33 | 34 => parse_daily_with_episode(&caps),
321
322 35 => parse_daily(&caps),
324
325 43 => parse_multi_season(&caps),
327 44 => parse_partial_season(&caps),
328 69..=71 | 96 => parse_season_only(&caps),
329
330 49 => parse_daily_with_part(&caps),
332
333 51 => parse_mini_series_word(&caps),
335
336 47 | 50 | 52 => parse_mini_series_part(&caps),
338
339 48 => parse_mini_dual(&caps),
341
342 68 => parse_generic(&caps, full_match, index, original),
345
346 72 => parse_spanish_cap(&caps, full_match),
348
349 73 => parse_short_format(&caps, full_match),
351
352 76 => parse_daily(&caps),
354
355 78 | 79 => parse_ambiguous_date(&caps),
357
358 80 => parse_daily(&caps),
360
361 82 => parse_four_digit_short(&caps),
363
364 95 => parse_terrible_multi(&caps),
366
367 27 | 36..=38 | 64 | 83 => parse_explicit_dual(&caps, index, original),
369
370 _ => parse_generic(&caps, full_match, index, original),
372 }?;
373
374 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
390fn cap_i32(caps: ®ex::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: ®ex::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
428static 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
433static 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 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
483static 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
498fn parse_generic(
503 caps: ®ex::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 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
577fn parse_explicit_dual(
582 caps: ®ex::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 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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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
816fn parse_daily(caps: ®ex::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 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
843fn parse_daily_with_episode(caps: ®ex::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
870fn parse_daily_with_part(caps: ®ex::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
892fn parse_ambiguous_date(caps: ®ex::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
914fn disambiguate_date(raw_month: i32, raw_day: i32) -> Option<(i32, i32)> {
918 if raw_month > 12 {
919 Some((raw_day, raw_month))
921 } else if raw_day > 12 {
922 Some((raw_month, raw_day))
924 } else {
925 None
927 }
928}
929
930fn parse_anime_season_episode(caps: ®ex::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
950fn 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
963fn extract_hash(caps: ®ex::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
976fn find_range_start(caps: ®ex::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 static TRAILING_CHAIN_RE: Lazy<Regex> =
1004 Lazy::new(|| Regex::new(r"(?:(\d{1,4})(?:[-_]| - ))+$").expect("TRAILING_CHAIN_RE"));
1005
1006 let m = TRAILING_CHAIN_RE.find(between)?;
1009 let chain = &between[m.start()..];
1010
1011 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
1028fn find_batch_range_in_title(
1046 caps: ®ex::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 let gap = input.get(title_end..abs_start)?;
1060 if gap != " - " {
1061 return None;
1062 }
1063
1064 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 let trim_at = title_start + num.start();
1079 Some((first, trim_at))
1080}
1081
1082fn 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 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 let n: i32 = m[1].parse().ok()?;
1100 Some(n)
1101}
1102
1103fn reattach_trailing_year(
1108 caps: ®ex::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
1137fn absolute_scan_start(caps: ®ex::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
1155fn 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 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 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
1225fn parse_absolute(caps: ®ex::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 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 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 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 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 if let Some(abs_match) = caps.name("absoluteepisode") {
1292 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 if (last - first) < 100 {
1306 absolute_episodes = episode_range(first, last);
1307 }
1308 }
1309 (Some(first), None) => {
1310 if (abs_ep - first) < 100 {
1312 absolute_episodes = episode_range(first, abs_ep);
1313 }
1314 }
1315 (None, Some(last)) => {
1316 if (last - abs_ep) < 100 {
1318 absolute_episodes = episode_range(abs_ep, last);
1319 }
1320 }
1321 (None, None) => {
1322 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 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 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 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 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 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 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 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}