929 lines
31 KiB
Rust
929 lines
31 KiB
Rust
//! Matching an RSS result against the wanted list (`DESIGN.md` §6.2).
|
|
//!
|
|
//! RSS is an empty-query feed: every item is offered to the whole wanted list
|
|
//! and nothing about the query says which title an item is for. That makes a
|
|
//! false positive expensive — it grabs the wrong film — and a false negative
|
|
//! nearly free, because the feed is read again ten minutes later and the
|
|
//! targeted search still runs. So every rule here is deliberately strict:
|
|
//!
|
|
//! - an ID the tracker itself supplied beats anything read off the name,
|
|
//! except a season or episode tag in the name, which makes the release a
|
|
//! TV one whatever id came with it; and an ID that matches nothing wanted
|
|
//! ends the comparison rather than falling back to the title;
|
|
//! - two supplied IDs that disagree about which title this is are an
|
|
//! ambiguity like any other, and match nothing;
|
|
//! - a title match needs the years to agree, and a wanted title with a known
|
|
//! year is never matched by a release that does not state one;
|
|
//! - anything that matches two wanted titles matches neither.
|
|
//!
|
|
//! The TV side ([`match_episode`]) answers a narrower question about one
|
|
//! release and one wanted episode: is this its file, or a pack for exactly
|
|
//! the season holding it?
|
|
|
|
use crate::{EpisodeId, MovieId};
|
|
use arr_parse::{EpisodeClaim, NameClaims};
|
|
|
|
/// A wanted title, in the shape matching needs.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct WantedMovie {
|
|
pub id: MovieId,
|
|
/// TMDB id, compared against the one the tracker attached to the item.
|
|
pub tmdb_id: Option<u32>,
|
|
/// `IMDb` id as TMDB spells it (`tt0317248`), compared against the one
|
|
/// the tracker attached to the item. Absent until the metadata refresh
|
|
/// has run, and for the titles TMDB knows no `IMDb` id for.
|
|
pub imdb_id: Option<String>,
|
|
pub title: String,
|
|
pub year: Option<u16>,
|
|
}
|
|
|
|
/// What tied a release to a wanted title.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum MatchKind {
|
|
/// The tracker supplied a TMDB id and it is one of ours.
|
|
TmdbId,
|
|
/// The tracker supplied an `IMDb` id and it is one of ours.
|
|
ImdbId,
|
|
/// The parsed release name and the wanted title agree, years included.
|
|
TitleAndYear,
|
|
}
|
|
|
|
/// The ids a tracker attached to a feed item, as it spelled them.
|
|
///
|
|
/// Both are optional and independent: a definition emits whichever its
|
|
/// tracker carries, and `imdbid` is the one most of them carry (§6.2).
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct ReleaseIds {
|
|
pub tmdb_id: Option<u32>,
|
|
pub imdb_id: Option<String>,
|
|
}
|
|
|
|
/// One wanted title an RSS item was matched to.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct MovieMatch {
|
|
pub movie: MovieId,
|
|
pub kind: MatchKind,
|
|
}
|
|
|
|
/// The wanted title an RSS item belongs to, or `None` when nothing matches
|
|
/// conservatively.
|
|
///
|
|
/// `ids` are what the indexer attached to the item, not anything inferred
|
|
/// from the name; `claims` is the parsed release name.
|
|
#[must_use]
|
|
pub fn match_movie(
|
|
wanted: &[WantedMovie],
|
|
ids: &ReleaseIds,
|
|
claims: &NameClaims,
|
|
) -> Option<MovieMatch> {
|
|
// A season or episode tag makes this a TV release, whatever the title
|
|
// or the tracker-supplied id says. Movies are the only thing RSS grabs
|
|
// today (§13, phase 4), and trackers do mislabel ids.
|
|
if claims.episode.is_some() {
|
|
return None;
|
|
}
|
|
|
|
let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key);
|
|
if ids.tmdb_id.is_some() || imdb_id.is_some() {
|
|
// A title matched by both ids is still one title, so each wanted
|
|
// title is offered at most once and only a genuine disagreement
|
|
// between the two ids reads as an ambiguity.
|
|
return unique(wanted.iter().filter_map(|movie| {
|
|
let kind = if ids.tmdb_id.is_some() && movie.tmdb_id == ids.tmdb_id {
|
|
MatchKind::TmdbId
|
|
} else if imdb_id.is_some() && movie.imdb_id.as_deref().and_then(imdb_key) == imdb_id {
|
|
MatchKind::ImdbId
|
|
} else {
|
|
return None;
|
|
};
|
|
Some(MovieMatch {
|
|
movie: movie.id,
|
|
kind,
|
|
})
|
|
}));
|
|
}
|
|
|
|
let title = match_key(claims.title.as_deref()?);
|
|
if title.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
unique(
|
|
wanted
|
|
.iter()
|
|
.filter(|movie| {
|
|
match_key(&movie.title) == title && years_agree(movie.year, claims.year)
|
|
})
|
|
.map(|movie| MovieMatch {
|
|
movie: movie.id,
|
|
kind: MatchKind::TitleAndYear,
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// The digits of an `IMDb` title id, or `None` when the string is not one.
|
|
///
|
|
/// Trackers spell the same id several ways: TMDB and most feeds write
|
|
/// `tt0317248`, some Torznab definitions emit the bare number, and either
|
|
/// may or may not carry the leading zeros `IMDb` pads to seven digits. All
|
|
/// of those are the same title, and nothing else is an id at all.
|
|
fn imdb_key(id: &str) -> Option<&str> {
|
|
let digits = id.trim();
|
|
let digits = digits
|
|
.strip_prefix("tt")
|
|
.or_else(|| digits.strip_prefix("TT"))
|
|
.unwrap_or(digits);
|
|
if !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
|
return None;
|
|
}
|
|
// An id of nothing but zeros is a placeholder, not a title.
|
|
let trimmed = digits.trim_start_matches('0');
|
|
(!trimmed.is_empty()).then_some(trimmed)
|
|
}
|
|
|
|
/// A release with no year matches only a title that has none either: films
|
|
/// share titles across decades, and the remake is not the one that is wanted.
|
|
fn years_agree(wanted: Option<u16>, release: Option<u16>) -> bool {
|
|
match (wanted, release) {
|
|
(Some(wanted), Some(release)) => wanted == release,
|
|
(None, None) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Two matches are an ambiguity, and an ambiguity is not a match.
|
|
fn unique(mut matches: impl Iterator<Item = MovieMatch>) -> Option<MovieMatch> {
|
|
let first = matches.next()?;
|
|
matches.next().is_none().then_some(first)
|
|
}
|
|
|
|
/// One wanted episode, in the shape matching needs. The ids are the
|
|
/// series', not the episode's: trackers attach them to the show.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct WantedEpisode {
|
|
pub id: EpisodeId,
|
|
/// TMDB id of the series, compared against the one the tracker attached
|
|
/// to the item.
|
|
pub tmdb_id: Option<u32>,
|
|
/// `IMDb` id of the series, as TMDB spells it (`tt11301866`). Absent
|
|
/// until the metadata refresh has run.
|
|
pub imdb_id: Option<String>,
|
|
pub title: String,
|
|
pub season: u32,
|
|
pub episode: u32,
|
|
}
|
|
|
|
/// Which file shape satisfied a wanted episode.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum MatchShape {
|
|
/// A release holding exactly this one episode.
|
|
SingleEpisode,
|
|
/// A pack for exactly the season this episode belongs to.
|
|
SeasonPack,
|
|
}
|
|
|
|
/// What tied a release to a wanted episode, and what it is.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct EpisodeMatch {
|
|
pub episode: EpisodeId,
|
|
pub kind: MatchKind,
|
|
pub shape: MatchShape,
|
|
}
|
|
|
|
/// Whether a release satisfies one wanted episode, and as what.
|
|
///
|
|
/// Identity is decided as [`match_movie`] decides it — a supplied id that
|
|
/// matches nothing wanted ends the comparison, a junk id falls through to
|
|
/// the title — except the title lane compares the series title alone. TV
|
|
/// release names rarely state a year at all, and when they do it is the
|
|
/// season's air year rather than the series', so the year rule films live
|
|
/// by would starve this lane; exact title comparison is what keeps two
|
|
/// series sharing a word apart.
|
|
///
|
|
/// Only two shapes satisfy anything: a file holding exactly this episode
|
|
/// and a pack for exactly its season. Multi-episode files and multi-season
|
|
/// packs are left to the operator, who reaches them through the episode and
|
|
/// season decks (§6.2).
|
|
#[must_use]
|
|
pub fn match_episode(
|
|
wanted: &WantedEpisode,
|
|
ids: &ReleaseIds,
|
|
claims: &NameClaims,
|
|
) -> Option<EpisodeMatch> {
|
|
let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key);
|
|
let kind = if ids.tmdb_id.is_some() || imdb_id.is_some() {
|
|
if ids.tmdb_id.is_some() && wanted.tmdb_id == ids.tmdb_id {
|
|
MatchKind::TmdbId
|
|
} else if imdb_id.is_some() && wanted.imdb_id.as_deref().and_then(imdb_key) == imdb_id {
|
|
MatchKind::ImdbId
|
|
} else {
|
|
return None;
|
|
}
|
|
} else {
|
|
let title = match_key(claims.title.as_deref()?);
|
|
if title.is_empty() || title != match_key(&wanted.title) {
|
|
return None;
|
|
}
|
|
MatchKind::TitleAndYear
|
|
};
|
|
|
|
// Whatever the name or id says, nothing without a TV tag holds an
|
|
// episode, and no daily-show air date is a numbered one.
|
|
let claim = claims.episode.as_ref()?;
|
|
let shape = if is_this_seasons_pack(claim, wanted.season) {
|
|
MatchShape::SeasonPack
|
|
} else if is_single_episode(claim, wanted.season, wanted.episode) {
|
|
MatchShape::SingleEpisode
|
|
} else {
|
|
return None;
|
|
};
|
|
Some(EpisodeMatch {
|
|
episode: wanted.id,
|
|
kind,
|
|
shape,
|
|
})
|
|
}
|
|
|
|
/// A pack for exactly this season. Multi-season packs are never selected
|
|
/// automatically: they drag other seasons in with them.
|
|
fn is_this_seasons_pack(claim: &EpisodeClaim, season: u32) -> bool {
|
|
match claim {
|
|
EpisodeClaim::Season { season: claimed } => *claimed == season,
|
|
EpisodeClaim::Seasons { first, last } => first == last && *first == season,
|
|
EpisodeClaim::Episodes { .. } | EpisodeClaim::Daily { .. } => false,
|
|
}
|
|
}
|
|
|
|
/// A release holding exactly this one episode. Multi-episode files are left
|
|
/// to manual grabs: two episodes automatically picking the same torrent
|
|
/// would fight over one `grabs` row.
|
|
fn is_single_episode(claim: &EpisodeClaim, season: u32, episode: u32) -> bool {
|
|
matches!(
|
|
claim,
|
|
EpisodeClaim::Episodes { episodes, .. } if episodes.len() == 1
|
|
) && claim.covers(season, episode)
|
|
}
|
|
|
|
/// The form two titles are compared in: lower case, accents folded to ASCII,
|
|
/// `&` spelled out, and every run of anything else one space.
|
|
///
|
|
/// This absorbs the separators and punctuation a release name loses ("Dune:
|
|
/// Part Two" against `Dune.Part.Two`) without absorbing a word, so a longer
|
|
/// or shorter title stays a different title.
|
|
fn match_key(title: &str) -> String {
|
|
let mut key = String::with_capacity(title.len());
|
|
let mut gap = false;
|
|
for character in title.chars() {
|
|
if let Some(folded) = fold(character) {
|
|
separate(&mut key, &mut gap);
|
|
key.push_str(folded);
|
|
} else if character.is_alphanumeric() {
|
|
separate(&mut key, &mut gap);
|
|
key.extend(character.to_lowercase());
|
|
} else {
|
|
gap = !key.is_empty();
|
|
}
|
|
}
|
|
key
|
|
}
|
|
|
|
fn separate(key: &mut String, gap: &mut bool) {
|
|
if *gap {
|
|
key.push(' ');
|
|
}
|
|
*gap = false;
|
|
}
|
|
|
|
/// The ASCII a letter is written as when a tracker cannot spell the accent,
|
|
/// and the word an `&` is spelled out as. `None` leaves the character to
|
|
/// [`match_key`], which keeps any other letter or digit as it is.
|
|
fn fold(character: char) -> Option<&'static str> {
|
|
Some(match character {
|
|
'&' => "and",
|
|
'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => "a",
|
|
'ç' | 'Ç' => "c",
|
|
'è' | 'é' | 'ê' | 'ë' | 'È' | 'É' | 'Ê' | 'Ë' => "e",
|
|
'ì' | 'í' | 'î' | 'ï' | 'Ì' | 'Í' | 'Î' | 'Ï' => "i",
|
|
'ñ' | 'Ñ' => "n",
|
|
'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => "o",
|
|
'ù' | 'ú' | 'û' | 'ü' | 'Ù' | 'Ú' | 'Û' | 'Ü' => "u",
|
|
'ý' | 'ÿ' | 'Ý' => "y",
|
|
'ß' => "ss",
|
|
'æ' | 'Æ' => "ae",
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
match_episode, match_key, match_movie, EpisodeId, EpisodeMatch, MatchKind, MatchShape,
|
|
MovieMatch, ReleaseIds, WantedEpisode, WantedMovie,
|
|
};
|
|
use crate::MovieId;
|
|
|
|
fn wanted() -> Vec<WantedMovie> {
|
|
vec![
|
|
WantedMovie {
|
|
id: MovieId(1),
|
|
tmdb_id: Some(693_134),
|
|
imdb_id: Some("tt15239678".to_owned()),
|
|
title: "Dune: Part Two".to_owned(),
|
|
year: Some(2024),
|
|
},
|
|
WantedMovie {
|
|
id: MovieId(2),
|
|
tmdb_id: Some(438_631),
|
|
imdb_id: Some("tt1160419".to_owned()),
|
|
title: "Dune".to_owned(),
|
|
year: Some(2021),
|
|
},
|
|
WantedMovie {
|
|
id: MovieId(3),
|
|
tmdb_id: Some(194),
|
|
imdb_id: None,
|
|
title: "Amélie".to_owned(),
|
|
year: Some(2001),
|
|
},
|
|
]
|
|
}
|
|
|
|
fn ids(tmdb_id: Option<u32>, imdb_id: Option<&str>) -> ReleaseIds {
|
|
ReleaseIds {
|
|
tmdb_id,
|
|
imdb_id: imdb_id.map(ToOwned::to_owned),
|
|
}
|
|
}
|
|
|
|
fn matched(name: &str, tmdb_id: Option<u32>) -> Option<MovieMatch> {
|
|
match_movie(&wanted(), &ids(tmdb_id, None), &arr_parse::parse(name))
|
|
}
|
|
|
|
fn matched_by_imdb(name: &str, imdb_id: &str) -> Option<MovieMatch> {
|
|
match_movie(
|
|
&wanted(),
|
|
&ids(None, Some(imdb_id)),
|
|
&arr_parse::parse(name),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn a_supplied_id_matches_whatever_the_name_says() {
|
|
assert_eq!(
|
|
matched("Some.Scene.Name.2024.2160p.WEB-DL", Some(693_134)),
|
|
Some(MovieMatch {
|
|
movie: MovieId(1),
|
|
kind: MatchKind::TmdbId,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// The tracker said which film this is and it is not one of ours. Falling
|
|
/// back to the title here is how a wrong film gets grabbed.
|
|
#[test]
|
|
fn a_supplied_id_that_is_not_wanted_ends_the_comparison() {
|
|
assert_eq!(matched("Dune.Part.Two.2024.2160p.WEB-DL", Some(11)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn separators_punctuation_and_accents_do_not_block_a_title_match() {
|
|
assert_eq!(
|
|
matched("Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos-GROUP", None),
|
|
Some(MovieMatch {
|
|
movie: MovieId(1),
|
|
kind: MatchKind::TitleAndYear,
|
|
})
|
|
);
|
|
assert_eq!(
|
|
matched("Amelie.2001.1080p.BluRay.x264-GROUP", None),
|
|
Some(MovieMatch {
|
|
movie: MovieId(3),
|
|
kind: MatchKind::TitleAndYear,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// The near miss: one title is a prefix of the other, same year band,
|
|
/// same franchise. Nothing here may match.
|
|
#[test]
|
|
fn a_near_miss_does_not_match() {
|
|
assert_eq!(matched("Dune.2024.2160p.WEB-DL-GROUP", None), None);
|
|
assert_eq!(matched("Dune.Part.One.2021.1080p.WEB-DL", None), None);
|
|
assert_eq!(matched("Dune.Part.Two.2023.1080p.WEB-DL", None), None);
|
|
assert_eq!(matched("Dune.Prophecy.2024.1080p.WEB-DL", None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_release_with_no_year_is_not_matched_to_a_title_that_has_one() {
|
|
assert_eq!(matched("Dune.Part.Two.2160p.WEB-DL", None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn an_episode_tag_is_never_a_movie() {
|
|
let wanted = vec![WantedMovie {
|
|
id: MovieId(4),
|
|
tmdb_id: Some(1),
|
|
imdb_id: None,
|
|
title: "Fallout".to_owned(),
|
|
year: Some(2024),
|
|
}];
|
|
let claims = arr_parse::parse("Fallout.2024.S01E03.1080p.WEB-DL");
|
|
assert_eq!(match_movie(&wanted, &ReleaseIds::default(), &claims), None);
|
|
}
|
|
|
|
/// The same rule on the id lane (#139): trackers mislabel ids, so a
|
|
/// season or episode tag in the name vetoes an id that matches too.
|
|
#[test]
|
|
fn an_episode_tag_beats_a_matching_supplied_id() {
|
|
let wanted = vec![WantedMovie {
|
|
id: MovieId(1),
|
|
tmdb_id: Some(693_134),
|
|
imdb_id: Some("tt15239678".to_owned()),
|
|
title: "Dune: Part Two".to_owned(),
|
|
year: Some(2024),
|
|
}];
|
|
let cases = [
|
|
("Fallout.S01E03.1080p.WEB-DL", &ids(Some(693_134), None)),
|
|
("Fallout.S01.1080p.BluRay-GROUP", &ids(Some(693_134), None)),
|
|
(
|
|
"Fallout.2024.S01E03.1080p.WEB-DL",
|
|
&ids(None, Some("tt15239678")),
|
|
),
|
|
("Fallout.S01E03.1080p.WEB-DL", &ids(None, Some("15239678"))),
|
|
];
|
|
for (name, release_ids) in cases {
|
|
assert_eq!(
|
|
match_movie(&wanted, release_ids, &arr_parse::parse(name)),
|
|
None,
|
|
"{name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn two_wanted_titles_that_both_match_are_an_ambiguity() {
|
|
let wanted = vec![
|
|
WantedMovie {
|
|
id: MovieId(5),
|
|
tmdb_id: Some(1),
|
|
imdb_id: None,
|
|
title: "The Thing".to_owned(),
|
|
year: Some(1982),
|
|
},
|
|
WantedMovie {
|
|
id: MovieId(6),
|
|
tmdb_id: Some(2),
|
|
imdb_id: None,
|
|
title: "The Thing".to_owned(),
|
|
year: Some(1982),
|
|
},
|
|
];
|
|
let claims = arr_parse::parse("The.Thing.1982.1080p.BluRay");
|
|
assert_eq!(match_movie(&wanted, &ReleaseIds::default(), &claims), None);
|
|
}
|
|
|
|
#[test]
|
|
fn ampersands_and_spelling_agree() {
|
|
assert_eq!(match_key("Fast & Furious"), "fast and furious");
|
|
assert_eq!(match_key("Fast and Furious"), "fast and furious");
|
|
assert_eq!(match_key(" Dune: Part Two "), "dune part two");
|
|
}
|
|
|
|
#[test]
|
|
fn a_supplied_imdb_id_matches_whatever_the_name_says() {
|
|
assert_eq!(
|
|
matched_by_imdb("Some.Scene.Name.2024.2160p.WEB-DL", "tt15239678"),
|
|
Some(MovieMatch {
|
|
movie: MovieId(1),
|
|
kind: MatchKind::ImdbId,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// Torznab definitions spell the same id three ways. All of them are the
|
|
/// one title, and matching none of them is how the id lane stays unused.
|
|
#[test]
|
|
fn an_imdb_id_matches_however_the_tracker_spells_it() {
|
|
for spelling in ["tt15239678", "15239678", "tt0015239678", " tt15239678 "] {
|
|
assert_eq!(
|
|
matched_by_imdb("Some.Scene.Name.2024.2160p.WEB-DL", spelling),
|
|
Some(MovieMatch {
|
|
movie: MovieId(1),
|
|
kind: MatchKind::ImdbId,
|
|
}),
|
|
"{spelling}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_supplied_imdb_id_that_is_not_wanted_ends_the_comparison() {
|
|
assert_eq!(
|
|
matched_by_imdb("Dune.Part.Two.2024.2160p.WEB-DL", "tt0000001"),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// A placeholder or a non-id is no id at all, so the title lane still runs.
|
|
#[test]
|
|
fn a_junk_imdb_id_falls_through_to_the_title() {
|
|
for junk in ["", "tt", "tt0000000", "0", "n/a"] {
|
|
assert_eq!(
|
|
matched_by_imdb("Dune.Part.Two.2024.2160p.WEB-DL", junk),
|
|
Some(MovieMatch {
|
|
movie: MovieId(1),
|
|
kind: MatchKind::TitleAndYear,
|
|
}),
|
|
"{junk}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Both ids on the same title is one match, not an ambiguity, and the
|
|
/// TMDB id is what reports it.
|
|
#[test]
|
|
fn two_ids_that_agree_are_one_match() {
|
|
assert_eq!(
|
|
match_movie(
|
|
&wanted(),
|
|
&ids(Some(693_134), Some("tt15239678")),
|
|
&arr_parse::parse("Some.Scene.Name.2024.2160p.WEB-DL"),
|
|
),
|
|
Some(MovieMatch {
|
|
movie: MovieId(1),
|
|
kind: MatchKind::TmdbId,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// The tracker named two different films. Picking either is a coin toss
|
|
/// on which one gets grabbed.
|
|
#[test]
|
|
fn two_ids_that_disagree_match_nothing() {
|
|
assert_eq!(
|
|
match_movie(
|
|
&wanted(),
|
|
&ids(Some(693_134), Some("tt1160419")),
|
|
&arr_parse::parse("Dune.Part.Two.2024.2160p.WEB-DL"),
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// One id the library does not carry does not veto the other: a title
|
|
/// TMDB knows no `IMDb` id for is matched by its TMDB id as before.
|
|
#[test]
|
|
fn an_id_the_library_lacks_does_not_block_the_other() {
|
|
assert_eq!(
|
|
match_movie(
|
|
&wanted(),
|
|
&ids(Some(194), Some("tt0211915")),
|
|
&arr_parse::parse("Amelie.2001.1080p.BluRay"),
|
|
),
|
|
Some(MovieMatch {
|
|
movie: MovieId(3),
|
|
kind: MatchKind::TmdbId,
|
|
})
|
|
);
|
|
}
|
|
|
|
fn wanted_episode() -> WantedEpisode {
|
|
WantedEpisode {
|
|
id: EpisodeId(7),
|
|
tmdb_id: Some(934_644),
|
|
imdb_id: Some("tt11301866".to_owned()),
|
|
title: "Fallout".to_owned(),
|
|
season: 1,
|
|
episode: 3,
|
|
}
|
|
}
|
|
|
|
fn episode_ids(tmdb_id: Option<u32>, imdb_id: Option<&str>) -> ReleaseIds {
|
|
ids(tmdb_id, imdb_id)
|
|
}
|
|
|
|
/// `(release name, expected shape)` for releases that satisfy the
|
|
/// wanted `Fallout` S01E03, matched on title alone.
|
|
#[test]
|
|
fn real_single_episode_releases_match_their_episode() {
|
|
let cases = [
|
|
(
|
|
"Fallout.2024.S01E03.1080p.WEB-DL.DDP5.1.H.264-GROUP",
|
|
MatchShape::SingleEpisode,
|
|
),
|
|
(
|
|
"Fallout.S01E03.2160p.WEB-DL.DV.HDR10Plus.HEVC-GROUP",
|
|
MatchShape::SingleEpisode,
|
|
),
|
|
(
|
|
"Fallout.S01E03.720p.AMZN.WEBRip.DDP5.1.x264-GROUP",
|
|
MatchShape::SingleEpisode,
|
|
),
|
|
];
|
|
for (name, shape) in cases {
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted_episode(),
|
|
&ReleaseIds::default(),
|
|
&arr_parse::parse(name),
|
|
),
|
|
Some(EpisodeMatch {
|
|
episode: EpisodeId(7),
|
|
kind: MatchKind::TitleAndYear,
|
|
shape,
|
|
}),
|
|
"{name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The same series, offered as a pack for exactly its season.
|
|
#[test]
|
|
fn a_pack_for_this_season_matches_every_episode_in_it() {
|
|
let cases = [
|
|
(
|
|
"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP",
|
|
MatchShape::SeasonPack,
|
|
),
|
|
(
|
|
"Fallout.Season.1.1080p.BluRay.x265-GROUP",
|
|
MatchShape::SeasonPack,
|
|
),
|
|
(
|
|
"Fallout.S01.1080p.BluRay.x264-GROUP",
|
|
MatchShape::SeasonPack,
|
|
),
|
|
(
|
|
"Fallout.S01-S01.1080p.BluRay.x264-GROUP",
|
|
MatchShape::SeasonPack,
|
|
),
|
|
];
|
|
for (name, shape) in cases {
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted_episode(),
|
|
&ReleaseIds::default(),
|
|
&arr_parse::parse(name),
|
|
),
|
|
Some(EpisodeMatch {
|
|
episode: EpisodeId(7),
|
|
kind: MatchKind::TitleAndYear,
|
|
shape,
|
|
}),
|
|
"{name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `COMPLETE` before the season tag used to stay in the title and
|
|
/// break the exact-title comparison (#135).
|
|
#[test]
|
|
fn a_pack_marker_before_the_tag_still_matches() {
|
|
let wanted = WantedEpisode {
|
|
id: EpisodeId(10),
|
|
tmdb_id: None,
|
|
imdb_id: None,
|
|
title: "Bluey".to_owned(),
|
|
season: 1,
|
|
episode: 3,
|
|
};
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted,
|
|
&ReleaseIds::default(),
|
|
&arr_parse::parse("Bluey.COMPLETE.S01.1080p.WEB-DL-GROUP"),
|
|
),
|
|
Some(EpisodeMatch {
|
|
episode: EpisodeId(10),
|
|
kind: MatchKind::TitleAndYear,
|
|
shape: MatchShape::SeasonPack,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// Punctuation, case and accents in the series title do not block the
|
|
/// match.
|
|
#[test]
|
|
fn mangled_series_titles_still_match() {
|
|
let wanted = WantedEpisode {
|
|
id: EpisodeId(8),
|
|
tmdb_id: None,
|
|
imdb_id: None,
|
|
title: "Ms. Marvel".to_owned(),
|
|
season: 1,
|
|
episode: 3,
|
|
};
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted,
|
|
&ReleaseIds::default(),
|
|
&arr_parse::parse("Ms.Marvel.S01E03.1080p.DSNY.WEB-DL.DDP5.1.Atmos.H.264-GROUP"),
|
|
)
|
|
.map(|matched| matched.shape),
|
|
Some(MatchShape::SingleEpisode)
|
|
);
|
|
}
|
|
|
|
/// Two series that share a word are different series. Both pairs are
|
|
/// real shows.
|
|
#[test]
|
|
fn another_series_sharing_a_word_is_not_a_match() {
|
|
let lone_star = WantedEpisode {
|
|
id: EpisodeId(9),
|
|
tmdb_id: None,
|
|
imdb_id: None,
|
|
title: "9-1-1: Lone Star".to_owned(),
|
|
season: 4,
|
|
episode: 1,
|
|
};
|
|
assert_eq!(
|
|
match_episode(
|
|
&lone_star,
|
|
&ReleaseIds::default(),
|
|
&arr_parse::parse("9-1-1.S07E01.1080p.AMZN.WEB-DL.DDP5.1.H.264-GROUP"),
|
|
),
|
|
None
|
|
);
|
|
|
|
let fallout = wanted_episode();
|
|
assert_eq!(
|
|
match_episode(
|
|
&fallout,
|
|
&ReleaseIds::default(),
|
|
&arr_parse::parse("The.Fallout.2021.S01E03.1080p.WEB-DL"),
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// A tracker-supplied id identifies the series whatever the name says.
|
|
#[test]
|
|
fn a_supplied_id_matches_whatever_the_name_says_tv() {
|
|
for (tmdb_id, imdb_id) in [(Some(934_644), None), (None, Some("tt11301866"))] {
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted_episode(),
|
|
&episode_ids(tmdb_id, imdb_id),
|
|
&arr_parse::parse("Totally.Unrelated.Name.S01.1080p.WEB-DL-GROUP"),
|
|
),
|
|
Some(EpisodeMatch {
|
|
episode: EpisodeId(7),
|
|
kind: if tmdb_id.is_some() {
|
|
MatchKind::TmdbId
|
|
} else {
|
|
MatchKind::ImdbId
|
|
},
|
|
shape: MatchShape::SeasonPack,
|
|
}),
|
|
"{tmdb_id:?} {imdb_id:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Torznab definitions spell the same id several ways; all of them are
|
|
/// the one series.
|
|
#[test]
|
|
fn an_imdb_id_matches_however_the_tracker_spells_it_tv() {
|
|
for spelling in ["tt11301866", "11301866", "tt011301866"] {
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted_episode(),
|
|
&episode_ids(None, Some(spelling)),
|
|
&arr_parse::parse("Unrelated.Name.S01E03.1080p.WEB-DL"),
|
|
)
|
|
.map(|matched| matched.kind),
|
|
Some(MatchKind::ImdbId),
|
|
"{spelling}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Same rule as films: the tracker said which series this is and it is
|
|
/// not ours, so the title does not get a vote.
|
|
#[test]
|
|
fn a_supplied_id_that_is_not_wanted_ends_the_comparison_tv() {
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted_episode(),
|
|
&episode_ids(Some(1), None),
|
|
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted_episode(),
|
|
&episode_ids(None, Some("tt0000001")),
|
|
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// A placeholder or a non-id is no id at all, so the title lane still
|
|
/// runs.
|
|
#[test]
|
|
fn a_junk_imdb_id_falls_through_to_the_title_tv() {
|
|
for junk in ["", "tt", "tt0000000", "n/a"] {
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted_episode(),
|
|
&episode_ids(None, Some(junk)),
|
|
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
|
|
)
|
|
.map(|matched| matched.shape),
|
|
Some(MatchShape::SingleEpisode),
|
|
"{junk}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Everything that is not this exact episode or this exact season's
|
|
/// pack satisfies nothing, whoever issued it.
|
|
#[test]
|
|
fn every_other_shape_satisfies_nothing() {
|
|
let refusals = [
|
|
// Wrong episode, wrong season.
|
|
"Fallout.S01E04.1080p.WEB-DL-GROUP",
|
|
"Fallout.S02E03.1080p.WEB-DL-GROUP",
|
|
// Multi-episode files fight over one grabs row; manual only.
|
|
"Fallout.S01E02E03.1080p.WEB-DL-GROUP",
|
|
"Fallout.S01E02-E04.1080p.WEB-DL-GROUP",
|
|
// Another season's pack, and a multi-season pack.
|
|
"Fallout.S02.2160p.WEB-DL-GROUP",
|
|
"Fallout.S01-S03.1080p.BluRay-GROUP",
|
|
// A daily-show air date is not a numbered episode.
|
|
"Last.Week.Tonight.With.John.Oliver.2024.05.13.1080p.WEB-DL",
|
|
// No TV tag at all: a film, not an episode of anything.
|
|
"A.Complete.Unknown.2024.2160p.WEB-DL-GROUP",
|
|
];
|
|
for name in refusals {
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted_episode(),
|
|
&ReleaseIds::default(),
|
|
&arr_parse::parse(name),
|
|
),
|
|
None,
|
|
"{name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A degenerate range means one season's pack, not a multi-season one.
|
|
#[test]
|
|
fn a_degenerate_season_range_is_this_seasons_pack() {
|
|
use super::{is_single_episode, is_this_seasons_pack};
|
|
use arr_parse::EpisodeClaim::{Daily, Episodes, Season, Seasons};
|
|
assert!(is_this_seasons_pack(&Seasons { first: 1, last: 1 }, 1));
|
|
assert!(!is_this_seasons_pack(&Seasons { first: 1, last: 3 }, 1));
|
|
assert!(!is_this_seasons_pack(
|
|
&Episodes {
|
|
season: 1,
|
|
episodes: vec![3]
|
|
},
|
|
1
|
|
));
|
|
assert!(!is_this_seasons_pack(&Season { season: 2 }, 1));
|
|
assert!(!is_this_seasons_pack(
|
|
&Daily {
|
|
year: 2024,
|
|
month: 5,
|
|
day: 13
|
|
},
|
|
1
|
|
));
|
|
// A single episode of another season is still not this one.
|
|
assert!(!is_single_episode(
|
|
&Episodes {
|
|
season: 2,
|
|
episodes: vec![3]
|
|
},
|
|
1,
|
|
3
|
|
));
|
|
}
|
|
|
|
/// A daily show's own episode, dated but unnumbered, satisfies nothing:
|
|
/// there is no numbered wanted episode it can be filed against.
|
|
#[test]
|
|
fn a_daily_claim_never_satisfies_a_numbered_wanted_episode() {
|
|
let wanted = WantedEpisode {
|
|
id: EpisodeId(10),
|
|
tmdb_id: Some(46_298),
|
|
imdb_id: None,
|
|
title: "Last Week Tonight with John Oliver".to_owned(),
|
|
season: 11,
|
|
episode: 17,
|
|
};
|
|
assert_eq!(
|
|
match_episode(
|
|
&wanted,
|
|
&ReleaseIds::default(),
|
|
&arr_parse::parse("Last.Week.Tonight.With.John.Oliver.2024.05.13.1080p.WEB-DL"),
|
|
),
|
|
None
|
|
);
|
|
}
|
|
}
|