diff --git a/Cargo.lock b/Cargo.lock index 17849bf..71f45be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,7 @@ dependencies = [ name = "arr-indexer" version = "0.1.0" dependencies = [ + "arr-parse", "chrono", "quick-xml", "reqwest", diff --git a/crates/arr-indexer/Cargo.toml b/crates/arr-indexer/Cargo.toml index 7503622..608d636 100644 --- a/crates/arr-indexer/Cargo.toml +++ b/crates/arr-indexer/Cargo.toml @@ -15,6 +15,7 @@ thiserror.workspace = true tokio.workspace = true [dev-dependencies] +arr-parse.workspace = true wiremock.workspace = true [lints] diff --git a/crates/arr-indexer/src/lib.rs b/crates/arr-indexer/src/lib.rs index 3c6614d..49981f4 100644 --- a/crates/arr-indexer/src/lib.rs +++ b/crates/arr-indexer/src/lib.rs @@ -2,7 +2,7 @@ mod search; -pub use search::{IndexerSearch, SearchError, SearchRelease, SearchRequest}; +pub use search::{IndexerSearch, SearchError, SearchRelease, SearchRequest, TvSelector, TvTarget}; use std::{collections::BTreeSet, time::Duration}; @@ -289,7 +289,10 @@ fn is_available(value: &str) -> bool { #[cfg(test)] mod tests { - use super::{Capabilities, CapabilityError, ProwlarrClient}; + use super::{ + parse_capabilities, Capabilities, CapabilityError, ProwlarrClient, SearchRequest, + TvSelector, TvTarget, + }; use reqwest::StatusCode; use wiremock::{ matchers::{header, method, path, query_param}, @@ -366,6 +369,108 @@ mod tests { ); } + #[test] + fn tv_requests_degrade_to_what_each_indexer_accepts() { + let alpha = capabilities(include_str!("../tests/fixtures/alpha-caps.xml")); + let season_only = capabilities(include_str!("../tests/fixtures/season-only-caps.xml")); + let text_only = capabilities(include_str!("../tests/fixtures/text-only-caps.xml")); + + let episode = TvTarget { + tvdb_id: Some(371_980), + title: "Example Show".to_owned(), + selector: TvSelector::Episode { + season: 2, + episode: 3, + }, + }; + assert_eq!( + alpha.tv_request(&episode), + Some(SearchRequest::Tv { + tvdb_id: 371_980, + selector: TvSelector::Episode { + season: 2, + episode: 3, + }, + }) + ); + assert_eq!( + season_only.tv_request(&episode), + Some(SearchRequest::Tv { + tvdb_id: 371_980, + selector: TvSelector::Season { season: 2 }, + }), + "an indexer without ep gets the season and filters locally" + ); + assert_eq!( + text_only.tv_request(&episode), + Some(SearchRequest::Text { + query: "Example Show S02E03".to_owned(), + }) + ); + assert_eq!(Capabilities::default().tv_request(&episode), None); + + let untracked = TvTarget { + tvdb_id: None, + ..episode.clone() + }; + assert_eq!( + alpha.tv_request(&untracked), + Some(SearchRequest::Text { + query: "Example Show S02E03".to_owned(), + }), + "no TVDB ID means text search even where tvdbid is supported" + ); + + let daily = TvTarget { + selector: TvSelector::Daily { + year: 2026, + month: 8, + day: 21, + }, + ..episode.clone() + }; + assert_eq!( + alpha.tv_request(&daily), + Some(SearchRequest::Tv { + tvdb_id: 371_980, + selector: TvSelector::Daily { + year: 2026, + month: 8, + day: 21, + }, + }) + ); + assert_eq!( + season_only.tv_request(&daily), + Some(SearchRequest::Tv { + tvdb_id: 371_980, + selector: TvSelector::Series, + }), + "an air date is not a season number, so it widens to the series" + ); + assert_eq!( + text_only.tv_request(&daily), + Some(SearchRequest::Text { + query: "Example Show 2026 08 21".to_owned(), + }) + ); + + let season = TvTarget { + selector: TvSelector::Season { season: 2 }, + ..episode + }; + assert_eq!( + text_only.tv_request(&season), + Some(SearchRequest::Text { + query: "Example Show S02".to_owned(), + }) + ); + } + + fn capabilities(body: &str) -> Capabilities { + parse_capabilities(body).expect("fixture is valid Torznab caps XML") + } + async fn mount_capabilities(server: &MockServer, id: i64, body: &str) { Mock::given(method("GET")) .and(path(format!("/{id}/api"))) diff --git a/crates/arr-indexer/src/search.rs b/crates/arr-indexer/src/search.rs index e8e6d6a..3ba9ae9 100644 --- a/crates/arr-indexer/src/search.rs +++ b/crates/arr-indexer/src/search.rs @@ -10,7 +10,7 @@ use reqwest::StatusCode; use thiserror::Error; use tokio::task::JoinSet; -use crate::ProwlarrClient; +use crate::{Capabilities, ProwlarrClient}; /// One operation against an indexer's Torznab endpoint. #[derive(Clone, Debug, Eq, PartialEq)] @@ -23,13 +23,42 @@ pub enum SearchRequest { Movie { imdb_id: String, }, + /// A `t=tvsearch` lookup by TVDB ID, narrowed by [`TvSelector`]. Tv { tvdb_id: u64, - season: Option, - episode: Option, + selector: TvSelector, }, } +/// Which part of a series a TV search asks for. +/// +/// Torznab carries a daily episode as `season=YYYY&ep=MM/DD`, the same two +/// parameters a numbered episode uses. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum TvSelector { + /// Everything the indexer holds for the series. + #[default] + Series, + /// One whole season. + Season { season: u32 }, + /// One numbered episode. + Episode { season: u32, episode: u32 }, + /// One dated episode of a daily show. + Daily { year: u16, month: u8, day: u8 }, +} + +/// What a TV search is looking for, in a form both a `t=tvsearch` call and a +/// text search can be built from (`DESIGN.md` §6.1). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TvTarget { + /// TVDB ID, when the series has one. Without it every indexer falls + /// back to text search. + pub tvdb_id: Option, + /// Series title, used to build the text-search query. + pub title: String, + pub selector: TvSelector, +} + /// Release metadata returned directly by one Torznab indexer. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SearchRelease { @@ -146,6 +175,64 @@ impl ProwlarrClient { } } +impl Capabilities { + /// The Torznab request one indexer should receive for a TV target. + /// + /// An indexer that cannot look up by TVDB ID gets a text search instead, + /// and one that takes `tvdbid` but not `season`/`ep` gets the narrowest + /// request it does accept (`DESIGN.md` §6.1). Both widen the result set, + /// so callers match the season and episode tags on the release names. + /// + /// Returns `None` when the indexer supports neither TV nor text search. + #[must_use] + pub fn tv_request(&self, target: &TvTarget) -> Option { + if let Some(tvdb_id) = target.tvdb_id { + if self.tv.available && self.tv.supports_parameter("tvdbid") { + return Some(SearchRequest::Tv { + tvdb_id, + selector: self.narrowed(target.selector), + }); + } + } + + self.search.available.then(|| SearchRequest::Text { + query: target.text_query(), + }) + } + + /// Widens a selector until every parameter it needs is one the indexer + /// advertises. + fn narrowed(&self, selector: TvSelector) -> TvSelector { + let takes_season = self.tv.supports_parameter("season"); + let takes_episode = takes_season && self.tv.supports_parameter("ep"); + match selector { + TvSelector::Season { .. } if takes_season => selector, + TvSelector::Episode { .. } | TvSelector::Daily { .. } if takes_episode => selector, + TvSelector::Episode { season, .. } if takes_season => TvSelector::Season { season }, + _ => TvSelector::Series, + } + } +} + +impl TvTarget { + /// The query a text-only indexer is asked for: the series title plus the + /// tag a release name would carry for this selector. + #[must_use] + pub fn text_query(&self) -> String { + let title = self.title.trim(); + match self.selector { + TvSelector::Series => title.to_owned(), + TvSelector::Season { season } => format!("{title} S{season:02}"), + TvSelector::Episode { season, episode } => { + format!("{title} S{season:02}E{episode:02}") + } + TvSelector::Daily { year, month, day } => { + format!("{title} {year} {month:02} {day:02}") + } + } + } +} + impl SearchRequest { fn operation(&self) -> &'static str { match self { @@ -162,17 +249,21 @@ impl SearchRequest { Self::Movie { imdb_id } => { parameters.push(("imdbid".to_owned(), imdb_id.clone())); } - Self::Tv { - tvdb_id, - season, - episode, - } => { + Self::Tv { tvdb_id, selector } => { parameters.push(("tvdbid".to_owned(), tvdb_id.to_string())); - if let Some(season) = season { - parameters.push(("season".to_owned(), season.to_string())); - } - if let Some(episode) = episode { - parameters.push(("ep".to_owned(), episode.to_string())); + match *selector { + TvSelector::Series => {} + TvSelector::Season { season } => { + parameters.push(("season".to_owned(), season.to_string())); + } + TvSelector::Episode { season, episode } => { + parameters.push(("season".to_owned(), season.to_string())); + parameters.push(("ep".to_owned(), episode.to_string())); + } + TvSelector::Daily { year, month, day } => { + parameters.push(("season".to_owned(), year.to_string())); + parameters.push(("ep".to_owned(), format!("{month:02}/{day:02}"))); + } } } } @@ -412,8 +503,9 @@ fn parse_date(value: &str) -> Option { #[cfg(test)] mod tests { - use super::{parse_releases, SearchError, SearchRequest}; + use super::{parse_releases, SearchError, SearchRequest, TvSelector}; use crate::ProwlarrClient; + use arr_parse::EpisodeClaim; use wiremock::{ matchers::{method, path, query_param, query_param_is_missing}, Mock, MockServer, ResponseTemplate, @@ -435,6 +527,25 @@ mod tests { None, ) .await; + mount_request( + &server, + 5, + &[ + ('t', "tvsearch"), + ('v', "371980"), + ('s', "2026"), + ('e', "08/21"), + ], + None, + ) + .await; + mount_request( + &server, + 6, + &[('t', "tvsearch"), ('v', "371980")], + Some("season"), + ) + .await; let client = ProwlarrClient::new(server.uri(), API_KEY).expect("client is valid"); client @@ -464,12 +575,38 @@ mod tests { 4, &SearchRequest::Tv { tvdb_id: 371_980, - season: Some(2), - episode: Some(3), + selector: TvSelector::Episode { + season: 2, + episode: 3, + }, }, ) .await .expect("TV response is valid"); + client + .search_indexer( + 5, + &SearchRequest::Tv { + tvdb_id: 371_980, + selector: TvSelector::Daily { + year: 2026, + month: 8, + day: 21, + }, + }, + ) + .await + .expect("daily TV response is valid"); + client + .search_indexer( + 6, + &SearchRequest::Tv { + tvdb_id: 371_980, + selector: TvSelector::Series, + }, + ) + .await + .expect("series-wide TV response is valid"); } #[tokio::test] @@ -580,6 +717,69 @@ mod tests { ); } + /// A pack and one episode of that pack carry the same series title, so + /// the tag on the release name is the only thing separating them. + #[test] + fn tv_results_separate_packs_episodes_and_air_dates() { + let body = include_str!("../tests/fixtures/tvsearch.xml"); + let releases = parse_releases(3, body.as_bytes()).expect("fixture is valid Torznab XML"); + + let claims: Vec<(String, Option)> = releases + .into_iter() + .map(|release| { + let claim = arr_parse::parse(&release.name).episode; + (release.guid, claim) + }) + .collect(); + let claim_of = |guid: &str| { + claims + .iter() + .find(|(id, _)| id == guid) + .and_then(|(_, claim)| claim.clone()) + .unwrap_or_else(|| panic!("{guid} carries an episode claim")) + }; + + let episode = claim_of("alpha-episode-1"); + assert_eq!( + episode, + EpisodeClaim::Episodes { + season: 2, + episodes: vec![3], + } + ); + assert!(!episode.is_season_pack()); + assert_eq!( + claim_of("alpha-episode-2"), + EpisodeClaim::Episodes { + season: 2, + episodes: vec![4], + } + ); + + let pack = claim_of("alpha-season-pack"); + assert_eq!(pack, EpisodeClaim::Season { season: 2 }); + assert!(pack.is_season_pack()); + assert!(pack.covers(2, 3), "the pack holds the episode too"); + + let series_pack = claim_of("alpha-series-pack"); + assert_eq!(series_pack, EpisodeClaim::Seasons { first: 1, last: 3 }); + assert!(series_pack.covers_season(2)); + + let multi = claim_of("alpha-multi-episode"); + assert_eq!( + multi, + EpisodeClaim::Episodes { + season: 2, + episodes: vec![3, 4], + } + ); + assert!(multi.is_multi_episode()); + assert!(!multi.is_season_pack()); + + assert!(claim_of("alpha-daily").covers_date(2026, 8, 21)); + assert!(claim_of("alpha-daily-hyphenated").covers_date(2026, 8, 22)); + } + async fn mount_request( server: &MockServer, id: i64, diff --git a/crates/arr-indexer/tests/fixtures/season-only-caps.xml b/crates/arr-indexer/tests/fixtures/season-only-caps.xml new file mode 100644 index 0000000..853aec9 --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/season-only-caps.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/arr-indexer/tests/fixtures/tvsearch.xml b/crates/arr-indexer/tests/fixtures/tvsearch.xml new file mode 100644 index 0000000..44f8db5 --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/tvsearch.xml @@ -0,0 +1,64 @@ + + + + Alpha Tracker + + Example.Show.S02E03.1080p.WEB-DL.DDP5.1.H.264-GROUP + alpha-episode-1 + https://indexer.invalid/details/alpha-episode-1 + Fri, 21 Aug 2026 21:04:00 +0000 + + + + + + + Example.Show.2x04.1080p.WEB-DL.DDP5.1.H.264-GROUP + alpha-episode-2 + https://indexer.invalid/details/alpha-episode-2 + Sat, 22 Aug 2026 21:04:00 +0000 + + + + + Example.Show.S02.1080p.WEB-DL.DDP5.1.H.264-GROUP + alpha-season-pack + https://indexer.invalid/details/alpha-season-pack + Sat, 22 Aug 2026 09:30:00 +0000 + + + + + Example.Show.S01-S03.COMPLETE.1080p.BluRay.x264-GROUP + alpha-series-pack + https://indexer.invalid/details/alpha-series-pack + Mon, 03 Aug 2026 12:00:00 +0000 + + + + + Example.Show.S02E03-E04.1080p.WEB-DL.DDP5.1.H.264-GROUP + alpha-multi-episode + https://indexer.invalid/details/alpha-multi-episode + Sat, 22 Aug 2026 10:00:00 +0000 + + + + + Example.Daily.Show.2026.08.21.Guest.Name.1080p.WEB.h264-GROUP + alpha-daily + https://indexer.invalid/details/alpha-daily + Fri, 21 Aug 2026 04:00:00 +0000 + + + + + Example.Daily.Show.2026-08-22.Guest.Name.720p.HDTV.x264-GROUP + alpha-daily-hyphenated + https://indexer.invalid/details/alpha-daily-hyphenated + Sat, 22 Aug 2026 04:00:00 +0000 + + + + + diff --git a/crates/arr-parse/src/episodes.rs b/crates/arr-parse/src/episodes.rs new file mode 100644 index 0000000..629ee54 --- /dev/null +++ b/crates/arr-parse/src/episodes.rs @@ -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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/crates/arr-parse/src/lib.rs b/crates/arr-parse/src/lib.rs index 4fead85..53983aa 100644 --- a/crates/arr-parse/src/lib.rs +++ b/crates/arr-parse/src/lib.rs @@ -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, + }, + /// 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, /// Claimed release group. pub group: Option, + /// Claimed season, episodes or air date. `None` for anything that does + /// not carry a TV tag. + pub episode: Option, } #[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() }; diff --git a/crates/arr-parse/src/markers.rs b/crates/arr-parse/src/markers.rs index e553e9a..3ccbe00 100644 --- a/crates/arr-parse/src/markers.rs +++ b/crates/arr-parse/src/markers.rs @@ -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. diff --git a/crates/arr-parse/tests/corpus.rs b/crates/arr-parse/tests/corpus.rs index 0ae39e7..a914bdd 100644 --- a/crates/arr-parse/tests/corpus.rs +++ b/crates/arr-parse/tests/corpus.rs @@ -8,7 +8,9 @@ //! pt-BR/pt-PT markers from §5.2, and real-world malformed names that //! must parse partially rather than fail. -use arr_parse::{parse, Codec, Edition, HdrMarker, LanguageMarker, NameClaims, Resolution, Source}; +use arr_parse::{ + parse, Codec, Edition, EpisodeClaim, HdrMarker, LanguageMarker, NameClaims, Resolution, Source, +}; use serde as _; struct Case { @@ -21,6 +23,14 @@ fn s(v: &str) -> Option { Some(v.to_string()) } +#[allow(clippy::unnecessary_wraps)] +fn episodes(season: u32, episodes: &[u32]) -> Option { + Some(EpisodeClaim::Episodes { + season, + episodes: episodes.to_vec(), + }) +} + /// Every resolution and every source tier appears at least once here, /// pulled from a broad search rather than picked to fit. #[allow(clippy::too_many_lines)] @@ -56,6 +66,7 @@ fn matrix_cases() -> Vec { source: Some(Source::WebDl), codec: Some(Codec::X264), group: s("RAWR"), + episode: episodes(2, &[1]), ..NameClaims::default() }, }, @@ -67,6 +78,7 @@ fn matrix_cases() -> Vec { source: Some(Source::Hdtv), codec: Some(Codec::X264), group: s("SYNCOPY"), + episode: episodes(8, &[14]), ..NameClaims::default() }, }, @@ -78,6 +90,7 @@ fn matrix_cases() -> Vec { source: Some(Source::BluRay), codec: Some(Codec::X264), group: s("CtrlHD"), + episode: episodes(3, &[22]), ..NameClaims::default() }, }, @@ -229,6 +242,7 @@ fn matrix_cases() -> Vec { source: Some(Source::WebDl), codec: Some(Codec::X265), group: s("NTb"), + episode: Some(EpisodeClaim::Season { season: 1 }), ..NameClaims::default() }, }, @@ -268,6 +282,7 @@ fn matrix_cases() -> Vec { source: Some(Source::Hdtv), codec: Some(Codec::X264), group: s("TJET"), + episode: Some(EpisodeClaim::Daily { year: 2026, month: 8, day: 21 }), ..NameClaims::default() }, }, @@ -279,6 +294,7 @@ fn matrix_cases() -> Vec { resolution: Some(Resolution::P480), codec: Some(Codec::X264), group: s("mSD"), + episode: episodes(8, &[37]), ..NameClaims::default() }, }, @@ -291,6 +307,7 @@ fn matrix_cases() -> Vec { resolution: Some(Resolution::P2160), codec: Some(Codec::X265), group: s("FaiLED"), + episode: episodes(1, &[1]), ..NameClaims::default() }, }, @@ -399,6 +416,7 @@ fn pt_br_cases() -> Vec { resolution: Some(Resolution::P1080), source: Some(Source::Hdtv), languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt], + episode: Some(EpisodeClaim::Season { season: 1 }), ..NameClaims::default() }, }, @@ -409,6 +427,7 @@ fn pt_br_cases() -> Vec { resolution: Some(Resolution::P576), source: Some(Source::WebDl), languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt], + episode: Some(EpisodeClaim::Season { season: 1 }), ..NameClaims::default() }, }, @@ -471,6 +490,7 @@ fn multi_audio_cases() -> Vec { codec: Some(Codec::X264), languages: vec![LanguageMarker::Multi], group: s("mSD"), + episode: episodes(4, &[18]), ..NameClaims::default() }, }, @@ -484,6 +504,7 @@ fn multi_audio_cases() -> Vec { codec: Some(Codec::X264), languages: vec![LanguageMarker::Multi], group: s("HiggsBoson"), + episode: episodes(1, &[1]), ..NameClaims::default() }, }, @@ -523,6 +544,7 @@ fn multi_audio_cases() -> Vec { hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10Plus], languages: vec![LanguageMarker::Dual], group: s("Kitsune"), + episode: Some(EpisodeClaim::Season { season: 2 }), ..NameClaims::default() }, }, @@ -723,6 +745,146 @@ fn malformed_cases() -> Vec { year: Some(2003), resolution: Some(Resolution::P480), source: Some(Source::Remux), + episode: Some(EpisodeClaim::Season { season: 1 }), + ..NameClaims::default() + }, + }, + ] +} + +/// Season and episode tags, the distinction grab selection turns on +/// (issue #37): single episodes, season packs, multi-episode files and +/// daily-dated shows. +#[allow(clippy::too_many_lines)] +fn tv_cases() -> Vec { + vec![ + Case { + name: "The Rookie S08E14 720p HDTV x264-SYNCOPY".into(), + want: NameClaims { + title: s("The Rookie"), + resolution: Some(Resolution::P720), + source: Some(Source::Hdtv), + codec: Some(Codec::X264), + group: s("SYNCOPY"), + episode: episodes(8, &[14]), + ..NameClaims::default() + }, + }, + Case { + name: "Slow.Horses.1x02.1080p.WEB-DL.DDP5.1.H.264-NTb".into(), + want: NameClaims { + title: s("Slow Horses"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + codec: Some(Codec::X264), + group: s("NTb"), + episode: episodes(1, &[2]), + ..NameClaims::default() + }, + }, + Case { + name: "Severance.S02.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX".into(), + want: NameClaims { + title: s("Severance"), + resolution: Some(Resolution::P2160), + source: Some(Source::WebDl), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr], + group: s("FLUX"), + episode: Some(EpisodeClaim::Season { season: 2 }), + ..NameClaims::default() + }, + }, + Case { + name: "The Bear Season 3 1080p WEB-DL x265-GROUP".into(), + want: NameClaims { + title: s("The Bear"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + codec: Some(Codec::X265), + group: s("GROUP"), + episode: Some(EpisodeClaim::Season { season: 3 }), + ..NameClaims::default() + }, + }, + Case { + name: "Peep.Show.S01-S09.COMPLETE.1080p.BluRay.x264-SHORTBREHD".into(), + want: NameClaims { + title: s("Peep Show"), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + codec: Some(Codec::X264), + group: s("SHORTBREHD"), + episode: Some(EpisodeClaim::Seasons { first: 1, last: 9 }), + ..NameClaims::default() + }, + }, + Case { + name: "Adventure.Time.S05E01E02.1080p.WEB-DL.AAC2.0.H.264-iT00NZ".into(), + want: NameClaims { + title: s("Adventure Time"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + codec: Some(Codec::X264), + group: s("iT00NZ"), + episode: episodes(5, &[1, 2]), + ..NameClaims::default() + }, + }, + Case { + name: "Bluey.S03E01-E04.1080p.WEB-DL.DD+5.1.H.264-playWEB".into(), + want: NameClaims { + title: s("Bluey"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + codec: Some(Codec::X264), + group: s("playWEB"), + episode: episodes(3, &[1, 2, 3, 4]), + ..NameClaims::default() + }, + }, + Case { + name: "The.Daily.Show.2024.05.13.Jon.Stewart.1080p.WEB.h264-EDITH".into(), + want: NameClaims { + title: s("The Daily Show"), + year: Some(2024), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + codec: Some(Codec::X264), + group: s("EDITH"), + episode: Some(EpisodeClaim::Daily { + year: 2024, + month: 5, + day: 13, + }), + ..NameClaims::default() + }, + }, + Case { + name: "Last.Week.Tonight.With.John.Oliver.2024-09-15.720p.HDTV.x264-aAF".into(), + want: NameClaims { + title: s("Last Week Tonight With John Oliver"), + resolution: Some(Resolution::P720), + source: Some(Source::Hdtv), + codec: Some(Codec::X264), + group: s("aAF"), + episode: Some(EpisodeClaim::Daily { + year: 2024, + month: 9, + day: 15, + }), + ..NameClaims::default() + }, + }, + Case { + name: "Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos.H.265-FLUX".into(), + want: NameClaims { + title: s("Dune Part Two"), + year: Some(2024), + resolution: Some(Resolution::P2160), + source: Some(Source::WebDl), + codec: Some(Codec::X265), + group: s("FLUX"), ..NameClaims::default() }, }, @@ -735,6 +897,7 @@ fn all_cases() -> Vec { cases.extend(multi_audio_cases()); cases.extend(dv_hdr_cases()); cases.extend(malformed_cases()); + cases.extend(tv_cases()); cases } diff --git a/crates/arr-parse/tests/parse.rs b/crates/arr-parse/tests/parse.rs index 5a69403..6b150ac 100644 --- a/crates/arr-parse/tests/parse.rs +++ b/crates/arr-parse/tests/parse.rs @@ -2,7 +2,9 @@ //! corpus pulled from real indexers is issue #9; these rows pin the parser's //! behaviour per shape of name. -use arr_parse::{parse, Codec, Edition, HdrMarker, LanguageMarker, NameClaims, Resolution, Source}; +use arr_parse::{ + parse, Codec, Edition, EpisodeClaim, HdrMarker, LanguageMarker, NameClaims, Resolution, Source, +}; use serde as _; struct Case { @@ -116,6 +118,10 @@ fn cases() -> Vec { source: Some(Source::Hdtv), codec: Some(Codec::X264), group: s("LOL"), + episode: Some(EpisodeClaim::Episodes { + season: 1, + episodes: vec![2], + }), ..NameClaims::default() }, }, @@ -297,6 +303,7 @@ fn cases() -> Vec { codec: Some(Codec::X265), hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10], group: s("NTb"), + episode: Some(EpisodeClaim::Season { season: 2 }), ..NameClaims::default() }, }, @@ -353,3 +360,66 @@ fn garbage_never_panics() { let _ = parse(input); } } + +#[test] +fn episode_tags_separate_packs_from_single_episodes() { + let single = parse("Show.S01E02.1080p.WEB-DL.x264-GRP") + .episode + .expect("S01E02 is an episode tag"); + assert_eq!( + single, + EpisodeClaim::Episodes { + season: 1, + episodes: vec![2] + } + ); + assert!(!single.is_season_pack()); + assert!(!single.is_multi_episode()); + assert!(single.covers(1, 2)); + assert!(!single.covers(1, 3)); + assert!(single.covers_season(1)); + + let multi = parse("Show.S01E02-E04.1080p.WEB-DL.x264-GRP") + .episode + .expect("S01E02-E04 is an episode tag"); + assert!(multi.is_multi_episode()); + assert!(!multi.is_season_pack()); + assert!(multi.covers(1, 3)); + assert!(!multi.covers(1, 5)); + + let pack = parse("Show.S01.1080p.WEB-DL.x264-GRP") + .episode + .expect("S01 is a season tag"); + assert!(pack.is_season_pack()); + assert!( + pack.covers(1, 7), + "a season pack covers every episode in it" + ); + assert!(!pack.covers(2, 7)); + + let seasons = parse("Show.S01-S03.1080p.WEB-DL.x264-GRP") + .episode + .expect("S01-S03 is a season range"); + assert!(seasons.is_season_pack()); + assert!(seasons.covers_season(2)); + assert!(!seasons.covers_season(4)); + + let daily = parse("Show.2024.05.13.1080p.WEB.h264-GRP") + .episode + .expect("a dotted air date is a daily tag"); + assert!(daily.covers_date(2024, 5, 13)); + assert!(!daily.covers_date(2024, 5, 14)); + assert!(!daily.covers(1, 1), "a daily episode has no season number"); +} + +#[test] +fn non_tv_names_claim_no_episode() { + for name in [ + "Dune.Part.Two.2024.2160p.WEB-DL.H.265-FLUX", + "Movie.1999.1080p.BluRay.x264-GRP", + "Show.2024.13.45.1080p.WEB-DL.x264-GRP", + "Blade.Runner.2049.2017.2160p.UHD.BluRay.x265-TERMiNAL", + ] { + assert_eq!(parse(name).episode, None, "{name}"); + } +}