use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpisodeInfo {
pub season: Option<u32>,
pub episode: Option<u32>,
pub part: Option<u32>,
pub part_count: Option<u32>,
}
pub fn parse_episode_number(value: &str, system: &str) -> Option<EpisodeInfo> {
match system {
"xmltv_ns" => parse_xmltv_ns(value),
"onscreen" => parse_onscreen(value),
_ => None,
}
}
pub fn parse_xmltv_ns(value: &str) -> Option<EpisodeInfo> {
let trimmed = value.trim();
let first_dot = trimmed.find('.')?;
let season_str = &trimmed[..first_dot];
let remainder = &trimmed[first_dot + 1..];
let (episode_str, part_str) = match remainder.find('.') {
Some(pos) => (&remainder[..pos], &remainder[pos + 1..]),
None => (remainder, ""),
};
let season = parse_int_and_increment(season_str);
let episode = parse_int_and_increment(episode_str);
let (part, part_count) = if !part_str.is_empty() {
parse_part_number(part_str)
} else {
(None, None)
};
if season.is_some() || episode.is_some() || part.is_some() {
Some(EpisodeInfo {
season,
episode,
part,
part_count,
})
} else {
None
}
}
pub fn parse_onscreen(value: &str) -> Option<EpisodeInfo> {
static UNWANTED_CHARS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[ \txX_\.]").expect("invalid regex"));
static SEASON_EPISODE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[sS]([0-9]+)[eE][pP]?([0-9]+)$").expect("invalid regex"));
static EPISODE_ONLY: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[eE][pP]?([0-9]+)$").expect("invalid regex"));
let cleaned = UNWANTED_CHARS.replace_all(value, "");
if cleaned.is_empty() {
return None;
}
let first_char = cleaned.as_bytes()[0];
if first_char == b'S' || first_char == b's' {
if let Some(caps) = SEASON_EPISODE.captures(&cleaned) {
let season: u32 = caps[1].parse().ok()?;
let episode: u32 = caps[2].parse().ok()?;
return Some(EpisodeInfo {
season: Some(season),
episode: Some(episode),
part: None,
part_count: None,
});
}
} else if first_char == b'E' || first_char == b'e' {
if let Some(caps) = EPISODE_ONLY.captures(&cleaned) {
let episode: u32 = caps[1].parse().ok()?;
return Some(EpisodeInfo {
season: None,
episode: Some(episode),
part: None,
part_count: None,
});
}
}
None
}
fn parse_int_and_increment(s: &str) -> Option<u32> {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
let n: i32 = trimmed.parse().ok()?;
if n < 0 {
return None;
}
u32::try_from(n + 1).ok()
}
fn parse_part_number(s: &str) -> (Option<u32>, Option<u32>) {
let trimmed = s.trim();
if trimmed.is_empty() {
return (None, None);
}
if let Some(slash_pos) = trimmed.find('/') {
let num_str = &trimmed[..slash_pos];
let den_str = &trimmed[slash_pos + 1..];
if let (Ok(num), Ok(den)) = (num_str.trim().parse::<i32>(), den_str.trim().parse::<i32>())
&& num >= 0
&& den > 0
{
return (u32::try_from(num + 1).ok(), u32::try_from(den).ok());
}
}
(None, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xmltv_ns_full_format() {
let info = parse_xmltv_ns("0.4.2/3").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
assert_eq!(info.part, Some(3));
assert_eq!(info.part_count, Some(3));
}
#[test]
fn xmltv_ns_season_episode_only() {
let info = parse_xmltv_ns("1.0.").unwrap();
assert_eq!(info.season, Some(2));
assert_eq!(info.episode, Some(1));
assert_eq!(info.part, None);
assert_eq!(info.part_count, None);
}
#[test]
fn xmltv_ns_no_season() {
let info = parse_xmltv_ns(".0.").unwrap();
assert_eq!(info.season, None);
assert_eq!(info.episode, Some(1));
assert_eq!(info.part, None);
}
#[test]
fn xmltv_ns_large_numbers() {
let info = parse_xmltv_ns("2.5.").unwrap();
assert_eq!(info.season, Some(3));
assert_eq!(info.episode, Some(6));
}
#[test]
fn xmltv_ns_no_dot_returns_none() {
assert!(parse_xmltv_ns("123").is_none());
}
#[test]
fn xmltv_ns_empty_returns_none() {
assert!(parse_xmltv_ns("").is_none());
assert!(parse_xmltv_ns(" ").is_none());
}
#[test]
fn xmltv_ns_all_empty_parts_returns_none() {
assert!(parse_xmltv_ns("..").is_none());
}
#[test]
fn xmltv_ns_two_parts_without_trailing_dot() {
let info = parse_xmltv_ns("0.4").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
assert_eq!(info.part, None);
}
#[test]
fn xmltv_ns_part_without_denominator_is_none() {
let info = parse_xmltv_ns("0.4.2").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
assert_eq!(info.part, None);
assert_eq!(info.part_count, None);
}
#[test]
fn onscreen_season_episode() {
let info = parse_onscreen("S01E05").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
}
#[test]
fn onscreen_episode_only() {
let info = parse_onscreen("E12").unwrap();
assert_eq!(info.season, None);
assert_eq!(info.episode, Some(12));
}
#[test]
fn onscreen_episode_with_ep_prefix() {
let info = parse_onscreen("EP07").unwrap();
assert_eq!(info.season, None);
assert_eq!(info.episode, Some(7));
}
#[test]
fn onscreen_with_spaces() {
let info = parse_onscreen("S 01 E 05").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
}
#[test]
fn onscreen_with_dots_and_underscores() {
let info = parse_onscreen("S.01.E.05").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
}
#[test]
fn onscreen_with_x_separator() {
let info = parse_onscreen("S01xE05").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
}
#[test]
fn onscreen_lowercase() {
let info = parse_onscreen("s02e10").unwrap();
assert_eq!(info.season, Some(2));
assert_eq!(info.episode, Some(10));
}
#[test]
fn onscreen_invalid_format_returns_none() {
assert!(parse_onscreen("Season 1 Episode 5").is_none());
assert!(parse_onscreen("12345").is_none());
assert!(parse_onscreen("").is_none());
}
#[test]
fn parse_episode_number_dispatches_xmltv_ns() {
let info = parse_episode_number("0.4.2/3", "xmltv_ns").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
}
#[test]
fn parse_episode_number_dispatches_onscreen() {
let info = parse_episode_number("S01E05", "onscreen").unwrap();
assert_eq!(info.season, Some(1));
assert_eq!(info.episode, Some(5));
}
#[test]
fn parse_episode_number_unknown_system() {
assert!(parse_episode_number("foo", "unknown").is_none());
}
}