diff --git a/Cargo.lock b/Cargo.lock index 8904f19..95fdfc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,9 @@ version = "0.1.0" [[package]] name = "arr-core" version = "0.1.0" +dependencies = [ + "arr-parse", +] [[package]] name = "arr-daemon" diff --git a/crates/arr-core/Cargo.toml b/crates/arr-core/Cargo.toml index d0c87cd..bc13fef 100644 --- a/crates/arr-core/Cargo.toml +++ b/crates/arr-core/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true publish = false [dependencies] +arr-parse.workspace = true [lints] workspace = true diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index be1badf..04ef0a4 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -4,6 +4,10 @@ use std::{collections::BTreeMap, path::PathBuf, time::SystemTime}; +pub mod policy; + +pub use arr_parse::NameClaims as ParsedRelease; + macro_rules! id_type { ($name:ident) => { #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -56,12 +60,43 @@ pub enum Source { WebDl, WebRip, Hdtv, + Dvd, + Telecine, Telesync, Cam, Screener, Other, } +impl From for Resolution { + fn from(value: arr_parse::Resolution) -> Self { + match value { + arr_parse::Resolution::P480 => Self::Other(480), + arr_parse::Resolution::P576 => Self::Other(576), + arr_parse::Resolution::P720 => Self::R720p, + arr_parse::Resolution::P1080 => Self::R1080p, + arr_parse::Resolution::P2160 => Self::R2160p, + } + } +} + +impl From for Source { + fn from(value: arr_parse::Source) -> Self { + match value { + arr_parse::Source::Cam => Self::Cam, + arr_parse::Source::Telesync => Self::Telesync, + arr_parse::Source::Telecine => Self::Telecine, + arr_parse::Source::Screener => Self::Screener, + arr_parse::Source::Dvd => Self::Dvd, + arr_parse::Source::Hdtv => Self::Hdtv, + arr_parse::Source::WebRip => Self::WebRip, + arr_parse::Source::WebDl => Self::WebDl, + arr_parse::Source::BluRay => Self::BluRay, + arr_parse::Source::Remux => Self::Remux, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DolbyVisionProfile { pub profile: u8, @@ -77,14 +112,6 @@ pub enum HdrFormat { DolbyVision(DolbyVisionProfile), } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum HdrClaim { - Hdr10, - Hdr10Plus, - Hlg, - DolbyVision, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub enum RequiredAudio { OriginalLanguage, @@ -192,14 +219,6 @@ pub struct MediaFile { pub waiver: Option, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ParsedRelease { - pub resolution: Option, - pub source: Option, - pub hdr: Vec, - pub audio_languages: Vec, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct Release { pub id: ReleaseId, diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs new file mode 100644 index 0000000..1cb0b6c --- /dev/null +++ b/crates/arr-core/src/policy.rs @@ -0,0 +1,486 @@ +use arr_parse::NameClaims; + +use crate::{MovieOverrides, Policy, ProbedMedia, Resolution, Rule, Source, Verdict}; + +/// The evidence available while evaluating a candidate. +#[derive(Clone, Copy, Debug)] +pub enum Candidate<'a> { + /// Claims parsed from a release name before a grab. + PreGrab(&'a NameClaims), + /// Facts reported by `ffprobe` after a download. + PostDownload(&'a ProbedMedia), +} + +impl Candidate<'_> { + /// Which side of the grab boundary supplied the evidence. + #[must_use] + pub const fn phase(&self) -> EvaluationPhase { + match self { + Self::PreGrab(_) => EvaluationPhase::PreGrab, + Self::PostDownload(_) => EvaluationPhase::PostDownload, + } + } + + fn resolution(&self) -> Option { + match self { + Self::PreGrab(claims) => claims.resolution.map(Resolution::from), + Self::PostDownload(media) => Some(media.resolution), + } + } + + fn source(&self) -> Option { + match self { + Self::PreGrab(claims) => claims.source.map(Source::from), + Self::PostDownload(media) => media.source, + } + } +} + +/// The two phases of policy evaluation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EvaluationPhase { + PreGrab, + PostDownload, +} + +/// Everything a policy rule may inspect. +#[derive(Clone, Copy, Debug)] +pub struct EvaluationContext<'a> { + pub policy: &'a Policy, + pub overrides: &'a MovieOverrides, + pub candidate: Candidate<'a>, +} + +/// A rule's identity when no violation exists to carry concrete evidence. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuleKind { + RequiredAudio, + DubBlacklist, + DolbyVisionProfile, + Resolution, + Source, + Size, + Other(String), +} + +/// The result of evaluating one rule. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuleEvaluation { + Pass(RuleKind), + Unknown(RuleKind), + SoftFail(Rule), + HardFail(Rule), +} + +/// A policy rule that can participate in the verdict pipeline. +pub trait PolicyRule: std::fmt::Debug { + fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation; +} + +/// Full evaluation details, including rules whose evidence was unknown. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Evaluation { + pub verdict: Verdict, + pub rules: Vec, +} + +/// Apply the built-in filter rules and retain the verdict and every rule result. +#[must_use] +pub fn evaluate( + policy: &Policy, + overrides: &MovieOverrides, + candidate: Candidate<'_>, +) -> Evaluation { + let context = EvaluationContext { + policy, + overrides, + candidate, + }; + let rules: [&dyn PolicyRule; 2] = [&ResolutionRule, &SourceRule]; + evaluate_rules(&context, &rules) +} + +/// Reduce an ordered set of policy rules to a verdict. +/// +/// The first hard failure decides rejection. If none exists, the first soft +/// failure decides the waiver. Unknown evaluations abstain from reduction and +/// remain visible in the returned report. +#[must_use] +pub fn evaluate_rules(context: &EvaluationContext<'_>, rules: &[&dyn PolicyRule]) -> Evaluation { + let evaluations: Vec = + rules.iter().map(|rule| rule.evaluate(context)).collect(); + + let hard_failure = evaluations.iter().find_map(|evaluation| { + if let RuleEvaluation::HardFail(rule) = evaluation { + Some(rule.clone()) + } else { + None + } + }); + let soft_failure = || { + evaluations.iter().find_map(|evaluation| { + if let RuleEvaluation::SoftFail(rule) = evaluation { + Some(rule.clone()) + } else { + None + } + }) + }; + let verdict = hard_failure.map_or_else( + || soft_failure().map_or(Verdict::Eligible, Verdict::Waived), + Verdict::Rejected, + ); + + Evaluation { + verdict, + rules: evaluations, + } +} + +/// Checks whether the candidate's resolution is allowed by the effective policy. +#[derive(Clone, Copy, Debug, Default)] +pub struct ResolutionRule; + +impl PolicyRule for ResolutionRule { + fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation { + let Some(resolution) = context.candidate.resolution() else { + return RuleEvaluation::Unknown(RuleKind::Resolution); + }; + + let allowed_by_root = context.policy.resolution_preference.contains(&resolution); + if allowed_by_root && (!context.overrides.only_4k || resolution == Resolution::R2160p) { + RuleEvaluation::Pass(RuleKind::Resolution) + } else if allowed_by_root + && context.overrides.only_4k + && context.candidate.phase() == EvaluationPhase::PostDownload + { + RuleEvaluation::SoftFail(Rule::Resolution(resolution)) + } else { + RuleEvaluation::HardFail(Rule::Resolution(resolution)) + } + } +} + +/// Rejects sources that are never watchable release candidates. +#[derive(Clone, Copy, Debug, Default)] +pub struct SourceRule; + +impl PolicyRule for SourceRule { + fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation { + let Some(source) = context.candidate.source() else { + return RuleEvaluation::Unknown(RuleKind::Source); + }; + match source { + Source::Cam | Source::Telesync | Source::Screener => { + RuleEvaluation::HardFail(Rule::Source(source)) + } + Source::Other => RuleEvaluation::Unknown(RuleKind::Source), + Source::Dvd + | Source::Telecine + | Source::Hdtv + | Source::WebRip + | Source::WebDl + | Source::BluRay + | Source::Remux => RuleEvaluation::Pass(RuleKind::Source), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource}; + + use super::*; + use crate::{HdrFormat, HdrRules, PolicyId, RequiredAudio}; + + fn policy() -> Policy { + Policy { + id: PolicyId(1), + name: "test".to_owned(), + required_audio: RequiredAudio::OriginalLanguage, + dub_blacklist: Vec::new(), + hdr_rules: HdrRules { + rejected_dolby_vision_profiles: Vec::new(), + }, + size_bands: BTreeMap::new(), + resolution_preference: vec![Resolution::R2160p, Resolution::R1080p], + source_weights: BTreeMap::new(), + } + } + + fn claims(resolution: Option, source: Option) -> NameClaims { + NameClaims { + resolution, + source, + ..NameClaims::default() + } + } + + fn probed(resolution: Resolution, source: Option) -> ProbedMedia { + ProbedMedia { + resolution, + source, + hdr: HdrFormat::Sdr, + audio_tracks: Vec::new(), + subtitle_tracks: Vec::new(), + } + } + + fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict { + evaluate(policy, overrides, candidate).verdict + } + + #[test] + fn missing_pre_grab_evidence_stays_unknown() { + let policy = policy(); + let claims = claims(None, None); + let report = evaluate( + &policy, + &MovieOverrides::default(), + Candidate::PreGrab(&claims), + ); + + assert_eq!(report.verdict, Verdict::Eligible); + assert_eq!( + report.rules, + vec![ + RuleEvaluation::Unknown(RuleKind::Resolution), + RuleEvaluation::Unknown(RuleKind::Source), + ] + ); + } + + #[test] + fn preferred_resolutions_pass_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + + for claimed in [ClaimedResolution::P2160, ClaimedResolution::P1080] { + let claims = claims(Some(claimed), Some(ClaimedSource::WebDl)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Eligible + ); + } + for resolution in [Resolution::R2160p, Resolution::R1080p] { + let media = probed(resolution, Some(Source::WebDl)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Eligible + ); + } + } + + #[test] + fn resolution_outside_root_policy_hard_fails_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedResolution::P480, Resolution::Other(480)), + (ClaimedResolution::P576, Resolution::Other(576)), + (ClaimedResolution::P720, Resolution::R720p), + ]; + + for (claimed_resolution, resolution) in cases { + let claims = claims(Some(claimed_resolution), Some(ClaimedSource::WebDl)); + let media = probed(resolution, Some(Source::WebDl)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Rejected(Rule::Resolution(resolution)) + ); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Rejected(Rule::Resolution(resolution)) + ); + } + } + + #[test] + fn only_4k_tightens_the_root_policy_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides { + only_4k: true, + ..MovieOverrides::default() + }; + let claims_1080 = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); + let media = probed(Resolution::R1080p, Some(Source::WebDl)); + + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims_1080)), + Verdict::Rejected(Rule::Resolution(Resolution::R1080p)) + ); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Waived(Rule::Resolution(Resolution::R1080p)) + ); + + let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Eligible + ); + } + + #[test] + fn only_4k_never_expands_the_root_policy() { + let mut policy = policy(); + policy.resolution_preference = vec![Resolution::R1080p]; + let overrides = MovieOverrides { + only_4k: true, + ..MovieOverrides::default() + }; + let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl)); + + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Rejected(Rule::Resolution(Resolution::R2160p)) + ); + } + + #[test] + fn every_unsafe_source_hard_fails_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedSource::Cam, Source::Cam), + (ClaimedSource::Telesync, Source::Telesync), + (ClaimedSource::Screener, Source::Screener), + ]; + + for (claimed_source, source) in cases { + let claims = claims(Some(ClaimedResolution::P2160), Some(claimed_source)); + let media = probed(Resolution::R2160p, Some(source)); + let expected = Verdict::Rejected(Rule::Source(source)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + expected + ); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + expected + ); + } + } + + #[test] + fn parsed_telesync_name_is_rejected() { + let policy = policy(); + let claims = arr_parse::parse("Movie.2024.1080p.TS-GROUP"); + + assert_eq!( + verdict( + &policy, + &MovieOverrides::default(), + Candidate::PreGrab(&claims), + ), + Verdict::Rejected(Rule::Source(Source::Telesync)) + ); + } + + #[test] + fn every_safe_source_passes_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedSource::Dvd, Source::Dvd), + (ClaimedSource::Telecine, Source::Telecine), + (ClaimedSource::Hdtv, Source::Hdtv), + (ClaimedSource::WebRip, Source::WebRip), + (ClaimedSource::WebDl, Source::WebDl), + (ClaimedSource::BluRay, Source::BluRay), + (ClaimedSource::Remux, Source::Remux), + ]; + + for (claimed_source, source) in cases { + let claims = claims(Some(ClaimedResolution::P2160), Some(claimed_source)); + let media = probed(Resolution::R2160p, Some(source)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Eligible + ); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Eligible + ); + } + } + + #[test] + fn unknown_post_download_source_is_preserved_in_report() { + let policy = policy(); + for source in [None, Some(Source::Other)] { + let media = probed(Resolution::R2160p, source); + let report = evaluate( + &policy, + &MovieOverrides::default(), + Candidate::PostDownload(&media), + ); + + assert_eq!(report.verdict, Verdict::Eligible); + assert_eq!(report.rules[1], RuleEvaluation::Unknown(RuleKind::Source)); + } + } + + #[derive(Debug)] + struct FixedRule { + evaluation: RuleEvaluation, + } + + impl PolicyRule for FixedRule { + fn evaluate(&self, _context: &EvaluationContext<'_>) -> RuleEvaluation { + self.evaluation.clone() + } + } + + #[test] + fn hard_failure_wins_over_an_earlier_soft_failure() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let claims = claims(None, None); + let context = EvaluationContext { + policy: &policy, + overrides: &overrides, + candidate: Candidate::PreGrab(&claims), + }; + let soft = FixedRule { + evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), + }; + let hard = FixedRule { + evaluation: RuleEvaluation::HardFail(Rule::Other("hard".to_owned())), + }; + + let report = evaluate_rules(&context, &[&soft, &hard]); + + assert_eq!( + report.verdict, + Verdict::Rejected(Rule::Other("hard".to_owned())) + ); + } + + #[test] + fn first_failure_of_the_deciding_kind_is_stable() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let claims = claims(None, None); + let context = EvaluationContext { + policy: &policy, + overrides: &overrides, + candidate: Candidate::PreGrab(&claims), + }; + let first = FixedRule { + evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), + }; + let second = FixedRule { + evaluation: RuleEvaluation::HardFail(Rule::Other("second".to_owned())), + }; + + let report = evaluate_rules(&context, &[&first, &second]); + + assert_eq!( + report.verdict, + Verdict::Rejected(Rule::Other("first".to_owned())) + ); + } +}