Skip to main content

avatarr_parser/
normalize.rs

1// Ported from Sonarr v4.0.17.2952 (97e85a90):
2//   src/NzbDrone.Core/Parser/Parser.cs — pre-processing pipeline
3//
4// Shared by all parsers (episode, daily, anime). Each function documents
5// which Parser.cs line(s) it corresponds to.
6
7use crate::episode::regexes;
8
9/// Parser.cs:1262-1290 — reject junk titles before the regex cascade.
10pub fn validate_before_parsing(title: &str) -> bool {
11    if title.is_empty() {
12        return false;
13    }
14
15    let lower = title.to_ascii_lowercase();
16    if lower.contains("password") && lower.contains("yenc") {
17        return false;
18    }
19
20    if !title.chars().any(|c| c.is_alphanumeric()) {
21        return false;
22    }
23
24    let without_ext = remove_file_extension(title);
25
26    for re in regexes::REJECT_HASHED_REGEXES.iter() {
27        if re.is_match(&without_ext) {
28            return false;
29        }
30    }
31
32    for re in regexes::SEASON_FOLDER_REGEXES.iter() {
33        if re.is_match(&without_ext) {
34            return false;
35        }
36    }
37
38    true
39}
40
41/// Parser.cs:968-982 — strip media file extensions.
42pub fn remove_file_extension(title: &str) -> String {
43    const MEDIA_EXTENSIONS: &[&str] = &[
44        ".3g2", ".3gp", ".3gp2", ".asf", ".avi", ".divx", ".flv", ".m4v", ".mk3d", ".mka", ".mkv",
45        ".mov", ".mp4", ".mpa", ".mpeg", ".mpg", ".ogg", ".ogm", ".ogv", ".qt", ".rm", ".rmvb",
46        ".tp", ".ts", ".vob", ".w64", ".wav", ".webm", ".wmv", ".srt", ".sub", ".idx", ".ssa",
47        ".ass", ".nfo", ".txt", ".par2", ".nzb",
48    ];
49
50    if let Some(m) = regexes::FILE_EXTENSION_REGEX.find(title) {
51        let ext = &title[m.start()..];
52        if MEDIA_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
53            return title[..m.start()].to_string();
54        }
55    }
56    title.to_string()
57}
58
59/// Parser.cs:716-718 — full pre-processing before the regex cascade.
60pub fn preprocess_title(title: &str) -> Option<String> {
61    if !validate_before_parsing(title) {
62        return None;
63    }
64
65    let mut release_title = if regexes::REVERSED_TITLE_REGEX.is_match(title) {
66        let without_ext = remove_file_extension(title);
67        without_ext.chars().rev().collect::<String>()
68    } else {
69        remove_file_extension(title)
70    };
71
72    // Strip C# verbatim string artifact: @"title" → title
73    if release_title.starts_with("@\"") {
74        release_title = release_title.strip_prefix("@\"").unwrap().to_string();
75        if release_title.ends_with('"') {
76            release_title.pop();
77        }
78    }
79
80    // CJK fullwidth bracket normalization
81    release_title = release_title.replace('【', "[").replace('】', "]");
82
83    // PreSubstitution[11]: Spanish releases (the only one in m52 scope)
84    if let Some(caps) = regexes::PRE_SUB_SPANISH_REGEX.captures(&release_title)
85        && let (Some(t), Some(y), Some(info)) =
86            (caps.name("title"), caps.name("year"), caps.name("info"))
87    {
88        release_title = format!("{} ({}) - {} ", t.as_str(), y.as_str(), info.as_str());
89    }
90
91    // Strip resolution/codec tokens
92    let simple_title = regexes::SIMPLE_TITLE_REGEX
93        .replace_all(&release_title, "")
94        .to_string();
95
96    // Strip website prefix/postfix
97    // C# has `(?<!Naruto-Kun\.)` lookbehind — skip stripping when the
98    // matched domain starts with a known anime subgroup name (false
99    // positive: `[Naruto-Kun.Hu]` is a subgroup, not a website).
100    let simple_title = if let Some(m) = regexes::WEBSITE_PREFIX_REGEX.find(&simple_title) {
101        let matched = &simple_title[m.start()..m.end()];
102        let lower = matched.to_ascii_lowercase();
103        if lower.contains("naruto-kun.") {
104            simple_title
105        } else {
106            regexes::WEBSITE_PREFIX_REGEX
107                .replace(&simple_title, "")
108                .to_string()
109        }
110    } else {
111        simple_title
112    };
113    let simple_title = regexes::WEBSITE_POSTFIX_REGEX
114        .replace(&simple_title, "")
115        .to_string();
116
117    // Strip torrent tracker tags
118    let simple_title = regexes::CLEAN_TORRENT_SUFFIX_REGEX
119        .replace(&simple_title, "")
120        .to_string();
121
122    // Strip empty brackets left by resolution/codec stripping (e.g. [720p] → [])
123    let simple_title = simple_title.replace("[]", "").replace("()", "");
124    let simple_title = simple_title.trim().to_string();
125
126    // Strip quality brackets — only if they look like quality info
127    let simple_title = if let Some(m) = regexes::CLEAN_QUALITY_BRACKETS_REGEX.find(&simple_title) {
128        let bracket_content = &simple_title[m.start()..];
129        let quality = crate::parse_quality_name(bracket_content);
130        if quality.quality != crate::Quality::Unknown {
131            simple_title[..m.start()].to_string()
132        } else {
133            simple_title
134        }
135    } else {
136        simple_title
137    };
138
139    // Expand 6-digit air dates (e.g. _210415_ → _2021.04.15_)
140    let simple_title = expand_six_digit_airdate(&simple_title);
141
142    Some(simple_title)
143}
144
145fn expand_six_digit_airdate(title: &str) -> String {
146    if let Some(caps) = regexes::SIX_DIGIT_AIR_DATE_REGEX.captures(title)
147        && let (Some(prefix), Some(year), Some(month), Some(day), Some(suffix)) = (
148            caps.name("prefix"),
149            caps.name("airyear"),
150            caps.name("airmonth"),
151            caps.name("airday"),
152            caps.name("suffix"),
153        )
154    {
155        let full_year = format!("20{}", year.as_str());
156        let replacement = format!(
157            "{}{}.{}.{}{}",
158            prefix.as_str(),
159            full_year,
160            month.as_str(),
161            day.as_str(),
162            suffix.as_str()
163        );
164        return format!(
165            "{}{}{}",
166            &title[..caps.get(0).unwrap().start()],
167            replacement,
168            &title[caps.get(0).unwrap().end()..]
169        );
170    }
171    title.to_string()
172}
173
174/// Check that a match at `[start..end)` is not adjacent to digits.
175/// This replaces C# `(?<!\d+)` and `(?!\d+)` lookarounds.
176// SAFETY: regex match offsets are always UTF-8 aligned; ASCII digit check never hits continuation bytes.
177pub fn digit_boundary_ok(input: &str, start: usize, end: usize) -> bool {
178    let bytes = input.as_bytes();
179    let no_before = start == 0 || !bytes[start - 1].is_ascii_digit();
180    let no_after = end >= bytes.len() || !bytes[end].is_ascii_digit();
181    no_before && no_after
182}
183
184/// Clean a series title: replace dots/underscores with spaces, trim.
185/// Mirrors Sonarr's TrimEnd('-') + TrimEnd(' ') post-processing.
186pub fn clean_series_title(raw: &str) -> String {
187    let mut title = regexes::REQUEST_INFO_REGEX.replace(raw, "").to_string();
188    title = title.replace(['.', '_'], " ");
189    title = title.trim().trim_end_matches(['-', '/']).trim().to_string();
190    close_trailing_year_paren(&mut title);
191    title
192}
193
194/// Append `)` when the title ends with `(YYYY` — an unbalanced year paren
195/// caused by slash-separated release formats like `Title (2024/S01E07/...)`.
196fn close_trailing_year_paren(title: &mut String) {
197    let mut rchars = title.chars().rev();
198    let last4: Vec<char> = rchars.by_ref().take(4).collect();
199    if last4.len() == 4 && last4.iter().all(|c| c.is_ascii_digit()) && rchars.next() == Some('(') {
200        title.push(')');
201    }
202}