feat: match RSS results by IMDb id (#99)
This commit was merged in pull request #99.
This commit is contained in:
@@ -15,7 +15,7 @@ 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, WantedMovie};
|
||||
pub use matching::{match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie};
|
||||
pub use score::{Score, ScoreWeights};
|
||||
pub use status::{derive_series_status, SeriesStatus};
|
||||
|
||||
|
||||
+181
-16
@@ -9,6 +9,8 @@
|
||||
//! - an ID the tracker itself supplied beats anything read off the name, 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.
|
||||
@@ -22,6 +24,10 @@ 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>,
|
||||
}
|
||||
@@ -31,10 +37,22 @@ pub struct WantedMovie {
|
||||
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 {
|
||||
@@ -45,24 +63,32 @@ pub struct MovieMatch {
|
||||
/// The wanted title an RSS item belongs to, or `None` when nothing matches
|
||||
/// conservatively.
|
||||
///
|
||||
/// `tmdb_id` is what the indexer attached to the item, not anything inferred
|
||||
/// `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],
|
||||
tmdb_id: Option<u32>,
|
||||
ids: &ReleaseIds,
|
||||
claims: &NameClaims,
|
||||
) -> Option<MovieMatch> {
|
||||
if let Some(tmdb_id) = tmdb_id {
|
||||
return unique(
|
||||
wanted
|
||||
.iter()
|
||||
.filter(|movie| movie.tmdb_id == Some(tmdb_id))
|
||||
.map(|movie| MovieMatch {
|
||||
movie: movie.id,
|
||||
kind: MatchKind::TmdbId,
|
||||
}),
|
||||
);
|
||||
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,
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
// A season or episode tag makes this a TV release, whatever the title
|
||||
@@ -88,6 +114,26 @@ pub fn match_movie(
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -156,7 +202,7 @@ fn fold(character: char) -> Option<&'static str> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{match_key, match_movie, MatchKind, MovieMatch, WantedMovie};
|
||||
use super::{match_key, match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie};
|
||||
use crate::MovieId;
|
||||
|
||||
fn wanted() -> Vec<WantedMovie> {
|
||||
@@ -164,26 +210,44 @@ mod tests {
|
||||
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(), tmdb_id, &arr_parse::parse(name))
|
||||
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]
|
||||
@@ -242,11 +306,12 @@ mod tests {
|
||||
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, None, &claims), None);
|
||||
assert_eq!(match_movie(&wanted, &ReleaseIds::default(), &claims), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -255,18 +320,20 @@ mod tests {
|
||||
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, None, &claims), None);
|
||||
assert_eq!(match_movie(&wanted, &ReleaseIds::default(), &claims), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -275,4 +342,102 @@ mod tests {
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user