avatarr-parser 0.1.0

Release-name parser ported from Sonarr v4.0.17.2952
Documentation
// Ported from Sonarr v4.0.17.2952 (97e85a90):
//   src/NzbDrone.Core/Parser/Parser.cs — pre-processing pipeline
//
// Shared by all parsers (episode, daily, anime). Each function documents
// which Parser.cs line(s) it corresponds to.

use crate::episode::regexes;

/// Parser.cs:1262-1290 — reject junk titles before the regex cascade.
pub fn validate_before_parsing(title: &str) -> bool {
    if title.is_empty() {
        return false;
    }

    let lower = title.to_ascii_lowercase();
    if lower.contains("password") && lower.contains("yenc") {
        return false;
    }

    if !title.chars().any(|c| c.is_alphanumeric()) {
        return false;
    }

    let without_ext = remove_file_extension(title);

    for re in regexes::REJECT_HASHED_REGEXES.iter() {
        if re.is_match(&without_ext) {
            return false;
        }
    }

    for re in regexes::SEASON_FOLDER_REGEXES.iter() {
        if re.is_match(&without_ext) {
            return false;
        }
    }

    true
}

/// Parser.cs:968-982 — strip media file extensions.
pub fn remove_file_extension(title: &str) -> String {
    const MEDIA_EXTENSIONS: &[&str] = &[
        ".3g2", ".3gp", ".3gp2", ".asf", ".avi", ".divx", ".flv", ".m4v", ".mk3d", ".mka", ".mkv",
        ".mov", ".mp4", ".mpa", ".mpeg", ".mpg", ".ogg", ".ogm", ".ogv", ".qt", ".rm", ".rmvb",
        ".tp", ".ts", ".vob", ".w64", ".wav", ".webm", ".wmv", ".srt", ".sub", ".idx", ".ssa",
        ".ass", ".nfo", ".txt", ".par2", ".nzb",
    ];

    if let Some(m) = regexes::FILE_EXTENSION_REGEX.find(title) {
        let ext = &title[m.start()..];
        if MEDIA_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
            return title[..m.start()].to_string();
        }
    }
    title.to_string()
}

/// Parser.cs:716-718 — full pre-processing before the regex cascade.
pub fn preprocess_title(title: &str) -> Option<String> {
    if !validate_before_parsing(title) {
        return None;
    }

    let mut release_title = if regexes::REVERSED_TITLE_REGEX.is_match(title) {
        let without_ext = remove_file_extension(title);
        without_ext.chars().rev().collect::<String>()
    } else {
        remove_file_extension(title)
    };

    // Strip C# verbatim string artifact: @"title" → title
    if release_title.starts_with("@\"") {
        release_title = release_title.strip_prefix("@\"").unwrap().to_string();
        if release_title.ends_with('"') {
            release_title.pop();
        }
    }

    // CJK fullwidth bracket normalization
    release_title = release_title.replace('', "[").replace('', "]");

    // PreSubstitution[11]: Spanish releases (the only one in m52 scope)
    if let Some(caps) = regexes::PRE_SUB_SPANISH_REGEX.captures(&release_title)
        && let (Some(t), Some(y), Some(info)) =
            (caps.name("title"), caps.name("year"), caps.name("info"))
    {
        release_title = format!("{} ({}) - {} ", t.as_str(), y.as_str(), info.as_str());
    }

    // Strip resolution/codec tokens
    let simple_title = regexes::SIMPLE_TITLE_REGEX
        .replace_all(&release_title, "")
        .to_string();

    // Strip website prefix/postfix
    // C# has `(?<!Naruto-Kun\.)` lookbehind — skip stripping when the
    // matched domain starts with a known anime subgroup name (false
    // positive: `[Naruto-Kun.Hu]` is a subgroup, not a website).
    let simple_title = if let Some(m) = regexes::WEBSITE_PREFIX_REGEX.find(&simple_title) {
        let matched = &simple_title[m.start()..m.end()];
        let lower = matched.to_ascii_lowercase();
        if lower.contains("naruto-kun.") {
            simple_title
        } else {
            regexes::WEBSITE_PREFIX_REGEX
                .replace(&simple_title, "")
                .to_string()
        }
    } else {
        simple_title
    };
    let simple_title = regexes::WEBSITE_POSTFIX_REGEX
        .replace(&simple_title, "")
        .to_string();

    // Strip torrent tracker tags
    let simple_title = regexes::CLEAN_TORRENT_SUFFIX_REGEX
        .replace(&simple_title, "")
        .to_string();

    // Strip empty brackets left by resolution/codec stripping (e.g. [720p] → [])
    let simple_title = simple_title.replace("[]", "").replace("()", "");
    let simple_title = simple_title.trim().to_string();

    // Strip quality brackets — only if they look like quality info
    let simple_title = if let Some(m) = regexes::CLEAN_QUALITY_BRACKETS_REGEX.find(&simple_title) {
        let bracket_content = &simple_title[m.start()..];
        let quality = crate::parse_quality_name(bracket_content);
        if quality.quality != crate::Quality::Unknown {
            simple_title[..m.start()].to_string()
        } else {
            simple_title
        }
    } else {
        simple_title
    };

    // Expand 6-digit air dates (e.g. _210415_ → _2021.04.15_)
    let simple_title = expand_six_digit_airdate(&simple_title);

    Some(simple_title)
}

