feat(indexer): tvsearch with season and episode tags (#78)
ci / web (push) Successful in 50s
ci / rust (push) Successful in 1m18s
e2e / e2e (push) Successful in 1m46s

This commit was merged in pull request #78.
This commit is contained in:
2026-08-22 22:34:04 +01:00
parent fc2a8de58d
commit 8d44225b57
11 changed files with 876 additions and 50 deletions
+158
View File
@@ -0,0 +1,158 @@
//! 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`, `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()?;
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)
}
+82
View File
@@ -8,6 +8,7 @@
//! Parsing never fails: a malformed name yields a partial [`NameClaims`],
//! worst case an empty one.
mod episodes;
mod markers;
use serde::{Deserialize, Serialize};
@@ -144,6 +145,82 @@ pub enum Edition {
Limited,
}
/// What a release name claims about which episodes it holds. A season pack
/// and one episode of that season carry the same series title and differ
/// only here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EpisodeClaim {
/// One or more episodes of a single season: `S01E02`, `S01E02E03`,
/// `S01E02-E04`, `1x02`.
Episodes {
season: u32,
/// Claimed episode numbers, in order of appearance. Ranges are
/// expanded.
episodes: Vec<u32>,
},
/// A whole season, no episode number: `S01`, `Season 1`.
Season { season: u32 },
/// Several whole seasons: `S01-S03`, `Seasons 1-3`.
Seasons { first: u32, last: u32 },
/// A daily show identified by its air date: `2024.05.13`.
Daily { year: u16, month: u8, day: u8 },
}
impl EpisodeClaim {
/// Whether the release claims a whole season or more, rather than
/// named episodes.
#[must_use]
pub fn is_season_pack(&self) -> bool {
matches!(self, Self::Season { .. } | Self::Seasons { .. })
}
/// Whether the release claims several episodes in one file.
#[must_use]
pub fn is_multi_episode(&self) -> bool {
matches!(self, Self::Episodes { episodes, .. } if episodes.len() > 1)
}
/// Whether the claim covers one numbered episode.
#[must_use]
pub fn covers(&self, season: u32, episode: u32) -> bool {
match self {
Self::Episodes {
season: claimed,
episodes,
} => *claimed == season && episodes.contains(&episode),
Self::Season { .. } | Self::Seasons { .. } => self.covers_season(season),
Self::Daily { .. } => false,
}
}
/// Whether the claim covers any part of one season.
#[must_use]
pub fn covers_season(&self, season: u32) -> bool {
match self {
Self::Episodes {
season: claimed, ..
}
| Self::Season { season: claimed } => *claimed == season,
Self::Seasons { first, last } => (*first..=*last).contains(&season),
Self::Daily { .. } => false,
}
}
/// Whether the claim is exactly this air date.
#[must_use]
pub fn covers_date(&self, year: u16, month: u8, day: u8) -> bool {
matches!(
self,
Self::Daily {
year: claimed_year,
month: claimed_month,
day: claimed_day,
} if *claimed_year == year && *claimed_month == month && *claimed_day == day
)
}
}
/// What a release name claims about itself. Every field is unverified
/// (`DESIGN.md` §5.6); absence of a marker means nothing.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -167,6 +244,9 @@ pub struct NameClaims {
pub editions: Vec<Edition>,
/// Claimed release group.
pub group: Option<String>,
/// Claimed season, episodes or air date. `None` for anything that does
/// not carry a TV tag.
pub episode: Option<EpisodeClaim>,
}
#[derive(Debug, Clone)]
@@ -205,11 +285,13 @@ pub fn parse(name: &str) -> NameClaims {
.copied();
let boundary = year_idx.map_or(limit, |y| y.min(limit));
let lows: Vec<&str> = toks.iter().map(|t| t.low.as_str()).collect();
let mut claims = NameClaims {
year: year_idx
.and_then(|i| toks.get(i))
.and_then(|t| markers::year_of(&t.low)),
group: dash_group.or(bracket_group),
episode: episodes::claim(&lows),
..NameClaims::default()
};
+3 -30
View File
@@ -159,37 +159,10 @@ fn is_audio_family(t: &str) -> bool {
})
}
/// `S01`, `S01E02`, `S01E01E02`, `S01-E02`, `1x02`.
/// `S01`, `S01E02`, `S01E01E02`, `S01-E02`, `1x02`, `S01-S03`, an air
/// date. Anything that ends the title region on a TV tag.
pub(crate) fn is_season_episode(t: &str) -> bool {
fn digits(s: &str) -> (usize, &str) {
let n = s.bytes().take_while(u8::is_ascii_digit).count();
(n, &s[n..])
}
if let Some(rest) = t.strip_prefix('s') {
let (n, mut rest) = digits(rest);
if !(1..=2).contains(&n) {
return false;
}
while !rest.is_empty() {
let Some(r) = rest.strip_prefix('e').or_else(|| rest.strip_prefix("-e")) else {
return false;
};
let (m, r2) = digits(r);
if !(1..=3).contains(&m) {
return false;
}
rest = r2;
}
return true;
}
let (n, rest) = digits(t);
if (1..=2).contains(&n) {
if let Some(r) = rest.strip_prefix('x') {
let (m, r2) = digits(r);
return (2..=3).contains(&m) && r2.is_empty();
}
}
false
crate::episodes::tag_of(t).is_some()
}
/// A plausible release year: four digits, 1900–2099.