@@ -15,7 +15,10 @@ pub mod tracking;
|
||||
|
||||
pub use arr_parse::NameClaims as ParsedRelease;
|
||||
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
|
||||
pub use matching::{match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie};
|
||||
pub use matching::{
|
||||
match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
|
||||
WantedEpisode, WantedMovie,
|
||||
};
|
||||
pub use score::{Score, ScoreWeights};
|
||||
pub use status::{derive_series_status, SeriesStatus};
|
||||
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
//! - 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::MovieId;
|
||||
use arr_parse::NameClaims;
|
||||
use crate::{EpisodeId, MovieId};
|
||||
use arr_parse::{EpisodeClaim, NameClaims};
|
||||
|
||||
/// A wanted title, in the shape matching needs.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -150,6 +154,113 @@ fn unique(mut matches: impl Iterator<Item = MovieMatch>) -> Option<MovieMatch> {
|
||||
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.
|
||||
///
|
||||
@@ -202,7 +313,10 @@ fn fold(character: char) -> Option<&'static str> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{match_key, match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie};
|
||||
use super::{
|
||||
match_episode, match_key, match_movie, EpisodeId, EpisodeMatch, MatchKind, MatchShape,
|
||||
MovieMatch, ReleaseIds, WantedEpisode, WantedMovie,
|
||||
};
|
||||
use crate::MovieId;
|
||||
|
||||
fn wanted() -> Vec<WantedMovie> {
|
||||
@@ -440,4 +554,315 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
];
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `S01-S01` does not parse as a claim (`arr-parse` takes only a real
|
||||
/// range), but the lifted rule is what a degenerate range would mean:
|
||||
/// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user