Files
arr/crates/arr-core/src/policy.rs
T
Miguel PalhasandClaude Fable 5 917aa4fa76 feat: scale size bands by episode runtime
Implements #209 per §5.5 as amended by #208: a band's floor and target
are rates against a 45-minute reference runtime, scaled by the series'
minutes per episode. A missing or zero runtime applies the bands
unscaled, and movies are never scaled. The runtime is stored on the
series row (new migration), filled on add and by the metadata refresh,
which never blanks a known value against TMDB's frequently-empty
episode_run_time. Composes with #210: allow_below_floor waives against
the scaled floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:45:55 +01:00

1342 lines
44 KiB
Rust

use arr_parse::{LanguageMarker, NameClaims};
use crate::{
lang::{is_resolved_portuguese, language_of_marker},
HdrFormat, Language, Policy, ProbedMedia, RequiredAudio, Resolution, Rule, Source,
TitleOverrides, 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,
}
}
/// The candidate's resolution, from whichever evidence it carries.
#[must_use]
pub fn resolution(&self) -> Option<Resolution> {
match self {
Self::PreGrab(claims) => claims.resolution.map(Resolution::from),
Self::PostDownload(media) => Some(media.resolution),
}
}
/// The candidate's source tier, from whichever evidence it carries.
#[must_use]
pub 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 TitleOverrides,
/// The title's original language from TMDB (`DESIGN.md` §5.2) — a
/// distinct input from any track's language.
pub original_language: &'a Language,
pub candidate: Candidate<'a>,
/// The release's size pre-grab, the file's size post-download. `None`
/// when the caller has no size to offer, which abstains rather than
/// rejects.
pub size_bytes: Option<u64>,
/// How many episodes `size_bytes` covers (`DESIGN.md` §5.5) — a size band
/// describes one episode, so the size rule divides by this. One for a
/// movie or an unknown count; zero is treated as one.
pub episode_count: u32,
/// The series' minutes per episode (`DESIGN.md` §5.5) — a size band is a
/// rate against 45 minutes, so the size rule scales its floor by
/// `runtime / 45`. Zero is a missing runtime and applies the band
/// unscaled; movies are never scaled and pass zero.
pub runtime_minutes: u32,
}
/// 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<RuleEvaluation>,
}
/// Apply the built-in filter rules and retain the verdict and every rule result.
#[must_use]
pub fn evaluate(
policy: &Policy,
overrides: &TitleOverrides,
original_language: &Language,
candidate: Candidate<'_>,
size_bytes: Option<u64>,
episode_count: u32,
runtime_minutes: u32,
) -> Evaluation {
let context = EvaluationContext {
policy,
overrides,
original_language,
candidate,
size_bytes,
episode_count,
runtime_minutes,
};
let rules: [&dyn PolicyRule; 6] = [
&ResolutionRule,
&SourceRule,
&SizeRule,
&HdrRule,
&DubBlacklistRule,
&RequiredAudioRule,
];
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<RuleEvaluation> =
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),
}
}
}
/// Rejects anything below its size band's floor (`DESIGN.md` §5.5).
///
/// The floor is a hard filter rather than a low score, because unbounded
/// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far
/// a candidate sits from the band's *target* is [`crate::score`]'s question,
/// not this rule's.
///
/// `allow_below_floor` softens the floor for one title rather than lifting
/// it: a below-floor release becomes a waiver, so it is never picked
/// automatically and its import is recorded as a §5.7 waiver. This mirrors
/// [`ResolutionRule`], where an override moves a failure between hard and
/// soft and never makes the rule stop applying.
#[derive(Clone, Copy, Debug, Default)]
pub struct SizeRule;
impl PolicyRule for SizeRule {
fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation {
let (Some(resolution), Some(size)) = (context.candidate.resolution(), context.size_bytes)
else {
return RuleEvaluation::Unknown(RuleKind::Size);
};
match crate::score::is_below_floor(
context.policy,
resolution,
size,
context.episode_count,
context.runtime_minutes,
) {
None => RuleEvaluation::Unknown(RuleKind::Size),
Some(true) if context.overrides.allow_below_floor => {
RuleEvaluation::SoftFail(Rule::Size)
}
Some(true) => RuleEvaluation::HardFail(Rule::Size),
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
}
}
}
/// Applies Dolby Vision profile policy using post-download probe evidence.
#[derive(Clone, Copy, Debug, Default)]
pub struct HdrRule;
impl PolicyRule for HdrRule {
fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation {
let Candidate::PostDownload(media) = context.candidate else {
return RuleEvaluation::Unknown(RuleKind::DolbyVisionProfile);
};
let HdrFormat::DolbyVision(profile) = media.hdr else {
return RuleEvaluation::Pass(RuleKind::DolbyVisionProfile);
};
let rejected = context
.policy
.hdr_rules
.rejected_dolby_vision_profiles
.iter()
.any(|blocked| {
blocked.profile == profile.profile
&& blocked.compatibility_id.is_none_or(|compatibility_id| {
profile.compatibility_id == Some(compatibility_id)
})
});
if rejected {
RuleEvaluation::HardFail(Rule::DolbyVisionProfile(profile))
} else {
RuleEvaluation::Pass(RuleKind::DolbyVisionProfile)
}
}
}
/// The one language expression from `DESIGN.md` §5.2:
///
/// ```text
/// reject audio track T if T.lang ∈ dub_blacklist and T.lang ≠ title.original_language
/// ```
///
/// A blacklisted dub of a title in another language never counts; a title's
/// own soundtrack always escapes its blacklist entry.
fn is_blacklisted_dub(context: &EvaluationContext<'_>, language: &Language) -> bool {
context.policy.dub_blacklist.contains(language) && language != context.original_language
}
/// Languages the release name claims as audio. `MULTi`/`DUAL` claim extra
/// unnamed tracks and map to no language.
fn claimed_languages(claims: &NameClaims) -> Vec<Language> {
claims
.languages
.iter()
.filter_map(|marker| language_of_marker(*marker))
.collect()
}
fn claims_extra_tracks(claims: &NameClaims) -> bool {
claims
.languages
.iter()
.any(|marker| matches!(marker, LanguageMarker::Multi | LanguageMarker::Dual))
}
/// Rejects candidates whose audio is nothing but blacklisted dubs.
///
/// A blacklisted track riding along other audio is fine (`DESIGN.md` §5.2:
/// "Others may ride along") — whether the rest satisfies the policy is
/// [`RequiredAudioRule`]'s question.
#[derive(Clone, Copy, Debug, Default)]
pub struct DubBlacklistRule;
impl PolicyRule for DubBlacklistRule {
fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation {
let (languages, extra_tracks) = match context.candidate {
Candidate::PreGrab(claims) => (claimed_languages(claims), claims_extra_tracks(claims)),
Candidate::PostDownload(media) => (
media
.audio_tracks
.iter()
.map(|track| track.language.clone())
.collect(),
false,
),
};
if languages.is_empty() {
return RuleEvaluation::Unknown(RuleKind::DubBlacklist);
}
let all_dubs = languages
.iter()
.all(|language| is_blacklisted_dub(context, language));
if all_dubs && !extra_tracks {
let dub = languages[0].clone();
return RuleEvaluation::HardFail(Rule::DubBlacklist(dub));
}
RuleEvaluation::Pass(RuleKind::DubBlacklist)
}
}
/// Checks that a track satisfying the policy's audio requirement exists.
///
/// The required set is the original language for `main`-style policies, or
/// the policy's own list for `kids`-style ones — plus the original language
/// when that is already a resolved Portuguese variant (`DESIGN.md` §5.2). A
/// blacklisted dub never satisfies the requirement.
///
/// An unresolved `por-unverified` track that could satisfy the requirement
/// abstains pre-grab and soft-fails post-download, so it surfaces as a
/// waiver instead of a guess in either direction.
#[derive(Clone, Copy, Debug, Default)]
pub struct RequiredAudioRule;
impl RequiredAudioRule {
fn required_set(context: &EvaluationContext<'_>) -> Vec<Language> {
match &context.policy.required_audio {
RequiredAudio::OriginalLanguage => vec![context.original_language.clone()],
RequiredAudio::AnyOf(languages) => {
let mut required = languages.clone();
if is_resolved_portuguese(context.original_language)
&& !required.contains(context.original_language)
{
required.push(context.original_language.clone());
}
required
}
}
}
fn failure(context: &EvaluationContext<'_>) -> RuleEvaluation {
match &context.policy.required_audio {
// §5.7: no original-language audio at all is useless — hard.
RequiredAudio::OriginalLanguage => RuleEvaluation::HardFail(Rule::RequiredAudio),
// §5.7: required audio missing but watchable — import, waive.
RequiredAudio::AnyOf(_) => RuleEvaluation::SoftFail(Rule::RequiredAudio),
}
}
}
impl PolicyRule for RequiredAudioRule {
fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation {
let required = Self::required_set(context);
let satisfies = |language: &Language| {
required.contains(language) && !is_blacklisted_dub(context, language)
};
let english = Language::Other("en".to_owned());
let unverified_could_satisfy = |languages: &[Language]| {
languages.contains(&Language::PortugueseUnverified)
&& required.iter().any(is_resolved_portuguese)
};
match context.candidate {
Candidate::PreGrab(claims) => {
let languages = claimed_languages(claims);
if languages.iter().any(satisfies) {
return RuleEvaluation::Pass(RuleKind::RequiredAudio);
}
if context.overrides.allow_english_audio && languages.contains(&english) {
return RuleEvaluation::Pass(RuleKind::RequiredAudio);
}
if languages.is_empty()
|| claims_extra_tracks(claims)
|| unverified_could_satisfy(&languages)
{
return RuleEvaluation::Unknown(RuleKind::RequiredAudio);
}
Self::failure(context)
}
Candidate::PostDownload(media) => {
let languages: Vec<Language> = media
.audio_tracks
.iter()
.map(|track| track.language.clone())
.collect();
if languages.is_empty() {
return RuleEvaluation::HardFail(Rule::RequiredAudio);
}
if languages.iter().any(satisfies) {
return RuleEvaluation::Pass(RuleKind::RequiredAudio);
}
if unverified_could_satisfy(&languages) {
return RuleEvaluation::SoftFail(Rule::PortugueseUnverified);
}
if context.overrides.allow_english_audio && languages.contains(&english) {
return RuleEvaluation::SoftFail(Rule::RequiredAudio);
}
Self::failure(context)
}
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource};
use super::*;
use crate::{
AudioTrack, DolbyVisionProfile, HdrFormat, HdrRules, PolicyId, RequiredAudio, SubtitleTrack,
};
fn en() -> Language {
Language::Other("en".to_owned())
}
/// The `main` root policy from `DESIGN.md` §5.2: require the original
/// language, blacklist pt-BR dubs.
fn policy() -> Policy {
Policy {
id: PolicyId(1),
name: "test".to_owned(),
required_audio: RequiredAudio::OriginalLanguage,
dub_blacklist: vec![Language::PortugueseBrazil],
hdr_rules: HdrRules {
rejected_dolby_vision_profiles: vec![
DolbyVisionProfile {
profile: 5,
compatibility_id: None,
},
DolbyVisionProfile {
profile: 7,
compatibility_id: None,
},
],
},
size_bands: BTreeMap::new(),
resolution_preference: vec![Resolution::R2160p, Resolution::R1080p],
source_weights: BTreeMap::new(),
score_weights: crate::ScoreWeights::default(),
}
}
/// The `kids` root policy from `DESIGN.md` §5.2: require pt-PT (or the
/// original language when that is already Portuguese), blacklist pt-BR.
fn kids_policy() -> Policy {
Policy {
required_audio: RequiredAudio::AnyOf(vec![Language::PortuguesePortugal]),
..policy()
}
}
fn claims(resolution: Option<ClaimedResolution>, source: Option<ClaimedSource>) -> NameClaims {
NameClaims {
resolution,
source,
..NameClaims::default()
}
}
fn track(language: Language) -> AudioTrack {
AudioTrack {
language,
title: None,
handler_name: None,
}
}
fn probed(resolution: Resolution, source: Option<Source>) -> ProbedMedia {
ProbedMedia {
resolution,
source,
hdr: HdrFormat::Sdr,
audio_tracks: vec![track(en())],
subtitle_tracks: Vec::new(),
}
}
fn probed_audio(tracks: Vec<AudioTrack>) -> ProbedMedia {
ProbedMedia {
audio_tracks: tracks,
..probed(Resolution::R2160p, Some(Source::WebDl))
}
}
fn name_claims(markers: &[LanguageMarker]) -> NameClaims {
NameClaims {
languages: markers.to_vec(),
..claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl))
}
}
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict
}
fn verdict_for(
policy: &Policy,
original_language: &Language,
candidate: Candidate<'_>,
) -> Verdict {
evaluate(
policy,
&TitleOverrides::default(),
original_language,
candidate,
None,
1,
0,
)
.verdict
}
#[test]
fn missing_pre_grab_evidence_stays_unknown() {
let policy = policy();
let claims = claims(None, None);
let report = evaluate(
&policy,
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!(
report.rules,
vec![
RuleEvaluation::Unknown(RuleKind::Resolution),
RuleEvaluation::Unknown(RuleKind::Source),
RuleEvaluation::Unknown(RuleKind::Size),
RuleEvaluation::Unknown(RuleKind::DolbyVisionProfile),
RuleEvaluation::Unknown(RuleKind::DubBlacklist),
RuleEvaluation::Unknown(RuleKind::RequiredAudio),
]
);
}
#[test]
fn preferred_resolutions_pass_in_both_phases() {
let policy = policy();
let overrides = TitleOverrides::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 = TitleOverrides::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 = TitleOverrides {
only_4k: true,
..TitleOverrides::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 = TitleOverrides {
only_4k: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl));
assert_eq!(
verdict(&policy, &overrides, Candidate::PreGrab(&claims)),
Verdict::Rejected(Rule::Resolution(Resolution::R2160p))
);
}
/// A policy with one 1080p band, so the floor has something to say.
fn banded_policy() -> Policy {
Policy {
size_bands: BTreeMap::from([(
Resolution::R1080p,
crate::SizeBand {
floor_bytes: 2 << 30,
target_bytes: 4 << 30,
penalty_points_per_gib_over: 600,
},
)]),
resolution_preference: vec![Resolution::R1080p],
..policy()
}
}
#[test]
fn a_below_floor_release_is_rejected_without_the_override() {
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
let evaluation = evaluate(
&banded_policy(),
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
0,
);
assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size));
}
#[test]
fn allow_below_floor_waives_the_floor_rather_than_lifting_it() {
let policy = banded_policy();
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Pre-grab the deck offers it, and only as a waiver: `waived` is
// never picked automatically (§9.3), so the operator still decides.
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
// And the import records the waiver rather than hard-failing (§5.7).
let media = probed(Resolution::R1080p, Some(Source::WebDl));
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PostDownload(&media),
Some(1 << 30),
1,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
#[test]
fn allow_below_floor_says_nothing_about_a_release_that_clears_the_floor() {
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
assert_eq!(
evaluate(
&banded_policy(),
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(4 << 30),
1,
0,
)
.verdict,
Verdict::Eligible
);
}
/// §5.5: the floor takes the per-episode figure, so the override that
/// relaxes it has to travel the same divisor.
#[test]
fn allow_below_floor_waives_a_pack_measured_per_episode() {
let policy = banded_policy();
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Ten episodes at 1 GiB each: over the floor in total, under it per
// episode, which is the figure the floor compares.
let size = Some(10 << 30);
assert_eq!(
evaluate(
&policy,
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
size,
10,
0,
)
.verdict,
Verdict::Rejected(Rule::Size)
);
assert_eq!(
evaluate(
&policy,
&TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
},
&en(),
Candidate::PreGrab(&claims),
size,
10,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
/// §5.5 scaling composes with the #210 waiver: the runtime moves the
/// floor, and `allow_below_floor` still only softens what remains below
/// it — it never bypasses the scaled comparison.
#[test]
fn allow_below_floor_waives_against_the_scaled_floor() {
let policy = banded_policy();
let waive = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// The 2 GiB floor at 22 minutes is ~0.98 GiB. 1.5 GiB clears it, so
// the override has nothing to waive; 0.5 GiB is below even the
// scaled floor and stays a waiver rather than eligible.
let at = |size_bytes, overrides| {
evaluate(
&policy,
overrides,
&en(),
Candidate::PreGrab(&claims),
Some(size_bytes),
1,
22,
)
.verdict
};
assert_eq!(at(3 << 29, &waive), Verdict::Eligible);
assert_eq!(at(1 << 29, &waive), Verdict::Waived(Rule::Size));
assert_eq!(
at(1 << 29, &TitleOverrides::default()),
Verdict::Rejected(Rule::Size)
);
}
#[test]
fn every_unsafe_source_hard_fails_in_both_phases() {
let policy = policy();
let overrides = TitleOverrides::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,
&TitleOverrides::default(),
Candidate::PreGrab(&claims),
),
Verdict::Rejected(Rule::Source(Source::Telesync))
);
}
#[test]
fn every_safe_source_passes_in_both_phases() {
let policy = policy();
let overrides = TitleOverrides::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,
&TitleOverrides::default(),
&en(),
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!(report.rules[1], RuleEvaluation::Unknown(RuleKind::Source));
}
}
#[test]
fn dolby_vision_profile_5_is_rejected() {
assert_dolby_vision_verdict(&policy(), 5, None, true);
}
#[test]
fn dolby_vision_profile_7_is_rejected() {
assert_dolby_vision_verdict(&policy(), 7, Some(6), true);
}
#[test]
fn dolby_vision_profile_8_1_is_accepted() {
assert_dolby_vision_verdict(&policy(), 8, Some(1), false);
}
#[test]
fn compatibility_specific_rule_matches_only_that_id() {
let mut policy = policy();
policy
.hdr_rules
.rejected_dolby_vision_profiles
.push(DolbyVisionProfile {
profile: 8,
compatibility_id: Some(2),
});
assert_dolby_vision_verdict(&policy, 8, Some(2), true);
assert_dolby_vision_verdict(&policy, 8, Some(1), false);
}
fn assert_dolby_vision_verdict(
policy: &Policy,
profile: u8,
compatibility_id: Option<u8>,
rejected: bool,
) {
let mut media = probed(Resolution::R2160p, Some(Source::WebDl));
let profile = DolbyVisionProfile {
profile,
compatibility_id,
};
media.hdr = HdrFormat::DolbyVision(profile);
let report = evaluate(
policy,
&TitleOverrides::default(),
&en(),
Candidate::PostDownload(&media),
None,
1,
0,
);
let expected = if rejected {
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
} else {
Verdict::Eligible
};
assert_eq!(report.verdict, expected);
}
#[test]
fn pre_grab_dolby_vision_claim_stays_unknown() {
let claims = arr_parse::parse("Movie.2024.2160p.WEB-DL.DV-GROUP");
let report = evaluate(
&policy(),
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!(
report.rules[3],
RuleEvaluation::Unknown(RuleKind::DolbyVisionProfile)
);
}
#[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 = TitleOverrides::default();
let original = en();
let claims = claims(None, None);
let context = EvaluationContext {
policy: &policy,
overrides: &overrides,
original_language: &original,
candidate: Candidate::PreGrab(&claims),
size_bytes: None,
episode_count: 1,
runtime_minutes: 0,
};
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 = TitleOverrides::default();
let original = en();
let claims = claims(None, None);
let context = EvaluationContext {
policy: &policy,
overrides: &overrides,
original_language: &original,
candidate: Candidate::PreGrab(&claims),
size_bytes: None,
episode_count: 1,
runtime_minutes: 0,
};
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()))
);
}
// §5.2 truth table, `main` root: require a track matching the original
// language; pt-BR is blacklisted as a dub only.
#[test]
fn main_original_language_track_passes() {
let media = probed_audio(vec![track(en())]);
assert_eq!(
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Eligible
);
}
#[test]
fn main_ptbr_dub_of_a_foreign_title_is_rejected() {
let media = probed_audio(vec![track(Language::PortugueseBrazil)]);
assert_eq!(
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Rejected(Rule::DubBlacklist(Language::PortugueseBrazil))
);
}
#[test]
fn main_brazilian_films_own_soundtrack_passes() {
let original = Language::PortugueseBrazil;
let media = probed_audio(vec![track(Language::PortugueseBrazil)]);
assert_eq!(
verdict_for(&policy(), &original, Candidate::PostDownload(&media)),
Verdict::Eligible
);
let claims = name_claims(&[LanguageMarker::PtBr]);
assert_eq!(
verdict_for(&policy(), &original, Candidate::PreGrab(&claims)),
Verdict::Eligible
);
}
#[test]
fn main_blacklisted_dub_may_ride_along_original_audio() {
let media = probed_audio(vec![track(en()), track(Language::PortugueseBrazil)]);
assert_eq!(
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Eligible
);
}
#[test]
fn main_missing_original_audio_hard_fails() {
let media = probed_audio(vec![track(Language::Other("it".to_owned()))]);
assert_eq!(
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Rejected(Rule::RequiredAudio)
);
}
// §5.2 truth table, `kids` root: require pt-PT, or the original language
// when that is already Portuguese; pt-BR never satisfies the requirement.
#[test]
fn kids_ptpt_track_passes() {
let media = probed_audio(vec![track(en()), track(Language::PortuguesePortugal)]);
assert_eq!(
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Eligible
);
}
#[test]
fn kids_ptbr_dub_never_satisfies() {
let media = probed_audio(vec![track(Language::PortugueseBrazil)]);
assert_eq!(
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Rejected(Rule::DubBlacklist(Language::PortugueseBrazil))
);
}
#[test]
fn kids_ptbr_alongside_english_still_leaves_the_requirement_unmet() {
let media = probed_audio(vec![track(en()), track(Language::PortugueseBrazil)]);
assert_eq!(
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Waived(Rule::RequiredAudio)
);
}
#[test]
fn kids_portuguese_original_soundtrack_passes() {
for original in [Language::PortuguesePortugal, Language::PortugueseBrazil] {
let media = probed_audio(vec![track(original.clone())]);
assert_eq!(
verdict_for(&kids_policy(), &original, Candidate::PostDownload(&media)),
Verdict::Eligible
);
}
}
#[test]
fn kids_missing_pt_audio_soft_fails() {
let media = probed_audio(vec![track(en())]);
assert_eq!(
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Waived(Rule::RequiredAudio)
);
}
#[test]
fn kids_allow_english_audio_grabs_and_imports_with_a_waiver() {
let policy = kids_policy();
let overrides = TitleOverrides {
allow_english_audio: true,
..TitleOverrides::default()
};
let claims = name_claims(&[LanguageMarker::English]);
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PreGrab(&claims),
None,
1,
0,
)
.verdict,
Verdict::Eligible
);
let media = probed_audio(vec![track(en())]);
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PostDownload(&media),
None,
1,
0,
)
.verdict,
Verdict::Waived(Rule::RequiredAudio)
);
}
// §5.2: subtitles carry no blacklist — pt-BR subtitles are always fine.
#[test]
fn subtitles_are_never_blacklisted() {
let mut media = probed_audio(vec![track(Language::PortuguesePortugal)]);
media.subtitle_tracks = vec![SubtitleTrack {
language: Language::PortugueseBrazil,
}];
assert_eq!(
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Eligible
);
assert_eq!(
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
Verdict::Rejected(Rule::RequiredAudio)
);
}
// §5.2 pre-grab: explicit language markers are hard filters.
#[test]
fn pre_grab_ptbr_marked_release_is_rejected_for_a_foreign_title() {
let claims = arr_parse::parse("Movie.2024.1080p.WEB-DL.PT-BR.Dublado-GRP");
for policy in [policy(), kids_policy()] {
assert_eq!(
verdict_for(&policy, &en(), Candidate::PreGrab(&claims)),
Verdict::Rejected(Rule::DubBlacklist(Language::PortugueseBrazil))
);
}
}
#[test]
fn pre_grab_dual_audio_ptbr_release_stays_unknown() {
let claims = name_claims(&[LanguageMarker::PtBr, LanguageMarker::Dual]);
assert_eq!(
verdict_for(&policy(), &en(), Candidate::PreGrab(&claims)),
Verdict::Eligible
);
}
#[test]
fn pre_grab_foreign_only_markers_fail_the_requirement() {
let claims = name_claims(&[LanguageMarker::Italian]);
assert_eq!(
verdict_for(&policy(), &en(), Candidate::PreGrab(&claims)),
Verdict::Rejected(Rule::RequiredAudio)
);
assert_eq!(
verdict_for(&kids_policy(), &en(), Candidate::PreGrab(&claims)),
Verdict::Waived(Rule::RequiredAudio)
);
}
// The issue's explicit acceptance case: an unresolvable Portuguese track
// is `por-unverified` — surfaced, never a guess in either direction.
#[test]
fn unverified_portuguese_track_is_surfaced_not_guessed() {
let media = probed_audio(vec![track(Language::PortugueseUnverified)]);
// kids: could be the required pt-PT — must not reject as a dub.
let report = evaluate(
&kids_policy(),
&TitleOverrides::default(),
&en(),
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
// main, Brazilian film: could be its own soundtrack — must not pass
// as verified either.
let report = evaluate(
&policy(),
&TitleOverrides::default(),
&Language::PortugueseBrazil,
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
}
#[test]
fn pre_grab_bare_portuguese_marker_stays_unknown_for_kids() {
let claims = name_claims(&[LanguageMarker::Portuguese]);
let report = evaluate(
&kids_policy(),
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!(
report.rules[5],
RuleEvaluation::Unknown(RuleKind::RequiredAudio)
);
}
}