avatarr_parser/release_group/
mod.rs1use once_cell::sync::Lazy;
19use regex::Regex;
20
21use crate::normalize;
22
23const EXCEPTION_GROUPS: &[&str] = &[
31 "Silence",
32 "afm72",
33 "Panda",
34 "Ghost",
35 "MONOLITH",
36 "Tigole",
37 "Joy",
38 "ImE",
39 "UTR",
40 "t3nzin",
41 "Anime Time",
42 "Project Angel",
43 "Hakata Ramen",
44 "HONE",
45 "Vyndros",
46 "SEV",
47 "Garshasp",
48 "Kappa",
49 "Natty",
50 "RCVR",
51 "SAMPA",
52 "YOGI",
53 "r00t",
54 "EDGE2020",
55 "RZeroX",
56];
57
58const EXCEPTION_EXACT_GROUPS: &[&str] = &[
61 "D-Z0N3",
62 "Fight-BB",
63 "VARYG",
64 "E.N.D",
65 "KRaLiMaRKo",
66 "BluDragon",
67 "DarQ",
68 "KCRT",
69 "BEN THE MEN",
70];
71
72static EXCEPTION_EXACT_REGEX: Lazy<Regex> = Lazy::new(|| {
75 let alts: Vec<String> = EXCEPTION_EXACT_GROUPS
76 .iter()
77 .map(|&g| {
78 if g == "BEN THE MEN" {
79 r"BEN[_. ]THE[_. ]MEN".to_string()
80 } else {
81 regex::escape(g)
82 }
83 })
84 .collect();
85 let pattern = format!(r"(?i)(?P<releasegroup>{})\b", alts.join("|"));
86 Regex::new(&pattern).expect("EXCEPTION_EXACT_REGEX")
87});
88
89static INVALID_RELEASE_GROUP_REGEX: Lazy<Regex> =
91 Lazy::new(|| Regex::new(r"(?i)^([se]\d+|[0-9a-f]{8})$").expect("INVALID_RELEASE_GROUP_REGEX"));
92
93static PRE_SUB_REGEXES: Lazy<Vec<(Regex, &'static str)>> = Lazy::new(|| {
106 vec![
107 (
109 Regex::new(r"\.E(\d{2,4})\.\d{6}\.(.*-NEXT)$").expect("PRE_SUB_0"),
110 ".S01E$1.$2",
111 ),
112 (
114 Regex::new(
115 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}]+?)?)\]",
116 )
117 .expect("PRE_SUB_1"),
118 "[${subgroup}] ${title} - ${episode} - ",
119 ),
120 (
122 Regex::new(
123 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})?\])\[",
124 )
125 .expect("PRE_SUB_2"),
126 "[${subgroup}][${title}][${episode}${episode2}][",
127 ),
128 (
130 Regex::new(
131 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)?\]",
132 )
133 .expect("PRE_SUB_3"),
134 "[${subgroup}] ${title} S${season}${season2} - ${episode} ",
135 ),
136 (
138 Regex::new(
139 r"(?i)^\[(?P<subgroup>[^\]]+)\](?:(?P<chinesub>\[[^\]]*\])+)\[(?P<title>[^\]]+?)\](?P<junk>\[[^\]]+\])*\[(?P<episode>[0-9]+(?:-[0-9]+)?)(?:\sEND|\sFin)?\]",
140 )
141 .expect("PRE_SUB_4"),
142 "[${subgroup}] ${title} - ${episode} ",
143 ),
144 ]
145});
146
147static ANIME_RELEASE_GROUP_REGEX: Lazy<Regex> =
149 Lazy::new(|| Regex::new(r"^\[(?P<subgroup>[^\]]+)\]").expect("ANIME_RELEASE_GROUP_REGEX"));
150
151static CLEAN_RELEASE_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
154 Regex::new(
155 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))+$",
156 )
157 .expect("CLEAN_RELEASE_GROUP_REGEX")
158});
159
160const RESOLUTION_TOKENS: &[&str] = &["480p", "576p", "720p", "1080p", "2160p"];
163
164const BLACKLIST_FULL: &[&str] = &["WEB-DL", "Blu-Ray", "DTS-HD", "DTS-X", "DTS-MA", "DTS-ES"];
185
186const BLACKLIST_SINGLE_SEGMENT: &[&str] = &["ES", "EN", "CAT"];
189
190struct TrailingCandidate {
192 dash_pos: usize,
194 full_end: usize,
196 group: String,
198}
199
200fn find_trailing_group_candidates(title: &str) -> Vec<TrailingCandidate> {
202 let mut candidates = Vec::new();
203 let bytes = title.as_bytes();
204 let len = bytes.len();
205 let mut i = 0;
206
207 while i < len {
208 if bytes[i] == b'-' {
209 let group_start = i + 1;
210 if group_start >= len {
211 break;
212 }
213
214 let mut j = group_start;
216 while j < len && bytes[j].is_ascii_alphanumeric() {
217 j += 1;
218 }
219 if j == group_start {
220 i += 1;
221 continue;
222 }
223
224 let first_end = j;
225
226 let mut full_end = first_end;
228 if j < len && bytes[j] == b'-' {
229 let seg2_start = j + 1;
230 let mut k = seg2_start;
231 while k < len && bytes[k].is_ascii_alphanumeric() {
232 k += 1;
233 }
234 if k > seg2_start {
235 full_end = k;
236 }
237 }
238
239 let group_text = title[group_start..full_end].to_string();
240 candidates.push(TrailingCandidate {
241 dash_pos: i,
242 full_end,
243 group: group_text,
244 });
245 i = full_end;
246 } else {
247 i += 1;
248 }
249 }
250
251 candidates
252}
253
254fn has_valid_boundary(title: &str, end: usize) -> bool {
264 if end >= title.len() {
265 return true; }
267 let remaining = &title[end..];
271 if let Some(ch) = remaining.chars().next() {
272 if matches!(ch, '-' | '.' | '_' | ' ') {
274 return true;
275 }
276 if ch.is_alphanumeric() {
280 return false;
281 }
282 true
284 } else {
285 true }
287}
288
289fn resolution_after(title: &str, pos: usize) -> bool {
291 if pos >= title.len() {
292 return false;
293 }
294 let rest = &title[pos..];
295 let rest_lower = rest.to_ascii_lowercase();
296 RESOLUTION_TOKENS.iter().any(|&r| rest_lower.contains(r))
297}
298
299fn is_resolution(candidate: &str) -> bool {
301 RESOLUTION_TOKENS
302 .iter()
303 .any(|&r| r.eq_ignore_ascii_case(candidate))
304}
305
306fn context_is_blacklisted(title: &str, candidate: &TrailingCandidate) -> bool {
309 let dash_pos = candidate.dash_pos;
310
311 let mut prefix_start = dash_pos;
313 while prefix_start > 0 && title.as_bytes()[prefix_start - 1].is_ascii_alphanumeric() {
314 prefix_start -= 1;
315 }
316
317 if prefix_start < dash_pos {
318 let full_token = &title[prefix_start..candidate.full_end];
319 for &bl in BLACKLIST_FULL {
320 if bl.eq_ignore_ascii_case(full_token) {
321 return true;
322 }
323 }
324 }
325
326 if !candidate.group.contains('-') {
329 for &bl in BLACKLIST_SINGLE_SEGMENT {
330 if bl.eq_ignore_ascii_case(&candidate.group) {
331 return true;
332 }
333 }
334 }
335
336 static DATE_PATTERN: Lazy<Regex> =
339 Lazy::new(|| Regex::new(r"\d{4}-\d{2}$").expect("DATE_PATTERN"));
340 if DATE_PATTERN.is_match(&title[..candidate.full_end]) {
341 return true;
342 }
343 static FULL_DATE_PATTERN: Lazy<Regex> =
346 Lazy::new(|| Regex::new(r"\d{4}-\d{1,2}-\d{2}$").expect("FULL_DATE_PATTERN"));
347 if FULL_DATE_PATTERN.is_match(&title[..candidate.full_end]) {
348 return true;
349 }
350
351 static TWO_DIGIT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{2}$").expect("TWO_DIGIT"));
354 if TWO_DIGIT.is_match(&candidate.group) {
355 return true;
356 }
357
358 false
359}
360
361static TRAILING_BRACKET_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
363 Regex::new(r"[-._ ]\[(?P<releasegroup>[a-zA-Z0-9]+)\]$").expect("TRAILING_BRACKET_GROUP_REGEX")
364});
365
366static EXCEPTION_RELEASE_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
376 let alts: Vec<String> = EXCEPTION_GROUPS.iter().map(|&g| regex::escape(g)).collect();
377 let pattern = format!(r"(?i)[._ \[](?P<releasegroup>{})[)\]]", alts.join("|"));
378 Regex::new(&pattern).expect("EXCEPTION_RELEASE_GROUP_REGEX")
379});
380
381pub fn parse_release_group(title: &str) -> Option<String> {
393 let title = title.trim();
395 if title.is_empty() {
396 return None;
397 }
398 let mut title = normalize::remove_file_extension(title);
399
400 for (re, replacement) in PRE_SUB_REGEXES.iter() {
402 if re.is_match(&title) {
403 title = re.replace(&title, *replacement).into_owned();
404 break;
405 }
406 }
407
408 title = crate::episode::regexes::WEBSITE_PREFIX_REGEX
410 .replace(&title, "")
411 .into_owned();
412 title = crate::episode::regexes::CLEAN_TORRENT_SUFFIX_REGEX
413 .replace(&title, "")
414 .into_owned();
415
416 if let Some(caps) = ANIME_RELEASE_GROUP_REGEX.captures(&title)
418 && let Some(m) = caps.name("subgroup")
419 {
420 let subgroup = m.as_str().trim();
421 if !subgroup.is_empty() {
422 return Some(subgroup.to_string());
423 }
424 }
425
426 title = CLEAN_RELEASE_GROUP_REGEX.replace(&title, "").into_owned();
428
429 let mut last_exception: Option<String> = None;
432 for caps in EXCEPTION_RELEASE_GROUP_REGEX.captures_iter(&title) {
433 if let Some(m) = caps.name("releasegroup") {
434 last_exception = Some(m.as_str().to_string());
435 }
436 }
437 if let Some(group) = last_exception {
438 return Some(group);
439 }
440
441 let mut last_exact: Option<String> = None;
443 for caps in EXCEPTION_EXACT_REGEX.captures_iter(&title) {
444 if let Some(m) = caps.name("releasegroup") {
445 last_exact = Some(m.as_str().to_string());
446 }
447 }
448 if let Some(group) = last_exact {
449 return Some(group);
450 }
451
452 let candidates = find_trailing_group_candidates(&title);
460
461 for candidate in candidates.iter().rev() {
463 let group = &candidate.group;
464
465 if !has_valid_boundary(&title, candidate.full_end) {
467 continue;
468 }
469
470 if resolution_after(&title, candidate.full_end) {
472 continue;
473 }
474
475 if is_resolution(group) {
477 continue;
478 }
479
480 if context_is_blacklisted(&title, candidate) {
482 continue;
483 }
484
485 if group.parse::<i64>().is_ok() {
487 continue;
488 }
489
490 if INVALID_RELEASE_GROUP_REGEX.is_match(group) {
492 continue;
493 }
494
495 return Some(group.clone());
496 }
497
498 if let Some(caps) = TRAILING_BRACKET_GROUP_REGEX.captures(&title)
500 && let Some(m) = caps.name("releasegroup")
501 {
502 let group = m.as_str();
503 if group.parse::<i64>().is_err() && !INVALID_RELEASE_GROUP_REGEX.is_match(group) {
504 return Some(group.to_string());
505 }
506 }
507
508 None
510}
511
512#[cfg(test)]
517mod unit_tests {
518 use super::*;
519
520 #[test]
521 fn trailing_group_candidate_search() {
522 let cases = find_trailing_group_candidates("Series.S01E01.720p.HDTV.x264-LOL");
523 assert!(
524 cases.iter().any(|c| c.group == "LOL"),
525 "should find LOL, got: {:?}",
526 cases.iter().map(|c| &c.group).collect::<Vec<_>>()
527 );
528
529 let cases2 =
530 find_trailing_group_candidates("SomeShow S01E168 1080p WEB-DL AAC 2.0 x264-Erai-raws");
531 assert!(
532 cases2.iter().any(|c| c.group == "Erai-raws"),
533 "should find Erai-raws with hyphen, got: {:?}",
534 cases2.iter().map(|c| &c.group).collect::<Vec<_>>()
535 );
536 }
537
538 #[test]
539 fn trailing_group_last_match_wins() {
540 let candidates =
541 find_trailing_group_candidates("Series-Title.S01E01.720p-FIRST.HDTV-SECOND");
542 let last = candidates.last().map(|c| c.group.as_str());
543 assert_eq!(last, Some("SECOND"), "last match should win");
544 }
545
546 #[test]
547 fn invalid_release_group_rejection() {
548 assert!(
549 INVALID_RELEASE_GROUP_REGEX.is_match("S01"),
550 "S01 should be invalid"
551 );
552 assert!(
553 INVALID_RELEASE_GROUP_REGEX.is_match("s02"),
554 "s02 should be invalid"
555 );
556 assert!(
557 INVALID_RELEASE_GROUP_REGEX.is_match("E15"),
558 "E15 should be invalid"
559 );
560 assert!(
561 INVALID_RELEASE_GROUP_REGEX.is_match("6B7FD717"),
562 "8-char hex should be invalid"
563 );
564 assert!(
565 INVALID_RELEASE_GROUP_REGEX.is_match("6b7fd717"),
566 "lowercase 8-char hex should be invalid"
567 );
568 assert!(
569 !INVALID_RELEASE_GROUP_REGEX.is_match("LOL"),
570 "LOL should be valid"
571 );
572 }
573
574 #[test]
575 fn blacklist_rejection() {
576 let title = "Series.S01E01.720p.WEB-DL.x264";
577 let candidates = find_trailing_group_candidates(title);
578 let dl_candidate = candidates.iter().find(|c| c.group == "DL");
579 assert!(dl_candidate.is_some(), "should find -DL candidate");
580 assert!(
581 context_is_blacklisted(title, dl_candidate.expect("dl candidate")),
582 "WEB-DL should be context-blacklisted"
583 );
584
585 let title2 = "Series.S01E01.DTS-HD.MA.5.1.x264";
586 let candidates2 = find_trailing_group_candidates(title2);
587 let hd_candidate = candidates2.iter().find(|c| c.group == "HD");
588 assert!(hd_candidate.is_some(), "should find -HD candidate");
589 assert!(
590 context_is_blacklisted(title2, hd_candidate.expect("hd candidate")),
591 "DTS-HD should be context-blacklisted"
592 );
593 }
594
595 #[test]
596 fn resolution_suffix_rejection() {
597 assert!(
600 resolution_after("Series.720p.HDTV.x264-LOL.1080p", 25),
601 "should detect 1080p after position 25"
602 );
603 assert!(
605 !resolution_after("Series.720p.HDTV.x264-LOL", 25),
606 "no resolution after group at end"
607 );
608 assert!(is_resolution("1080p"), "1080p should be a resolution");
609 assert!(is_resolution("720p"), "720p should be a resolution");
610 assert!(!is_resolution("LOL"), "LOL should not be a resolution");
611 }
612}