use once_cell::sync::Lazy;
use regex::Regex;
use super::ParsedEpisodeInfo;
use super::regexes;
use crate::normalize;
pub fn parse_title(title: &str) -> Option<ParsedEpisodeInfo> {
let simple_title = normalize::preprocess_title(title)?;
for entry in regexes::REPORT_TITLE_REGEXES.iter() {
if let Some(mut info) = try_regex(entry.index, entry.regex, &simple_title) {
info.release_group = crate::release_group::parse_release_group(title);
if let Some(caps) = entry.regex.captures(&simple_title)
&& let Some(sg) = caps.name("subgroup")
{
let sg_val = sg.as_str().trim();
if !sg_val.is_empty() {
info.release_group = Some(sg_val.to_string());
}
}
return Some(info);
}
}
None
}
fn followed_by_hyphen_lowercase_word(title: &str, end: usize) -> bool {
let after = title.as_bytes().get(end..).unwrap_or(&[]);
matches!(after, [b'-', rest @ ..] if rest.first().is_some_and(|c| c.is_ascii_lowercase()))
}
fn followed_by_word_chain_then_dash_episode(title: &str, end: usize) -> bool {
static TAIL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^\s+[A-Za-z][A-Za-z'\-]*(?:\s+[A-Za-z][A-Za-z'\-]*)*\s*-\s*\d+")
.expect("TAIL_RE")
});
let after = title.get(end..).unwrap_or("");
TAIL_RE.is_match(after)
}
fn mask_digit_ranges(input: &str, ranges: &[(usize, usize)]) -> String {
let mut out = String::with_capacity(input.len());
let mut last = 0;
for &(s, e) in ranges {
out.push_str(&input[last..s]);
out.extend(std::iter::repeat_n('X', e - s));
last = e;
}
out.push_str(&input[last..]);
out
}
fn try_regex(index: u8, regex: &Regex, title: &str) -> Option<ParsedEpisodeInfo> {
let absolute_index = matches!(
index,
7 | 8 | 11..=21 | 26 | 28..=32 | 65..=67 | 77 | 81 | 87..=94
);
let working: String;
let title_for_caps: &str = if absolute_index {
const MAX_RETRIES: usize = 8;
let mut current = std::borrow::Cow::Borrowed(title);
let mut iters = 0;
loop {
let caps = regex.captures(current.as_ref())?;
let bad_ranges: Vec<(usize, usize)> = ["absoluteepisode", "absoluteepisode2"]
.iter()
.filter_map(|name| caps.name(name))
.filter(|m| {
followed_by_hyphen_lowercase_word(current.as_ref(), m.end())
|| followed_by_word_chain_then_dash_episode(current.as_ref(), m.end())
})
.map(|m| (m.start(), m.end()))
.collect();
if bad_ranges.is_empty() {
break;
}
iters += 1;
if iters > MAX_RETRIES {
return None;
}
current = std::borrow::Cow::Owned(mask_digit_ranges(current.as_ref(), &bad_ranges));
}
match current {
std::borrow::Cow::Borrowed(s) => s,
std::borrow::Cow::Owned(s) => {
working = s;
working.as_str()
}
}
} else {
title
};
let caps = regex.captures(title_for_caps)?;
let full_match = caps.get(0)?.as_str();
let original = title;
let title = title_for_caps;
#[cfg(debug_assertions)]
if std::env::var("AVATARR_DEBUG_REGEX_INDEX").is_ok() {
eprintln!("regex matched: index={index} title={title:?}");
}
if matches!(index, 0 | 1)
&& let (Some(s1), Some(s2)) = (caps.name("sep1"), caps.name("sep2"))
&& s1.as_str() != s2.as_str()
{
return None;
}
if index == 1
&& let Some(m) = caps.name("airday")
&& m.end() < title.len()
&& title.as_bytes()[m.end()].is_ascii_digit()
{
return None;
}
if !matches!(
index,
0 | 1 | 7 | 8 | 10..=21 | 26 | 28..=32 | 34 | 35
| 45 | 46 | 49 | 65..=68 | 72 | 73 | 76..=82 | 87..=95
) {
for name in ["ep", "ep1", "ep2", "season", "seasonpart"] {
if let Some(m) = caps.name(name)
&& !normalize::digit_boundary_ok(title, m.start(), m.end())
{
return None;
}
}
}
if index == 43 {
for name in ["season1", "season2"] {
if let Some(m) = caps.name(name)
&& !normalize::digit_boundary_ok(title, m.start(), m.end())
{
return None;
}
}
}
if matches!(index, 70 | 71)
&& let Some(m) = caps.name("season")
{
let after = &title[m.end()..];
let skip_sep = after
.strip_prefix(|c: char| "-_. ".contains(c))
.unwrap_or(after);
if skip_sep.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
}
if index == 82
&& let Some(m) = caps.name("ep")
{
static EP82_REJECT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(?:[pi]|\d+|[)\]]|\W\d+|\W(?:ep|e|x)\d+)").expect("EP82_REJECT_RE")
});
let after = &title[m.end()..];
if EP82_REJECT_RE.is_match(after) {
return None;
}
}
if index == 75
&& let Some(m) = caps.name("season")
{
let before = &title[..m.start()];
if let Some(prefix) = before.strip_suffix('-')
&& prefix.ends_with(|c: char| c.is_ascii_digit())
{
return None;
}
}
if index == 10
&& let Some(ep_m) = caps.name("episode")
{
let after_ep = &title[ep_m.end()..];
if let Some(rest) = after_ep.strip_prefix(|c: char| "-_. ".contains(c))
&& rest.starts_with(|c: char| c.is_ascii_digit())
{
return None;
}
}
if matches!(
index,
7 | 8 | 11..=21 | 26 | 28..=32 | 65..=67 | 77 | 81 | 87..=94
) {
for name in ["absoluteepisode", "absoluteepisode2"] {
if let Some(m) = caps.name(name)
&& !normalize::digit_boundary_ok(title, m.start(), m.end())
{
return None;
}
}
}
if matches!(
index,
7 | 8 | 11..=21 | 26 | 28..=32 | 45 | 46 | 65..=67 | 77 | 81 | 87..=94
) && let Some(m) = caps.name("absoluteepisode")
&& m.end() < title.len()
&& title.as_bytes()[m.end()] == b':'
{
return None;
}
let mut result = match index {
0 => parse_daily(&caps),
1 => parse_daily(&caps),
10 => parse_anime_season_episode(&caps),
7 | 8 | 11..=21 | 26 | 28..=32 | 45 | 46 | 65..=67 | 77 | 81 | 87..=94 => {
parse_absolute(&caps, index, title)
}
33 | 34 => parse_daily_with_episode(&caps),
35 => parse_daily(&caps),
43 => parse_multi_season(&caps),
44 => parse_partial_season(&caps),
69..=71 | 96 => parse_season_only(&caps),
49 => parse_daily_with_part(&caps),
51 => parse_mini_series_word(&caps),
47 | 50 | 52 => parse_mini_series_part(&caps),
48 => parse_mini_dual(&caps),
68 => parse_generic(&caps, full_match, index, original),
72 => parse_spanish_cap(&caps, full_match),
73 => parse_short_format(&caps, full_match),
76 => parse_daily(&caps),
78 | 79 => parse_ambiguous_date(&caps),
80 => parse_daily(&caps),
82 => parse_four_digit_short(&caps),
95 => parse_terrible_multi(&caps),
27 | 36..=38 | 64 | 83 => parse_explicit_dual(&caps, index, original),
_ => parse_generic(&caps, full_match, index, original),
}?;
if title.as_ptr() != original.as_ptr()
&& let Some(m) = caps.name("title")
{
result.series_title = normalize::clean_series_title(&original[m.start()..m.end()]);
}
Some(result)
}
fn cap_i32(caps: ®ex::Captures, name: &str) -> Option<i32> {
caps.name(name)?.as_str().parse::<i32>().ok()
}
fn cap_str<'a>(caps: &'a regex::Captures, name: &str) -> Option<&'a str> {
Some(caps.name(name)?.as_str())
}
fn title_from_caps(caps: ®ex::Captures) -> String {
normalize::clean_series_title(cap_str(caps, "title").unwrap_or(""))
}
fn word_to_number(word: &str) -> Option<i32> {
match word.to_ascii_lowercase().as_str() {
"one" => Some(1),
"two" => Some(2),
"three" => Some(3),
"four" => Some(4),
"five" => Some(5),
"six" => Some(6),
"seven" => Some(7),
"eight" => Some(8),
"nine" => Some(9),
_ => None,
}
}
fn episode_range(first: i32, last: i32) -> Vec<i32> {
if first > last {
return Vec::new();
}
(first..=last).collect()
}
static EPISODE_SCAN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:Episode\s+|[Ee][Pp]?|[Xx])(\d{1,5})").expect("EPISODE_SCAN_RE")
});
static DASH_CONTINUATION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"[-_](\d{1,5})").expect("DASH_CONTINUATION_RE"));
fn extract_episodes_from_match(matched: &str) -> Vec<i32> {
let mut eps: Vec<i32> = Vec::new();
let mut last_ep_end: usize = 0;
for cap in EPISODE_SCAN_RE.captures_iter(matched) {
if let Ok(n) = cap[1].parse::<i32>()
&& !eps.contains(&n)
{
eps.push(n);
}
if let Some(m) = cap.get(0) {
last_ep_end = m.end();
}
}
if !eps.is_empty() {
let mut pos = last_ep_end;
while pos < matched.len() {
let remaining = &matched[pos..];
match DASH_CONTINUATION_RE.find(remaining) {
Some(dm) if dm.start() == 0 => {
if let Some(dcap) = DASH_CONTINUATION_RE.captures(remaining)
&& let Ok(n) = dcap[1].parse::<i32>()
&& !eps.contains(&n)
{
eps.push(n);
}
pos += dm.end();
}
_ => break,
}
}
}
if eps.len() >= 2 {
let first = eps[0];
let last = *eps.last().unwrap();
if last > first && (last - first + 1) as usize > eps.len() && (last - first) < 100 {
eps = episode_range(first, last);
}
}
eps
}
static SEASON_SCAN_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)(?:S(\d{1,4})|(\d{1,4})x)").expect("SEASON_SCAN_RE"));
fn extract_season_from_match(matched: &str) -> Option<i32> {
let cap = SEASON_SCAN_RE.captures(matched)?;
if let Some(m) = cap.get(1) {
return m.as_str().parse().ok();
}
if let Some(m) = cap.get(2) {
return m.as_str().parse().ok();
}
None
}
fn parse_generic(
caps: ®ex::Captures,
full_match: &str,
index: u8,
input: &str,
) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let season = cap_i32(caps, "season");
let ep = cap_i32(caps, "ep");
let ep1 = cap_i32(caps, "ep1");
let ep2 = cap_i32(caps, "ep2");
let season_number = match season {
Some(s) => s,
None => {
if index <= 6 || index == 97 {
extract_season_from_match(full_match)?
} else {
1
}
}
};
let episodes = if let Some(e) = ep {
let re_scanned = extract_episodes_from_match(full_match);
if re_scanned.len() > 1 {
re_scanned
} else {
vec![e]
}
} else if let Some(e1) = ep1 {
if let Some(e2) = ep2 {
let range = episode_range(e1, e2);
if range.is_empty() {
return None;
}
range
} else {
vec![e1]
}
} else {
let scanned = extract_episodes_from_match(full_match);
if scanned.is_empty() {
return None;
}
scanned
};
let is_split = cap_str(caps, "splitepisode").is_some();
let special = cap_str(caps, "special").is_some();
let mut info = ParsedEpisodeInfo {
series_title: title,
season_number,
episode_numbers: episodes,
is_split_episode: is_split,
special,
..Default::default()
};
if info.episode_numbers.len() >= 2
&& let Some(scan_start) = absolute_scan_start(caps)
&& let Some(abs_range) =
enrich_absolute_from_tail(input, scan_start, info.episode_numbers.len())
{
info.absolute_episode_numbers = abs_range;
}
Some(info)
}
fn parse_explicit_dual(
caps: ®ex::Captures,
index: u8,
input: &str,
) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let season = cap_i32(caps, "season")?;
let ep1 = cap_i32(caps, "ep1")?;
let ep2 = cap_i32(caps, "ep2");
let episodes = if let Some(e2) = ep2 {
episode_range(ep1, e2)
} else {
vec![ep1]
};
if episodes.is_empty() {
return None;
}
let full_season = if index == 36 {
if let (Some(count), Some(&last)) = (cap_i32(caps, "episodecount"), episodes.last()) {
last == count
} else {
false
}
} else {
false
};
let mut info = ParsedEpisodeInfo {
series_title: title,
season_number: season,
episode_numbers: if full_season { vec![] } else { episodes },
full_season,
..Default::default()
};
if info.episode_numbers.len() >= 2
&& let Some(scan_start) = absolute_scan_start(caps)
&& let Some(abs_range) =
enrich_absolute_from_tail(input, scan_start, info.episode_numbers.len())
{
info.absolute_episode_numbers = abs_range;
}
Some(info)
}
fn parse_multi_season(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let s1 = cap_i32(caps, "season1")?;
let s2 = cap_i32(caps, "season2")?;
if s1 < 1 || s2 <= s1 {
return None;
}
Some(ParsedEpisodeInfo {
series_title: title,
season_number: s1,
full_season: true,
is_multi_season: true,
..Default::default()
})
}
fn parse_partial_season(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let season = cap_i32(caps, "season")?;
let season_part = cap_i32(caps, "seasonpart").unwrap_or(0);
Some(ParsedEpisodeInfo {
series_title: title,
season_number: season,
is_partial_season: true,
season_part,
..Default::default()
})
}
fn parse_season_only(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let season = cap_i32(caps, "season")?;
let extras = cap_str(caps, "extras");
Some(ParsedEpisodeInfo {
series_title: title,
season_number: season,
full_season: extras.is_none(),
is_season_extra: extras.is_some(),
..Default::default()
})
}
fn parse_mini_series_part(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let ep = cap_i32(caps, "ep")?;
Some(ParsedEpisodeInfo {
series_title: title,
season_number: 1,
episode_numbers: vec![ep],
..Default::default()
})
}
fn parse_mini_series_word(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let word = cap_str(caps, "ep")?;
let ep = word_to_number(word)?;
Some(ParsedEpisodeInfo {
series_title: title,
season_number: 1,
episode_numbers: vec![ep],
..Default::default()
})
}
fn parse_mini_dual(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let ep1 = cap_i32(caps, "ep1")?;
let ep2 = cap_i32(caps, "ep2");
let episodes = if let Some(e2) = ep2 {
episode_range(ep1, e2)
} else {
vec![ep1]
};
Some(ParsedEpisodeInfo {
series_title: title,
season_number: 1,
episode_numbers: episodes,
..Default::default()
})
}
fn parse_spanish_cap(caps: ®ex::Captures, full_match: &str) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let season = cap_i32(caps, "season")?;
let ep = cap_i32(caps, "ep")?;
static CAP_RANGE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)Cap[_. ]+(\d{1,2})(\d{2})[_](\d{1,2})(\d{2})").expect("CAP_RANGE")
});
if let Some(range_caps) = CAP_RANGE_RE.captures(full_match) {
let s1: i32 = range_caps[1].parse().ok()?;
let e1: i32 = range_caps[2].parse().ok()?;
let _s2: i32 = range_caps[3].parse().ok()?;
let e2: i32 = range_caps[4].parse().ok()?;
return Some(ParsedEpisodeInfo {
series_title: title,
season_number: s1,
episode_numbers: episode_range(e1, e2),
..Default::default()
});
}
Some(ParsedEpisodeInfo {
series_title: title,
season_number: season,
episode_numbers: vec![ep],
..Default::default()
})
}
static SHORT_FORMAT_SCAN_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"([1-9])([1-9][0-9]|0[1-9])").expect("SHORT_FORMAT_SCAN"));
fn parse_short_format(caps: ®ex::Captures, full_match: &str) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let title_end = caps.name("title").map(|m| m.end()).unwrap_or(0);
let numbers_text = &full_match[title_end..];
let mut season: Option<i32> = None;
let mut episodes = Vec::new();
for scan_cap in SHORT_FORMAT_SCAN_RE.captures_iter(numbers_text) {
let s: i32 = scan_cap[1].parse().ok()?;
let e: i32 = scan_cap[2].parse().ok()?;
if season.is_none() {
season = Some(s);
}
if season == Some(s) && !episodes.contains(&e) {
episodes.push(e);
}
}
if episodes.is_empty() {
return None;
}
Some(ParsedEpisodeInfo {
series_title: title,
season_number: season?,
episode_numbers: episodes,
..Default::default()
})
}
fn parse_four_digit_short(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let season = cap_i32(caps, "season")?;
let ep = cap_i32(caps, "ep")?;
Some(ParsedEpisodeInfo {
series_title: title,
season_number: season,
episode_numbers: vec![ep],
..Default::default()
})
}
fn parse_terrible_multi(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let season = cap_i32(caps, "season").unwrap_or(0);
let ep1 = cap_i32(caps, "ep1")?;
let ep2 = cap_i32(caps, "ep2")?;
Some(ParsedEpisodeInfo {
series_title: title,
season_number: season,
episode_numbers: vec![ep1, ep2],
..Default::default()
})
}
fn parse_daily(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let year = cap_i32(caps, "airyear")?;
let mut month = cap_i32(caps, "airmonth")?;
let mut day = cap_i32(caps, "airday")?;
if month > 12 {
std::mem::swap(&mut month, &mut day);
}
Some(ParsedEpisodeInfo {
series_title: title,
air_year: year,
air_month: month,
air_day: day,
..Default::default()
})
}
fn parse_daily_with_episode(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let year = cap_i32(caps, "airyear")?;
let mut month = cap_i32(caps, "airmonth")?;
let mut day = cap_i32(caps, "airday")?;
let season = cap_i32(caps, "season").unwrap_or(0);
let ep = cap_i32(caps, "ep")
.or_else(|| cap_i32(caps, "episode"))
.unwrap_or(0);
if month > 12 {
std::mem::swap(&mut month, &mut day);
}
Some(ParsedEpisodeInfo {
series_title: title,
air_year: year,
air_month: month,
air_day: day,
season_number: season,
episode_numbers: if ep > 0 { vec![ep] } else { Vec::new() },
..Default::default()
})
}
fn parse_daily_with_part(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let year = cap_i32(caps, "airyear")?;
let mut month = cap_i32(caps, "airmonth")?;
let mut day = cap_i32(caps, "airday")?;
let part = cap_i32(caps, "part")?;
if month > 12 {
std::mem::swap(&mut month, &mut day);
}
Some(ParsedEpisodeInfo {
series_title: title,
air_year: year,
air_month: month,
air_day: day,
daily_part: Some(part),
..Default::default()
})
}
fn parse_ambiguous_date(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let year = cap_i32(caps, "airyear")?;
let raw_month = cap_i32(caps, "ambiguousairmonth")?;
let raw_day = cap_i32(caps, "ambiguousairday")?;
let (month, day) = disambiguate_date(raw_month, raw_day)?;
Some(ParsedEpisodeInfo {
series_title: title,
air_year: year,
air_month: month,
air_day: day,
..Default::default()
})
}
fn disambiguate_date(raw_month: i32, raw_day: i32) -> Option<(i32, i32)> {
if raw_month > 12 {
Some((raw_day, raw_month))
} else if raw_day > 12 {
Some((raw_month, raw_day))
} else {
None
}
}
fn parse_anime_season_episode(caps: ®ex::Captures) -> Option<ParsedEpisodeInfo> {
let title = title_from_caps(caps);
let season = cap_i32(caps, "season")?;
let ep = cap_i32(caps, "episode")?;
let release_hash = extract_hash(caps);
Some(ParsedEpisodeInfo {
series_title: title,
season_number: season,
episode_numbers: vec![ep],
release_hash,
..Default::default()
})
}
fn parse_absolute_number(s: &str) -> Option<(i32, bool)> {
if let Some(dot_pos) = s.find('.') {
let int_part = &s[..dot_pos];
let n: i32 = int_part.parse().ok()?;
Some((n, true))
} else {
let n: i32 = s.parse().ok()?;
Some((n, false))
}
}
fn extract_hash(caps: ®ex::Captures) -> Option<String> {
let raw = cap_str(caps, "hash")?;
let trimmed = raw
.trim_start_matches(['[', '('])
.trim_end_matches([']', ')']);
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn find_range_start(caps: ®ex::Captures, abs_start: usize, abs_ep: i32) -> Option<i32> {
let full_match = caps.get(0)?;
let title_end = caps.name("title").map(|m| m.end()).unwrap_or(0);
if abs_start <= title_end {
return None;
}
let between = full_match.as_str().get(
title_end.saturating_sub(full_match.start())..abs_start.saturating_sub(full_match.start()),
)?;
static TRAILING_CHAIN_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:(\d{1,4})(?:[-_]| - ))+$").expect("TRAILING_CHAIN_RE"));
let m = TRAILING_CHAIN_RE.find(between)?;
let chain = &between[m.start()..];
static CHAIN_NUM_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(\d{1,4})").expect("CHAIN_NUM_RE"));
let mut first: Option<i32> = None;
for cap in CHAIN_NUM_RE.captures_iter(chain) {
if let Ok(n) = cap[1].parse::<i32>()
&& n < abs_ep
&& first.is_none()
{
first = Some(n);
}
}
first
}
fn find_batch_range_in_title(
caps: ®ex::Captures,
abs_start: usize,
abs_ep: i32,
input: &str,
) -> Option<(i32, usize)> {
let title_match = caps.name("title")?;
let title_start = title_match.start();
let title_end = title_match.end();
let gap = input.get(title_end..abs_start)?;
if gap != " - " {
return None;
}
static TITLE_TAIL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\s)(0\d{1,3})\s*$").expect("TITLE_TAIL_RE"));
let title_text = input.get(title_start..title_end)?;
let m = TITLE_TAIL_RE.captures(title_text)?;
let num = m.get(1)?;
let first: i32 = num.as_str().parse().ok()?;
if first >= abs_ep || first <= 0 {
return None;
}
let trim_at = title_start + num.start();
Some((first, trim_at))
}
fn find_range_end(input: &str, abs_end: usize, _abs_ep: i32) -> Option<i32> {
if abs_end >= input.len() {
return None;
}
let after = &input[abs_end..];
static RANGE_END_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(?:[-_. ]+(\d{1,4}))+").expect("RANGE_END_RE"));
let m = RANGE_END_RE.captures(after)?;
let n: i32 = m[1].parse().ok()?;
Some(n)
}
fn reattach_trailing_year(
caps: ®ex::Captures,
title: String,
abs_ep: i32,
input: &str,
) -> String {
if !(1..=999).contains(&abs_ep) {
return title;
}
let title_end = match caps.name("title") {
Some(m) => m.end(),
None => return title,
};
let abs_start = match caps.name("absoluteepisode") {
Some(m) => m.start(),
None => return title,
};
let between = match input.get(title_end..abs_start) {
Some(s) => s,
None => return title,
};
static YEAR_BETWEEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[ ._-](?P<year>(?:19|20)\d{2})[ ._-]$").expect("YEAR_BETWEEN_RE")
});
match YEAR_BETWEEN_RE.captures(between) {
Some(c) => format!("{title} {}", &c["year"]),
None => title,
}
}
fn absolute_scan_start(caps: ®ex::Captures) -> Option<usize> {
if let Some(m) = caps.name("ep2") {
return Some(m.end());
}
if let Some(m) = caps.name("ep1") {
return Some(m.end());
}
if let Some(m) = caps.name("ep") {
return Some(m.end());
}
None
}
fn enrich_absolute_from_tail(
input: &str,
scan_start: usize,
expected_len: usize,
) -> Option<Vec<i32>> {
if expected_len < 2 || scan_start >= input.len() {
return None;
}
let tail = &input[scan_start..];
static TAIL_RANGE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?:\((?P<paren>\d{1,4}(?:-\d{1,4})+)\)|\s-\s*(?P<dash>\d{1,4}(?:-\d{1,4})+)\b)",
)
.expect("TAIL_RANGE_RE")
});
let m = TAIL_RANGE_RE.captures(tail)?;
let chain = m.name("paren").or_else(|| m.name("dash"))?.as_str();
let nums: Vec<i32> = chain.split('-').filter_map(|s| s.parse().ok()).collect();
if nums.len() != expected_len {
return None;
}
let lo = *nums.first()?;
let hi = *nums.last()?;
if lo <= 0 || hi <= lo || (hi - lo) >= 100 {
return None;
}
Some(episode_range(lo, hi))
}
fn parse_absolute(caps: ®ex::Captures, index: u8, input: &str) -> Option<ParsedEpisodeInfo> {
let mut title = title_from_caps(caps);
let abs_str = cap_str(caps, "absoluteepisode")?;
let (abs_ep, mut is_special) = parse_absolute_number(abs_str)?;
if abs_ep <= 0 && !matches!(index, 45 | 46) {
return None;
}
if cap_str(caps, "special").is_some() {
is_special = true;
}
title = if matches!(index, 87..=94) {
reattach_trailing_year(caps, title, abs_ep, input)
} else {
title
};
let mut absolute_episodes = vec![abs_ep];
if let Some(abs2_str) = cap_str(caps, "absoluteepisode2")
&& let Some((abs_ep2, special2)) = parse_absolute_number(abs2_str)
{
if special2 {
is_special = true;
}
if abs_ep2 > abs_ep && (abs_ep2 - abs_ep) < 100 {
absolute_episodes = episode_range(abs_ep, abs_ep2);
let abs1_match = caps.name("absoluteepisode");
let abs2_match = caps.name("absoluteepisode2");
let backward = abs1_match
.and_then(|m| find_range_start(caps, m.start(), abs_ep))
.filter(|&n| n < abs_ep);
let forward = abs2_match
.and_then(|m| find_range_end(input, m.end(), abs_ep2))
.filter(|&n| n > abs_ep2);
let lo = backward.unwrap_or(abs_ep);
let hi = forward.unwrap_or(abs_ep2);
if lo < hi && (hi - lo) < 100 {
absolute_episodes = episode_range(lo, hi);
}
} else if abs_ep2 != abs_ep {
absolute_episodes.push(abs_ep2);
}
} else if absolute_episodes.len() == 1 {
if let Some(abs_match) = caps.name("absoluteepisode") {
let backward = find_range_start(caps, abs_match.start(), abs_ep);
let forward = find_range_end(input, abs_match.end(), abs_ep);
let backward_ok = backward.filter(|&n| n < abs_ep);
let forward_ok = forward.filter(|&n| n > abs_ep);
match (backward_ok, forward_ok) {
(Some(first), Some(last)) => {
if (last - first) < 100 {
absolute_episodes = episode_range(first, last);
}
}
(Some(first), None) => {
if (abs_ep - first) < 100 {
absolute_episodes = episode_range(first, abs_ep);
}
}
(None, Some(last)) => {
if (last - abs_ep) < 100 {
absolute_episodes = episode_range(abs_ep, last);
}
}
(None, None) => {
if let Some((first, trim_at)) =
find_batch_range_in_title(caps, abs_match.start(), abs_ep, input)
{
absolute_episodes = episode_range(first, abs_ep);
let title_start = caps.name("title").map(|m| m.start()).unwrap_or(0);
title = normalize::clean_series_title(&input[title_start..trim_at]);
}
}
}
}
}
let season_number = match index {
7 | 8 | 11..=15 | 26 => cap_i32(caps, "season").unwrap_or(0),
_ => 0,
};
let episode_numbers = match index {
7 | 8 | 11..=13 | 26 => {
if let Some(ep) = cap_i32(caps, "episode") {
vec![ep]
} else {
Vec::new()
}
}
_ => Vec::new(),
};
let release_hash = extract_hash(caps);
Some(ParsedEpisodeInfo {
series_title: title,
season_number,
episode_numbers,
absolute_episode_numbers: absolute_episodes,
special: is_special,
release_hash,
..Default::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_absolute_capture_followed_by_hyphen_lowercase_word() {
let input =
"[Chihiro] Anime Title 300-nen, With Even More Title 02 [720p Hi10P AAC][031FA533]";
let info = parse_title(input).expect("must match a regex");
assert_eq!(
info.series_title,
"Anime Title 300-nen, With Even More Title"
);
assert_eq!(info.absolute_episode_numbers, vec![2]);
}
#[test]
fn rejects_absolute_when_later_dash_episode_follows_words() {
let input = "[SubsPlease] Series Title - 100 Years Quest - 01 (1080p) [1107F3A9].mkv";
let info = parse_title(input).expect("must match a regex");
assert_eq!(info.series_title, "Series Title - 100 Years Quest");
assert_eq!(info.absolute_episode_numbers, vec![1]);
}
#[test]
fn detects_space_dash_space_range_start() {
let input = "[HorribleSubs] Some Anime Show 01 - 119 [1080p] [Batch]";
let info = parse_title(input).expect("must match a regex");
assert_eq!(info.series_title, "Some Anime Show");
assert_eq!(info.absolute_episode_numbers.first().copied(), Some(1));
assert_eq!(info.absolute_episode_numbers.last().copied(), Some(119));
}
#[test]
fn extracts_triple_dash_range_to_third_element() {
let input = "Series Title (2010) - 01-02-03 - Episode Title (1) HDTV-720p";
let info = parse_title(input).expect("must match a regex");
assert_eq!(info.series_title, "Series Title (2010)");
assert_eq!(info.absolute_episode_numbers, vec![1, 2, 3]);
}
#[test]
fn carries_trailing_year_into_title_for_broad_absolute() {
let input = "Series Title 2018 06 720p x265 AOZ.mp4";
let info = parse_title(input).expect("must match a regex");
assert_eq!(info.series_title, "Series Title 2018");
assert_eq!(info.absolute_episode_numbers, vec![6]);
}
#[test]
fn enriches_standard_match_with_trailing_absolute_range() {
let input =
"Series Title (2010) - S01E01-02 (001-002) - Episode Title (1) HDTV-720p v2 [RlsGrp]";
let info = parse_title(input).expect("must match a regex");
assert_eq!(info.series_title, "Series Title (2010)");
assert_eq!(info.season_number, 1);
assert_eq!(info.episode_numbers, vec![1, 2]);
assert_eq!(info.absolute_episode_numbers.first().copied(), Some(1));
assert_eq!(info.absolute_episode_numbers.last().copied(), Some(2));
}
}