fn expand_six_digit_airdate(title: &str) -> String {
    if let Some(caps) = regexes::SIX_DIGIT_AIR_DATE_REGEX.captures(title)
        && let (Some(prefix), Some(year), Some(month), Some(day), Some(suffix)) = (
            caps.name("prefix"),
            caps.name("airyear"),
            caps.name("airmonth"),
            caps.name("airday"),
            caps.name("suffix"),
        )
    {
        let full_year = format!("20{}", year.as_str());
        let replacement = format!(
            "{}{}.{}.{}{}",
            prefix.as_str(),
            full_year,
            month.as_str(),
            day.as_str(),
            suffix.as_str()
        );
        return format!(
            "{}{}{}",
            &title[..caps.get(0).unwrap().start()],
            replacement,
            &title[caps.get(0).unwrap().end()..]
        );
    }
    title.to_string()
}

/// Check that a match at `[start..end)` is not adjacent to digits.
/// This replaces C# `(?<!\d+)` and `(?!\d+)` lookarounds.
// SAFETY: regex match offsets are always UTF-8 aligned; ASCII digit check never hits continuation bytes.
pub fn digit_boundary_ok(input: &str, start: usize, end: usize) -> bool {
    let bytes = input.as_bytes();
    let no_before = start == 0 || !bytes[start - 1].is_ascii_digit();
    let no_after = end >= bytes.len() || !bytes[end].is_ascii_digit();
    no_before && no_after
}

/// Clean a series title: replace dots/underscores with spaces, trim.
/// Mirrors Sonarr's TrimEnd('-') + TrimEnd(' ') post-processing.
pub fn clean_series_title(raw: &str) -> String {
    let mut title = regexes::REQUEST_INFO_REGEX.replace(raw, "").to_string();
    title = title.replace(['.', '_'], " ");
    title = title.trim().trim_end_matches(['-', '/']).trim().to_string();
    close_trailing_year_paren(&mut title);
    title
}

/// Append `)` when the title ends with `(YYYY` — an unbalanced year paren
/// caused by slash-separated release formats like `Title (2024/S01E07/...)`.
fn close_trailing_year_paren(title: &mut String) {
    let mut rchars = title.chars().rev();
    let last4: Vec<char> = rchars.by_ref().take(4).collect();
    if last4.len() == 4 && last4.iter().all(|c| c.is_ascii_digit()) && rchars.next() == Some('(') {
        title.push(')');
    }
}