use once_cell::sync::Lazy;
use regex::Regex;
use crate::normalize;
const EXCEPTION_GROUPS: &[&str] = &[
"Silence",
"afm72",
"Panda",
"Ghost",
"MONOLITH",
"Tigole",
"Joy",
"ImE",
"UTR",
"t3nzin",
"Anime Time",
"Project Angel",
"Hakata Ramen",
"HONE",
"Vyndros",
"SEV",
"Garshasp",
"Kappa",
"Natty",
"RCVR",
"SAMPA",
"YOGI",
"r00t",
"EDGE2020",
"RZeroX",
];
const EXCEPTION_EXACT_GROUPS: &[&str] = &[
"D-Z0N3",
"Fight-BB",
"VARYG",
"E.N.D",
"KRaLiMaRKo",
"BluDragon",
"DarQ",
"KCRT",
"BEN THE MEN",
];
static EXCEPTION_EXACT_REGEX: Lazy<Regex> = Lazy::new(|| {
let alts: Vec<String> = EXCEPTION_EXACT_GROUPS
.iter()
.map(|&g| {
if g == "BEN THE MEN" {
r"BEN[_. ]THE[_. ]MEN".to_string()
} else {
regex::escape(g)
}
})
.collect();
let pattern = format!(r"(?i)(?P<releasegroup>{})\b", alts.join("|"));
Regex::new(&pattern).expect("EXCEPTION_EXACT_REGEX")
});
static INVALID_RELEASE_GROUP_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^([se]\d+|[0-9a-f]{8})$").expect("INVALID_RELEASE_GROUP_REGEX"));
static PRE_SUB_REGEXES: Lazy<Vec<(Regex, &'static str)>> = Lazy::new(|| {
vec![
(
Regex::new(r"\.E(\d{2,4})\.\d{6}\.(.*-NEXT)$").expect("PRE_SUB_0"),
".S01E$1.$2",
),
(
Regex::new(
r"(?i)^\[(?:(?P<subgroup>[^\]]+?)(?:[\x{4e00}-\x{9fcc}]+)?)\]\[(?P<title>[^\]]+?)(?:\s(?P<chinesetitle>[\x{4e00}-\x{9fcc}][^\]]*?))\]\[(?:(?:[\x{4e00}-\x{9fcc}]+?)?(?P<episode>\d{1,4})(?:[\x{4e00}-\x{9fcc}]+?)?)\]",
)
.expect("PRE_SUB_1"),
"[${subgroup}] ${title} - ${episode} - ",
),
(
Regex::new(
r"(?i)^\[(?P<subgroup>[^\]]*?(?:LoliHouse|ZERO|Lilith-Raws|Skymoon-Raws|orion origin)[^\]]*?)\](?P<title>[^\[\]]+?)(?:\s-\s(?P<episode>[0-9-]+)\s*|\[\x{7b2c}?(?P<episode2>[0-9]+(?:-[0-9]+)?)\x{8bdd}?(?:END|\x{5b8c})?\])\[",
)
.expect("PRE_SUB_2"),
"[${subgroup}][${title}][${episode}${episode2}][",
),
(
Regex::new(
r"(?i)^\[(?P<subgroup>[^\]]+)\](?:\s?\x{2605}[^\[\s-]+\s?)?\[?(?:(?P<chinesetitle>[^\]]*?)(?:\]\[|\s*[_/\x{00b7}]\s*)){0,2}(?P<title>[^\[\]]+?)(?:\s(?:S?(?:(?:0)(?P<season>\d)|(?P<season2>[1-9]\d))))\]?(?:\[\d{4}\])?\[\x{7b2c}?(?P<episode>[0-9]+(?:-[0-9]+)?)\x{8bdd}?\x{96c6}?(?:\s?END|\x{5b8c}|\s?Fin)?\]",
)
.expect("PRE_SUB_3"),
"[${subgroup}] ${title} S${season}${season2} - ${episode} ",
),
(
Regex::new(
r"(?i)^\[(?P<subgroup>[^\]]+)\](?:(?P<chinesub>\[[^\]]*\])+)\[(?P<title>[^\]]+?)\](?P<junk>\[[^\]]+\])*\[(?P<episode>[0-9]+(?:-[0-9]+)?)(?:\sEND|\sFin)?\]",
)
.expect("PRE_SUB_4"),
"[${subgroup}] ${title} - ${episode} ",
),
]
});
static ANIME_RELEASE_GROUP_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^\[(?P<subgroup>[^\]]+)\]").expect("ANIME_RELEASE_GROUP_REGEX"));
static CLEAN_RELEASE_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)(?:-(RP|1|NZBGeek|Obfuscated|Scrambled|sample|Pre|postbot|xpost|Rakuv[a-z0-9]*|WhiteRev|BUYMORE|AsRequested|AlternativeToRequested|GEROV|Z0iDS3N|Chamele0n|4P|4Planet|AlteZachen|RePACKPOST))+$",
)
.expect("CLEAN_RELEASE_GROUP_REGEX")
});
const RESOLUTION_TOKENS: &[&str] = &["480p", "576p", "720p", "1080p", "2160p"];
const BLACKLIST_FULL: &[&str] = &["WEB-DL", "Blu-Ray", "DTS-HD", "DTS-X", "DTS-MA", "DTS-ES"];
const BLACKLIST_SINGLE_SEGMENT: &[&str] = &["ES", "EN", "CAT"];
struct TrailingCandidate {
dash_pos: usize,
full_end: usize,
group: String,
}
fn find_trailing_group_candidates(title: &str) -> Vec<TrailingCandidate> {
let mut candidates = Vec::new();
let bytes = title.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'-' {
let group_start = i + 1;
if group_start >= len {
break;
}
let mut j = group_start;
while j < len && bytes[j].is_ascii_alphanumeric() {
j += 1;
}
if j == group_start {
i += 1;
continue;
}
let first_end = j;
let mut full_end = first_end;
if j < len && bytes[j] == b'-' {
let seg2_start = j + 1;
let mut k = seg2_start;
while k < len && bytes[k].is_ascii_alphanumeric() {
k += 1;
}
if k > seg2_start {
full_end = k;
}
}
let group_text = title[group_start..full_end].to_string();
candidates.push(TrailingCandidate {
dash_pos: i,
full_end,
group: group_text,
});
i = full_end;
} else {
i += 1;
}
}
candidates
}
fn has_valid_boundary(title: &str, end: usize) -> bool {
if end >= title.len() {
return true; }
let remaining = &title[end..];
if let Some(ch) = remaining.chars().next() {
if matches!(ch, '-' | '.' | '_' | ' ') {
return true;
}
if ch.is_alphanumeric() {
return false;
}
true
} else {
true }
}
fn resolution_after(title: &str, pos: usize) -> bool {
if pos >= title.len() {
return false;
}
let rest = &title[pos..];
let rest_lower = rest.to_ascii_lowercase();
RESOLUTION_TOKENS.iter().any(|&r| rest_lower.contains(r))
}
fn is_resolution(candidate: &str) -> bool {
RESOLUTION_TOKENS
.iter()
.any(|&r| r.eq_ignore_ascii_case(candidate))
}
fn context_is_blacklisted(title: &str, candidate: &TrailingCandidate) -> bool {
let dash_pos = candidate.dash_pos;
let mut prefix_start = dash_pos;
while prefix_start > 0 && title.as_bytes()[prefix_start - 1].is_ascii_alphanumeric() {
prefix_start -= 1;
}
if prefix_start < dash_pos {
let full_token = &title[prefix_start..candidate.full_end];
for &bl in BLACKLIST_FULL {
if bl.eq_ignore_ascii_case(full_token) {
return true;
}
}
}
if !candidate.group.contains('-') {
for &bl in BLACKLIST_SINGLE_SEGMENT {
if bl.eq_ignore_ascii_case(&candidate.group) {
return true;
}
}
}
static DATE_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d{4}-\d{2}$").expect("DATE_PATTERN"));
if DATE_PATTERN.is_match(&title[..candidate.full_end]) {
return true;
}
static FULL_DATE_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d{4}-\d{1,2}-\d{2}$").expect("FULL_DATE_PATTERN"));
if FULL_DATE_PATTERN.is_match(&title[..candidate.full_end]) {
return true;
}
static TWO_DIGIT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{2}$").expect("TWO_DIGIT"));
if TWO_DIGIT.is_match(&candidate.group) {
return true;
}
false
}
static TRAILING_BRACKET_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"[-._ ]\[(?P<releasegroup>[a-zA-Z0-9]+)\]$").expect("TRAILING_BRACKET_GROUP_REGEX")
});
static EXCEPTION_RELEASE_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
let alts: Vec<String> = EXCEPTION_GROUPS.iter().map(|&g| regex::escape(g)).collect();
let pattern = format!(r"(?i)[._ \[](?P<releasegroup>{})[)\]]", alts.join("|"));
Regex::new(&pattern).expect("EXCEPTION_RELEASE_GROUP_REGEX")
});
pub fn parse_release_group(title: &str) -> Option<String> {
let title = title.trim();
if title.is_empty() {
return None;
}
let mut title = normalize::remove_file_extension(title);
for (re, replacement) in PRE_SUB_REGEXES.iter() {
if re.is_match(&title) {
title = re.replace(&title, *replacement).into_owned();
break;
}
}
title = crate::episode::regexes::WEBSITE_PREFIX_REGEX
.replace(&title, "")
.into_owned();
title = crate::episode::regexes::CLEAN_TORRENT_SUFFIX_REGEX
.replace(&title, "")
.into_owned();
if let Some(caps) = ANIME_RELEASE_GROUP_REGEX.captures(&title)
&& let Some(m) = caps.name("subgroup")
{
let subgroup = m.as_str().trim();
if !subgroup.is_empty() {
return Some(subgroup.to_string());
}
}
title = CLEAN_RELEASE_GROUP_REGEX.replace(&title, "").into_owned();
let mut last_exception: Option<String> = None;
for caps in EXCEPTION_RELEASE_GROUP_REGEX.captures_iter(&title) {
if let Some(m) = caps.name("releasegroup") {
last_exception = Some(m.as_str().to_string());
}
}
if let Some(group) = last_exception {
return Some(group);
}
let mut last_exact: Option<String> = None;
for caps in EXCEPTION_EXACT_REGEX.captures_iter(&title) {
if let Some(m) = caps.name("releasegroup") {
last_exact = Some(m.as_str().to_string());
}
}
if let Some(group) = last_exact {
return Some(group);
}
let candidates = find_trailing_group_candidates(&title);
for candidate in candidates.iter().rev() {
let group = &candidate.group;
if !has_valid_boundary(&title, candidate.full_end) {
continue;
}
if resolution_after(&title, candidate.full_end) {
continue;
}
if is_resolution(group) {
continue;
}
if context_is_blacklisted(&title, candidate) {
continue;
}
if group.parse::<i64>().is_ok() {
continue;
}
if INVALID_RELEASE_GROUP_REGEX.is_match(group) {
continue;
}
return Some(group.clone());
}
if let Some(caps) = TRAILING_BRACKET_GROUP_REGEX.captures(&title)
&& let Some(m) = caps.name("releasegroup")
{
let group = m.as_str();
if group.parse::<i64>().is_err() && !INVALID_RELEASE_GROUP_REGEX.is_match(group) {
return Some(group.to_string());
}
}
None
}
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn trailing_group_candidate_search() {
let cases = find_trailing_group_candidates("Series.S01E01.720p.HDTV.x264-LOL");
assert!(
cases.iter().any(|c| c.group == "LOL"),
"should find LOL, got: {:?}",
cases.iter().map(|c| &c.group).collect::<Vec<_>>()
);
let cases2 =
find_trailing_group_candidates("SomeShow S01E168 1080p WEB-DL AAC 2.0 x264-Erai-raws");
assert!(
cases2.iter().any(|c| c.group == "Erai-raws"),
"should find Erai-raws with hyphen, got: {:?}",
cases2.iter().map(|c| &c.group).collect::<Vec<_>>()
);
}
#[test]
fn trailing_group_last_match_wins() {
let candidates =
find_trailing_group_candidates("Series-Title.S01E01.720p-FIRST.HDTV-SECOND");
let last = candidates.last().map(|c| c.group.as_str());
assert_eq!(last, Some("SECOND"), "last match should win");
}
#[test]
fn invalid_release_group_rejection() {
assert!(
INVALID_RELEASE_GROUP_REGEX.is_match("S01"),
"S01 should be invalid"
);
assert!(
INVALID_RELEASE_GROUP_REGEX.is_match("s02"),
"s02 should be invalid"
);
assert!(
INVALID_RELEASE_GROUP_REGEX.is_match("E15"),
"E15 should be invalid"
);
assert!(
INVALID_RELEASE_GROUP_REGEX.is_match("6B7FD717"),
"8-char hex should be invalid"
);
assert!(
INVALID_RELEASE_GROUP_REGEX.is_match("6b7fd717"),
"lowercase 8-char hex should be invalid"
);
assert!(
!INVALID_RELEASE_GROUP_REGEX.is_match("LOL"),
"LOL should be valid"
);
}
#[test]
fn blacklist_rejection() {
let title = "Series.S01E01.720p.WEB-DL.x264";
let candidates = find_trailing_group_candidates(title);
let dl_candidate = candidates.iter().find(|c| c.group == "DL");
assert!(dl_candidate.is_some(), "should find -DL candidate");
assert!(
context_is_blacklisted(title, dl_candidate.expect("dl candidate")),
"WEB-DL should be context-blacklisted"
);
let title2 = "Series.S01E01.DTS-HD.MA.5.1.x264";
let candidates2 = find_trailing_group_candidates(title2);
let hd_candidate = candidates2.iter().find(|c| c.group == "HD");
assert!(hd_candidate.is_some(), "should find -HD candidate");
assert!(
context_is_blacklisted(title2, hd_candidate.expect("hd candidate")),
"DTS-HD should be context-blacklisted"
);
}
#[test]
fn resolution_suffix_rejection() {
assert!(
resolution_after("Series.720p.HDTV.x264-LOL.1080p", 25),
"should detect 1080p after position 25"
);
assert!(
!resolution_after("Series.720p.HDTV.x264-LOL", 25),
"no resolution after group at end"
);
assert!(is_resolution("1080p"), "1080p should be a resolution");
assert!(is_resolution("720p"), "720p should be a resolution");
assert!(!is_resolution("LOL"), "LOL should not be a resolution");
}
}