feat(core): add policy verdict pipeline

This commit is contained in:
Miguel Palhas
2026-08-22 19:56:59 +01:00
parent 3e57cf5bf8
commit 77b947a85d
4 changed files with 538 additions and 8 deletions
Generated
+3
View File
@@ -28,6 +28,9 @@ version = "0.1.0"
[[package]]
name = "arr-core"
version = "0.1.0"
dependencies = [
"arr-parse",
]
[[package]]
name = "arr-daemon"
+1
View File
@@ -7,6 +7,7 @@ repository.workspace = true
publish = false
[dependencies]
arr-parse.workspace = true
[lints]
workspace = true
+37 -8
View File
@@ -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<arr_parse::Resolution> 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<arr_parse::Source> 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,
@@ -169,7 +204,9 @@ pub enum Rule {
RequiredAudio,
DubBlacklist(Language),
DolbyVisionProfile(DolbyVisionProfile),
ResolutionUnknown,
Resolution(Resolution),
SourceUnknown,
Source(Source),
Size,
Other(String),
@@ -192,14 +229,6 @@ pub struct MediaFile {
pub waiver: Option<Rule>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParsedRelease {
pub resolution: Option<Resolution>,
pub source: Option<Source>,
pub hdr: Vec<HdrClaim>,
pub audio_languages: Vec<Language>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Release {
pub id: ReleaseId,
+497
View File
@@ -0,0 +1,497 @@
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<Resolution> {
match self {
Self::PreGrab(claims) => claims.resolution.map(Resolution::from),
Self::PostDownload(media) => Some(media.resolution),
}
}
fn source(&self) -> Option<Source> {
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 result before the pipeline reduces all results to one verdict.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuleOutcome {
Pass,
Unknown,
SoftFail,
HardFail,
}
/// The result of evaluating one named rule.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuleEvaluation {
pub rule: Rule,
pub outcome: RuleOutcome,
}
/// 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<RuleEvaluation>,
}
/// Apply the built-in filter rules and return the candidate's bucket.
#[must_use]
pub fn evaluate(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict {
evaluate_with_report(policy, overrides, candidate).verdict
}
/// Apply the built-in filter rules and retain each rule's result.
#[must_use]
pub fn evaluate_with_report(
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 evidence never becomes a pass.
#[must_use]
pub fn evaluate_rules(context: &EvaluationContext<'_>, rules: &[&dyn PolicyRule]) -> Evaluation {
let evaluations: Vec<RuleEvaluation> =
rules.iter().map(|rule| rule.evaluate(context)).collect();
let deciding_rule = |outcome| {
evaluations
.iter()
.find(|evaluation| evaluation.outcome == outcome)
.map(|evaluation| evaluation.rule.clone())
};
let verdict = deciding_rule(RuleOutcome::HardFail).map_or_else(
|| deciding_rule(RuleOutcome::SoftFail).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 {
rule: Rule::ResolutionUnknown,
outcome: RuleOutcome::Unknown,
};
};
let accepted = context.policy.resolution_preference.contains(&resolution)
&& (!context.overrides.only_4k || resolution == Resolution::R2160p);
let outcome = if accepted {
RuleOutcome::Pass
} else if context.candidate.phase() == EvaluationPhase::PostDownload {
RuleOutcome::SoftFail
} else {
RuleOutcome::HardFail
};
RuleEvaluation {
rule: Rule::Resolution(resolution),
outcome,
}
}
}
/// 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 {
rule: Rule::SourceUnknown,
outcome: RuleOutcome::Unknown,
};
};
let outcome = match source {
Source::Cam | Source::Telesync | Source::Screener => RuleOutcome::HardFail,
Source::Other => RuleOutcome::Unknown,
Source::Dvd
| Source::Telecine
| Source::Hdtv
| Source::WebRip
| Source::WebDl
| Source::BluRay
| Source::Remux => RuleOutcome::Pass,
};
RuleEvaluation {
rule: Rule::Source(source),
outcome,
}
}
}
#[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<ClaimedResolution>, source: Option<ClaimedSource>) -> NameClaims {
NameClaims {
resolution,
source,
..NameClaims::default()
}
}
fn probed(resolution: Resolution, source: Option<Source>) -> ProbedMedia {
ProbedMedia {
resolution,
source,
hdr: HdrFormat::Sdr,
audio_tracks: Vec::new(),
subtitle_tracks: Vec::new(),
}
}
#[test]
fn missing_pre_grab_evidence_stays_unknown() {
let policy = policy();
let claims = claims(None, None);
let report = evaluate_with_report(
&policy,
&MovieOverrides::default(),
Candidate::PreGrab(&claims),
);
assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!(
report.rules,
vec![
RuleEvaluation {
rule: Rule::ResolutionUnknown,
outcome: RuleOutcome::Unknown,
},
RuleEvaluation {
rule: Rule::SourceUnknown,
outcome: RuleOutcome::Unknown,
},
]
);
}
#[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!(
evaluate(&policy, &overrides, Candidate::PreGrab(&claims)),
Verdict::Eligible
);
}
for resolution in [Resolution::R2160p, Resolution::R1080p] {
let media = probed(resolution, Some(Source::WebDl));
assert_eq!(
evaluate(&policy, &overrides, Candidate::PostDownload(&media)),
Verdict::Eligible
);
}
}
#[test]
fn every_disallowed_resolution_is_hard_before_grab_and_soft_after_download() {
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!(
evaluate(&policy, &overrides, Candidate::PreGrab(&claims)),
Verdict::Rejected(Rule::Resolution(resolution))
);
assert_eq!(
evaluate(&policy, &overrides, Candidate::PostDownload(&media)),
Verdict::Waived(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!(
evaluate(&policy, &overrides, Candidate::PreGrab(&claims_1080)),
Verdict::Rejected(Rule::Resolution(Resolution::R1080p))
);
assert_eq!(
evaluate(&policy, &overrides, Candidate::PostDownload(&media)),
Verdict::Waived(Rule::Resolution(Resolution::R1080p))
);
let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl));
assert_eq!(
evaluate(&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!(
evaluate(&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!(
evaluate(&policy, &overrides, Candidate::PreGrab(&claims)),
expected
);
assert_eq!(
evaluate(&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!(
evaluate(
&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!(
evaluate(&policy, &overrides, Candidate::PreGrab(&claims)),
Verdict::Eligible
);
assert_eq!(
evaluate(&policy, &overrides, Candidate::PostDownload(&media)),
Verdict::Eligible
);
}
}
#[test]
fn unknown_post_download_source_is_not_treated_as_a_pass() {
let policy = policy();
for source in [None, Some(Source::Other)] {
let media = probed(Resolution::R2160p, source);
let report = evaluate_with_report(
&policy,
&MovieOverrides::default(),
Candidate::PostDownload(&media),
);
assert_eq!(report.rules[1].outcome, RuleOutcome::Unknown);
}
}
#[derive(Debug)]
struct FixedRule {
rule: Rule,
outcome: RuleOutcome,
}
impl PolicyRule for FixedRule {
fn evaluate(&self, _context: &EvaluationContext<'_>) -> RuleEvaluation {
RuleEvaluation {
rule: self.rule.clone(),
outcome: self.outcome,
}
}
}
#[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 {
rule: Rule::Other("soft".to_owned()),
outcome: RuleOutcome::SoftFail,
};
let hard = FixedRule {
rule: Rule::Other("hard".to_owned()),
outcome: RuleOutcome::HardFail,
};
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 {
rule: Rule::Other("first".to_owned()),
outcome: RuleOutcome::HardFail,
};
let second = FixedRule {
rule: Rule::Other("second".to_owned()),
outcome: RuleOutcome::HardFail,
};
let report = evaluate_rules(&context, &[&first, &second]);
assert_eq!(
report.verdict,
Verdict::Rejected(Rule::Other("first".to_owned()))
);
}
}