feat: RSS sync worker (#91)
This commit was merged in pull request #91.
This commit is contained in:
@@ -6,12 +6,14 @@ use std::{collections::BTreeMap, fmt, path::PathBuf, time::SystemTime};
|
||||
|
||||
pub mod lang;
|
||||
pub mod layout;
|
||||
pub mod matching;
|
||||
pub mod policy;
|
||||
pub mod score;
|
||||
pub mod status;
|
||||
pub mod tracking;
|
||||
|
||||
pub use arr_parse::NameClaims as ParsedRelease;
|
||||
pub use matching::{match_movie, MatchKind, MovieMatch, WantedMovie};
|
||||
pub use score::{Score, ScoreWeights};
|
||||
pub use status::{derive_series_status, SeriesStatus};
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! 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, and
|
||||
//! an ID that matches nothing wanted ends the comparison rather than
|
||||
//! falling back to the title;
|
||||
//! - 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.
|
||||
|
||||
use crate::MovieId;
|
||||
use arr_parse::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>,
|
||||
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 parsed release name and the wanted title agree, years included.
|
||||
TitleAndYear,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// `tmdb_id` is 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>,
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// A season or episode tag makes this a TV release, whatever the title
|
||||
// says. Movies are the only thing RSS grabs today (§13, phase 4).
|
||||
if claims.episode.is_some() {
|
||||
return None;
|
||||
}
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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_key, match_movie, MatchKind, MovieMatch, WantedMovie};
|
||||
use crate::MovieId;
|
||||
|
||||
fn wanted() -> Vec<WantedMovie> {
|
||||
vec![
|
||||
WantedMovie {
|
||||
id: MovieId(1),
|
||||
tmdb_id: Some(693_134),
|
||||
title: "Dune: Part Two".to_owned(),
|
||||
year: Some(2024),
|
||||
},
|
||||
WantedMovie {
|
||||
id: MovieId(2),
|
||||
tmdb_id: Some(438_631),
|
||||
title: "Dune".to_owned(),
|
||||
year: Some(2021),
|
||||
},
|
||||
WantedMovie {
|
||||
id: MovieId(3),
|
||||
tmdb_id: Some(194),
|
||||
title: "Amélie".to_owned(),
|
||||
year: Some(2001),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn matched(name: &str, tmdb_id: Option<u32>) -> Option<MovieMatch> {
|
||||
match_movie(&wanted(), tmdb_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),
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_wanted_titles_that_both_match_are_an_ambiguity() {
|
||||
let wanted = vec![
|
||||
WantedMovie {
|
||||
id: MovieId(5),
|
||||
tmdb_id: Some(1),
|
||||
title: "The Thing".to_owned(),
|
||||
year: Some(1982),
|
||||
},
|
||||
WantedMovie {
|
||||
id: MovieId(6),
|
||||
tmdb_id: Some(2),
|
||||
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);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user