8d79c7c378
COMPLETE before the season tag was weak junk, so it stayed in the title and exact-title matching failed silently (#135). It now closes the boundary wherever it lands. S01-S01 produced no claim because the dash-range branch required last > season; a degenerate range is one season's pack, matching the rule match_episode lifted.
160 lines
5.2 KiB
Rust
160 lines
5.2 KiB
Rust
//! Season and episode tags in release names.
|
|
//!
|
|
//! A pack and a single episode carry the same series title and differ only
|
|
//! here, so the distinction is a parse result rather than a downstream guess.
|
|
|
|
use crate::EpisodeClaim;
|
|
|
|
/// Longest episode range expanded from a tag like `S01E01-E12`. Anything
|
|
/// wider is a malformed tag rather than a real multi-episode file.
|
|
const MAX_RANGE: u32 = 100;
|
|
|
|
/// The first season, episode or air-date tag in a tokenised release name.
|
|
pub(crate) fn claim(tokens: &[&str]) -> Option<EpisodeClaim> {
|
|
for (index, token) in tokens.iter().enumerate() {
|
|
if let Some(claim) = tag_of(token) {
|
|
return Some(claim);
|
|
}
|
|
if matches!(*token, "season" | "seasons") {
|
|
if let Some(claim) = tokens.get(index + 1).and_then(|next| season_words(next)) {
|
|
return Some(claim);
|
|
}
|
|
}
|
|
if let (Some(month), Some(day)) = (tokens.get(index + 1), tokens.get(index + 2)) {
|
|
if let Some(claim) = date(token, month, day) {
|
|
return Some(claim);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// A season, episode or air-date claim held in a single token.
|
|
pub(crate) fn tag_of(token: &str) -> Option<EpisodeClaim> {
|
|
season_tag(token)
|
|
.or_else(|| cross_tag(token))
|
|
.or_else(|| date_tag(token))
|
|
}
|
|
|
|
/// `S01`, `S01-S03`, `S01-S01`, `S01E02`, `S01E02E03`, `S01E02-E04`.
|
|
fn season_tag(token: &str) -> Option<EpisodeClaim> {
|
|
let rest = token.strip_prefix('s')?;
|
|
let (season, mut rest) = digits(rest);
|
|
if !(1..=2).contains(&season.len()) {
|
|
return None;
|
|
}
|
|
let season: u32 = season.parse().ok()?;
|
|
|
|
if rest.is_empty() {
|
|
return Some(EpisodeClaim::Season { season });
|
|
}
|
|
|
|
if let Some(range) = rest.strip_prefix("-s") {
|
|
let (last, tail) = digits(range);
|
|
if !tail.is_empty() || !(1..=2).contains(&last.len()) {
|
|
return None;
|
|
}
|
|
let last: u32 = last.parse().ok()?;
|
|
// A degenerate range (`S01-S01`) is one season's pack, not nothing.
|
|
return (last >= season).then_some(EpisodeClaim::Seasons {
|
|
first: season,
|
|
last,
|
|
});
|
|
}
|
|
|
|
let mut episodes: Vec<u32> = Vec::new();
|
|
while !rest.is_empty() {
|
|
let (ranged, body) = if let Some(body) = rest.strip_prefix('e') {
|
|
(false, body)
|
|
} else {
|
|
(
|
|
true,
|
|
rest.strip_prefix("-e").or_else(|| rest.strip_prefix('-'))?,
|
|
)
|
|
};
|
|
let (number, tail) = digits(body);
|
|
if !(1..=3).contains(&number.len()) {
|
|
return None;
|
|
}
|
|
let number: u32 = number.parse().ok()?;
|
|
match episodes.last().copied() {
|
|
Some(previous) if ranged => {
|
|
if number <= previous || number - previous > MAX_RANGE {
|
|
return None;
|
|
}
|
|
episodes.extend(previous.saturating_add(1)..=number);
|
|
}
|
|
_ => {
|
|
if !episodes.contains(&number) {
|
|
episodes.push(number);
|
|
}
|
|
}
|
|
}
|
|
rest = tail;
|
|
}
|
|
|
|
(!episodes.is_empty()).then_some(EpisodeClaim::Episodes { season, episodes })
|
|
}
|
|
|
|
/// `1x02`, the alternative single-episode form.
|
|
fn cross_tag(token: &str) -> Option<EpisodeClaim> {
|
|
let (season, rest) = digits(token);
|
|
if !(1..=2).contains(&season.len()) {
|
|
return None;
|
|
}
|
|
let (episode, tail) = digits(rest.strip_prefix('x')?);
|
|
if !tail.is_empty() || !(2..=3).contains(&episode.len()) {
|
|
return None;
|
|
}
|
|
Some(EpisodeClaim::Episodes {
|
|
season: season.parse().ok()?,
|
|
episodes: vec![episode.parse().ok()?],
|
|
})
|
|
}
|
|
|
|
/// A hyphenated air date, `2024-05-13`.
|
|
fn date_tag(token: &str) -> Option<EpisodeClaim> {
|
|
let mut parts = token.split('-');
|
|
let (year, month, day) = (parts.next()?, parts.next()?, parts.next()?);
|
|
if parts.next().is_some() {
|
|
return None;
|
|
}
|
|
date(year, month, day)
|
|
}
|
|
|
|
/// An air date split across three tokens, `2024.05.13`.
|
|
fn date(year: &str, month: &str, day: &str) -> Option<EpisodeClaim> {
|
|
if year.len() != 4 || month.len() != 2 || day.len() != 2 {
|
|
return None;
|
|
}
|
|
let year: u16 = year.parse().ok()?;
|
|
let month: u8 = month.parse().ok()?;
|
|
let day: u8 = day.parse().ok()?;
|
|
((1900..=2999).contains(&year) && (1..=12).contains(&month) && (1..=31).contains(&day))
|
|
.then_some(EpisodeClaim::Daily { year, month, day })
|
|
}
|
|
|
|
/// The number after a spelled-out `Season`: `Season 1`, `Seasons 1-3`.
|
|
fn season_words(token: &str) -> Option<EpisodeClaim> {
|
|
let (first, rest) = digits(token);
|
|
if !(1..=2).contains(&first.len()) {
|
|
return None;
|
|
}
|
|
let first: u32 = first.parse().ok()?;
|
|
if rest.is_empty() {
|
|
return Some(EpisodeClaim::Season { season: first });
|
|
}
|
|
let (last, tail) = digits(rest.strip_prefix('-')?);
|
|
if !tail.is_empty() || !(1..=2).contains(&last.len()) {
|
|
return None;
|
|
}
|
|
let last: u32 = last.parse().ok()?;
|
|
(last >= first).then_some(EpisodeClaim::Seasons { first, last })
|
|
}
|
|
|
|
/// Splits a leading run of ASCII digits off a token.
|
|
fn digits(token: &str) -> (&str, &str) {
|
|
let count = token.bytes().take_while(u8::is_ascii_digit).count();
|
|
token.split_at(count)
|
|
}
|