feat(indexer): tvsearch with season and episode tags (#78)
This commit was merged in pull request #78.
This commit is contained in:
Generated
+1
@@ -140,6 +140,7 @@ dependencies = [
|
|||||||
name = "arr-indexer"
|
name = "arr-indexer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arr-parse",
|
||||||
"chrono",
|
"chrono",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ thiserror.workspace = true
|
|||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
arr-parse.workspace = true
|
||||||
wiremock.workspace = true
|
wiremock.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
mod search;
|
mod search;
|
||||||
|
|
||||||
pub use search::{IndexerSearch, SearchError, SearchRelease, SearchRequest};
|
pub use search::{IndexerSearch, SearchError, SearchRelease, SearchRequest, TvSelector, TvTarget};
|
||||||
|
|
||||||
use std::{collections::BTreeSet, time::Duration};
|
use std::{collections::BTreeSet, time::Duration};
|
||||||
|
|
||||||
@@ -289,7 +289,10 @@ fn is_available(value: &str) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{Capabilities, CapabilityError, ProwlarrClient};
|
use super::{
|
||||||
|
parse_capabilities, Capabilities, CapabilityError, ProwlarrClient, SearchRequest,
|
||||||
|
TvSelector, TvTarget,
|
||||||
|
};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use wiremock::{
|
use wiremock::{
|
||||||
matchers::{header, method, path, query_param},
|
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) {
|
async fn mount_capabilities(server: &MockServer, id: i64, body: &str) {
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path(format!("/{id}/api")))
|
.and(path(format!("/{id}/api")))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use reqwest::StatusCode;
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
use crate::ProwlarrClient;
|
use crate::{Capabilities, ProwlarrClient};
|
||||||
|
|
||||||
/// One operation against an indexer's Torznab endpoint.
|
/// One operation against an indexer's Torznab endpoint.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -23,13 +23,42 @@ pub enum SearchRequest {
|
|||||||
Movie {
|
Movie {
|
||||||
imdb_id: String,
|
imdb_id: String,
|
||||||
},
|
},
|
||||||
|
/// A `t=tvsearch` lookup by TVDB ID, narrowed by [`TvSelector`].
|
||||||
Tv {
|
Tv {
|
||||||
tvdb_id: u64,
|
tvdb_id: u64,
|
||||||
season: Option<u32>,
|
selector: TvSelector,
|
||||||
episode: Option<u32>,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<u64>,
|
||||||
|
/// Series title, used to build the text-search query.
|
||||||
|
pub title: String,
|
||||||
|
pub selector: TvSelector,
|
||||||
|
}
|
||||||
|
|
||||||
/// Release metadata returned directly by one Torznab indexer.
|
/// Release metadata returned directly by one Torznab indexer.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct SearchRelease {
|
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<SearchRequest> {
|
||||||
|
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 {
|
impl SearchRequest {
|
||||||
fn operation(&self) -> &'static str {
|
fn operation(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -162,17 +249,21 @@ impl SearchRequest {
|
|||||||
Self::Movie { imdb_id } => {
|
Self::Movie { imdb_id } => {
|
||||||
parameters.push(("imdbid".to_owned(), imdb_id.clone()));
|
parameters.push(("imdbid".to_owned(), imdb_id.clone()));
|
||||||
}
|
}
|
||||||
Self::Tv {
|
Self::Tv { tvdb_id, selector } => {
|
||||||
tvdb_id,
|
|
||||||
season,
|
|
||||||
episode,
|
|
||||||
} => {
|
|
||||||
parameters.push(("tvdbid".to_owned(), tvdb_id.to_string()));
|
parameters.push(("tvdbid".to_owned(), tvdb_id.to_string()));
|
||||||
if let Some(season) = season {
|
match *selector {
|
||||||
parameters.push(("season".to_owned(), season.to_string()));
|
TvSelector::Series => {}
|
||||||
}
|
TvSelector::Season { season } => {
|
||||||
if let Some(episode) = episode {
|
parameters.push(("season".to_owned(), season.to_string()));
|
||||||
parameters.push(("ep".to_owned(), episode.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<SystemTime> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_releases, SearchError, SearchRequest};
|
use super::{parse_releases, SearchError, SearchRequest, TvSelector};
|
||||||
use crate::ProwlarrClient;
|
use crate::ProwlarrClient;
|
||||||
|
use arr_parse::EpisodeClaim;
|
||||||
use wiremock::{
|
use wiremock::{
|
||||||
matchers::{method, path, query_param, query_param_is_missing},
|
matchers::{method, path, query_param, query_param_is_missing},
|
||||||
Mock, MockServer, ResponseTemplate,
|
Mock, MockServer, ResponseTemplate,
|
||||||
@@ -435,6 +527,25 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.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");
|
let client = ProwlarrClient::new(server.uri(), API_KEY).expect("client is valid");
|
||||||
client
|
client
|
||||||
@@ -464,12 +575,38 @@ mod tests {
|
|||||||
4,
|
4,
|
||||||
&SearchRequest::Tv {
|
&SearchRequest::Tv {
|
||||||
tvdb_id: 371_980,
|
tvdb_id: 371_980,
|
||||||
season: Some(2),
|
selector: TvSelector::Episode {
|
||||||
episode: Some(3),
|
season: 2,
|
||||||
|
episode: 3,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("TV response is valid");
|
.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]
|
#[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<EpisodeClaim>)> = 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(
|
async fn mount_request(
|
||||||
server: &MockServer,
|
server: &MockServer,
|
||||||
id: i64,
|
id: i64,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<caps>
|
||||||
|
<server version="1.0" title="Season Only" />
|
||||||
|
<searching>
|
||||||
|
<search available="yes" supportedParams="q" />
|
||||||
|
<tv-search available="yes" supportedParams="q,season,tvdbid" />
|
||||||
|
<movie-search available="no" supportedParams="q" />
|
||||||
|
</searching>
|
||||||
|
</caps>
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/">
|
||||||
|
<channel>
|
||||||
|
<title>Alpha Tracker</title>
|
||||||
|
<item>
|
||||||
|
<title>Example.Show.S02E03.1080p.WEB-DL.DDP5.1.H.264-GROUP</title>
|
||||||
|
<guid>alpha-episode-1</guid>
|
||||||
|
<link>https://indexer.invalid/details/alpha-episode-1</link>
|
||||||
|
<pubDate>Fri, 21 Aug 2026 21:04:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://indexer.invalid/download/alpha-episode-1" length="2147483648" type="application/x-bittorrent" />
|
||||||
|
<newznab:attr name="seeders" value="52" />
|
||||||
|
<newznab:attr name="season" value="2" />
|
||||||
|
<newznab:attr name="episode" value="3" />
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Example.Show.2x04.1080p.WEB-DL.DDP5.1.H.264-GROUP</title>
|
||||||
|
<guid>alpha-episode-2</guid>
|
||||||
|
<link>https://indexer.invalid/details/alpha-episode-2</link>
|
||||||
|
<pubDate>Sat, 22 Aug 2026 21:04:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://indexer.invalid/download/alpha-episode-2" length="2147483648" type="application/x-bittorrent" />
|
||||||
|
<newznab:attr name="seeders" value="31" />
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Example.Show.S02.1080p.WEB-DL.DDP5.1.H.264-GROUP</title>
|
||||||
|
<guid>alpha-season-pack</guid>
|
||||||
|
<link>https://indexer.invalid/details/alpha-season-pack</link>
|
||||||
|
<pubDate>Sat, 22 Aug 2026 09:30:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://indexer.invalid/download/alpha-season-pack" length="21474836480" type="application/x-bittorrent" />
|
||||||
|
<newznab:attr name="seeders" value="18" />
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Example.Show.S01-S03.COMPLETE.1080p.BluRay.x264-GROUP</title>
|
||||||
|
<guid>alpha-series-pack</guid>
|
||||||
|
<link>https://indexer.invalid/details/alpha-series-pack</link>
|
||||||
|
<pubDate>Mon, 03 Aug 2026 12:00:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://indexer.invalid/download/alpha-series-pack" length="96636764160" type="application/x-bittorrent" />
|
||||||
|
<newznab:attr name="seeders" value="6" />
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Example.Show.S02E03-E04.1080p.WEB-DL.DDP5.1.H.264-GROUP</title>
|
||||||
|
<guid>alpha-multi-episode</guid>
|
||||||
|
<link>https://indexer.invalid/details/alpha-multi-episode</link>
|
||||||
|
<pubDate>Sat, 22 Aug 2026 10:00:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://indexer.invalid/download/alpha-multi-episode" length="4294967296" type="application/x-bittorrent" />
|
||||||
|
<newznab:attr name="seeders" value="11" />
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Example.Daily.Show.2026.08.21.Guest.Name.1080p.WEB.h264-GROUP</title>
|
||||||
|
<guid>alpha-daily</guid>
|
||||||
|
<link>https://indexer.invalid/details/alpha-daily</link>
|
||||||
|
<pubDate>Fri, 21 Aug 2026 04:00:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://indexer.invalid/download/alpha-daily" length="1073741824" type="application/x-bittorrent" />
|
||||||
|
<newznab:attr name="seeders" value="24" />
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Example.Daily.Show.2026-08-22.Guest.Name.720p.HDTV.x264-GROUP</title>
|
||||||
|
<guid>alpha-daily-hyphenated</guid>
|
||||||
|
<link>https://indexer.invalid/details/alpha-daily-hyphenated</link>
|
||||||
|
<pubDate>Sat, 22 Aug 2026 04:00:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://indexer.invalid/download/alpha-daily-hyphenated" length="536870912" type="application/x-bittorrent" />
|
||||||
|
<newznab:attr name="seeders" value="9" />
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
//! Parsing never fails: a malformed name yields a partial [`NameClaims`],
|
//! Parsing never fails: a malformed name yields a partial [`NameClaims`],
|
||||||
//! worst case an empty one.
|
//! worst case an empty one.
|
||||||
|
|
||||||
|
mod episodes;
|
||||||
mod markers;
|
mod markers;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -144,6 +145,82 @@ pub enum Edition {
|
|||||||
Limited,
|
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
|
/// What a release name claims about itself. Every field is unverified
|
||||||
/// (`DESIGN.md` §5.6); absence of a marker means nothing.
|
/// (`DESIGN.md` §5.6); absence of a marker means nothing.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -167,6 +244,9 @@ pub struct NameClaims {
|
|||||||
pub editions: Vec<Edition>,
|
pub editions: Vec<Edition>,
|
||||||
/// Claimed release group.
|
/// Claimed release group.
|
||||||
pub group: Option<String>,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -205,11 +285,13 @@ pub fn parse(name: &str) -> NameClaims {
|
|||||||
.copied();
|
.copied();
|
||||||
let boundary = year_idx.map_or(limit, |y| y.min(limit));
|
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 {
|
let mut claims = NameClaims {
|
||||||
year: year_idx
|
year: year_idx
|
||||||
.and_then(|i| toks.get(i))
|
.and_then(|i| toks.get(i))
|
||||||
.and_then(|t| markers::year_of(&t.low)),
|
.and_then(|t| markers::year_of(&t.low)),
|
||||||
group: dash_group.or(bracket_group),
|
group: dash_group.or(bracket_group),
|
||||||
|
episode: episodes::claim(&lows),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
pub(crate) fn is_season_episode(t: &str) -> bool {
|
||||||
fn digits(s: &str) -> (usize, &str) {
|
crate::episodes::tag_of(t).is_some()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A plausible release year: four digits, 1900–2099.
|
/// A plausible release year: four digits, 1900–2099.
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
//! pt-BR/pt-PT markers from §5.2, and real-world malformed names that
|
//! pt-BR/pt-PT markers from §5.2, and real-world malformed names that
|
||||||
//! must parse partially rather than fail.
|
//! 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 _;
|
use serde as _;
|
||||||
|
|
||||||
struct Case {
|
struct Case {
|
||||||
@@ -21,6 +23,14 @@ fn s(v: &str) -> Option<String> {
|
|||||||
Some(v.to_string())
|
Some(v.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
|
fn episodes(season: u32, episodes: &[u32]) -> Option<EpisodeClaim> {
|
||||||
|
Some(EpisodeClaim::Episodes {
|
||||||
|
season,
|
||||||
|
episodes: episodes.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Every resolution and every source tier appears at least once here,
|
/// Every resolution and every source tier appears at least once here,
|
||||||
/// pulled from a broad search rather than picked to fit.
|
/// pulled from a broad search rather than picked to fit.
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@@ -56,6 +66,7 @@ fn matrix_cases() -> Vec<Case> {
|
|||||||
source: Some(Source::WebDl),
|
source: Some(Source::WebDl),
|
||||||
codec: Some(Codec::X264),
|
codec: Some(Codec::X264),
|
||||||
group: s("RAWR"),
|
group: s("RAWR"),
|
||||||
|
episode: episodes(2, &[1]),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -67,6 +78,7 @@ fn matrix_cases() -> Vec<Case> {
|
|||||||
source: Some(Source::Hdtv),
|
source: Some(Source::Hdtv),
|
||||||
codec: Some(Codec::X264),
|
codec: Some(Codec::X264),
|
||||||
group: s("SYNCOPY"),
|
group: s("SYNCOPY"),
|
||||||
|
episode: episodes(8, &[14]),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -78,6 +90,7 @@ fn matrix_cases() -> Vec<Case> {
|
|||||||
source: Some(Source::BluRay),
|
source: Some(Source::BluRay),
|
||||||
codec: Some(Codec::X264),
|
codec: Some(Codec::X264),
|
||||||
group: s("CtrlHD"),
|
group: s("CtrlHD"),
|
||||||
|
episode: episodes(3, &[22]),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -229,6 +242,7 @@ fn matrix_cases() -> Vec<Case> {
|
|||||||
source: Some(Source::WebDl),
|
source: Some(Source::WebDl),
|
||||||
codec: Some(Codec::X265),
|
codec: Some(Codec::X265),
|
||||||
group: s("NTb"),
|
group: s("NTb"),
|
||||||
|
episode: Some(EpisodeClaim::Season { season: 1 }),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -268,6 +282,7 @@ fn matrix_cases() -> Vec<Case> {
|
|||||||
source: Some(Source::Hdtv),
|
source: Some(Source::Hdtv),
|
||||||
codec: Some(Codec::X264),
|
codec: Some(Codec::X264),
|
||||||
group: s("TJET"),
|
group: s("TJET"),
|
||||||
|
episode: Some(EpisodeClaim::Daily { year: 2026, month: 8, day: 21 }),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -279,6 +294,7 @@ fn matrix_cases() -> Vec<Case> {
|
|||||||
resolution: Some(Resolution::P480),
|
resolution: Some(Resolution::P480),
|
||||||
codec: Some(Codec::X264),
|
codec: Some(Codec::X264),
|
||||||
group: s("mSD"),
|
group: s("mSD"),
|
||||||
|
episode: episodes(8, &[37]),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -291,6 +307,7 @@ fn matrix_cases() -> Vec<Case> {
|
|||||||
resolution: Some(Resolution::P2160),
|
resolution: Some(Resolution::P2160),
|
||||||
codec: Some(Codec::X265),
|
codec: Some(Codec::X265),
|
||||||
group: s("FaiLED"),
|
group: s("FaiLED"),
|
||||||
|
episode: episodes(1, &[1]),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -399,6 +416,7 @@ fn pt_br_cases() -> Vec<Case> {
|
|||||||
resolution: Some(Resolution::P1080),
|
resolution: Some(Resolution::P1080),
|
||||||
source: Some(Source::Hdtv),
|
source: Some(Source::Hdtv),
|
||||||
languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt],
|
languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt],
|
||||||
|
episode: Some(EpisodeClaim::Season { season: 1 }),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -409,6 +427,7 @@ fn pt_br_cases() -> Vec<Case> {
|
|||||||
resolution: Some(Resolution::P576),
|
resolution: Some(Resolution::P576),
|
||||||
source: Some(Source::WebDl),
|
source: Some(Source::WebDl),
|
||||||
languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt],
|
languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt],
|
||||||
|
episode: Some(EpisodeClaim::Season { season: 1 }),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -471,6 +490,7 @@ fn multi_audio_cases() -> Vec<Case> {
|
|||||||
codec: Some(Codec::X264),
|
codec: Some(Codec::X264),
|
||||||
languages: vec![LanguageMarker::Multi],
|
languages: vec![LanguageMarker::Multi],
|
||||||
group: s("mSD"),
|
group: s("mSD"),
|
||||||
|
episode: episodes(4, &[18]),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -484,6 +504,7 @@ fn multi_audio_cases() -> Vec<Case> {
|
|||||||
codec: Some(Codec::X264),
|
codec: Some(Codec::X264),
|
||||||
languages: vec![LanguageMarker::Multi],
|
languages: vec![LanguageMarker::Multi],
|
||||||
group: s("HiggsBoson"),
|
group: s("HiggsBoson"),
|
||||||
|
episode: episodes(1, &[1]),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -523,6 +544,7 @@ fn multi_audio_cases() -> Vec<Case> {
|
|||||||
hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10Plus],
|
hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10Plus],
|
||||||
languages: vec![LanguageMarker::Dual],
|
languages: vec![LanguageMarker::Dual],
|
||||||
group: s("Kitsune"),
|
group: s("Kitsune"),
|
||||||
|
episode: Some(EpisodeClaim::Season { season: 2 }),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -723,6 +745,146 @@ fn malformed_cases() -> Vec<Case> {
|
|||||||
year: Some(2003),
|
year: Some(2003),
|
||||||
resolution: Some(Resolution::P480),
|
resolution: Some(Resolution::P480),
|
||||||
source: Some(Source::Remux),
|
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<Case> {
|
||||||
|
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()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -735,6 +897,7 @@ fn all_cases() -> Vec<Case> {
|
|||||||
cases.extend(multi_audio_cases());
|
cases.extend(multi_audio_cases());
|
||||||
cases.extend(dv_hdr_cases());
|
cases.extend(dv_hdr_cases());
|
||||||
cases.extend(malformed_cases());
|
cases.extend(malformed_cases());
|
||||||
|
cases.extend(tv_cases());
|
||||||
cases
|
cases
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
//! corpus pulled from real indexers is issue #9; these rows pin the parser's
|
//! corpus pulled from real indexers is issue #9; these rows pin the parser's
|
||||||
//! behaviour per shape of name.
|
//! 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 _;
|
use serde as _;
|
||||||
|
|
||||||
struct Case {
|
struct Case {
|
||||||
@@ -116,6 +118,10 @@ fn cases() -> Vec<Case> {
|
|||||||
source: Some(Source::Hdtv),
|
source: Some(Source::Hdtv),
|
||||||
codec: Some(Codec::X264),
|
codec: Some(Codec::X264),
|
||||||
group: s("LOL"),
|
group: s("LOL"),
|
||||||
|
episode: Some(EpisodeClaim::Episodes {
|
||||||
|
season: 1,
|
||||||
|
episodes: vec![2],
|
||||||
|
}),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -297,6 +303,7 @@ fn cases() -> Vec<Case> {
|
|||||||
codec: Some(Codec::X265),
|
codec: Some(Codec::X265),
|
||||||
hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10],
|
hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10],
|
||||||
group: s("NTb"),
|
group: s("NTb"),
|
||||||
|
episode: Some(EpisodeClaim::Season { season: 2 }),
|
||||||
..NameClaims::default()
|
..NameClaims::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -353,3 +360,66 @@ fn garbage_never_panics() {
|
|||||||
let _ = parse(input);
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user