1117 lines
37 KiB
Rust
1117 lines
37 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>,
|
|
}
|
|
|
|
/// 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>,
|
|
) -> Evaluation {
|
|
let context = EvaluationContext {
|
|
policy,
|
|
overrides,
|
|
original_language,
|
|
candidate,
|
|
size_bytes,
|
|
};
|
|
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.
|
|
#[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) {
|
|
None => RuleEvaluation::Unknown(RuleKind::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).verdict
|
|
}
|
|
|
|
fn verdict_for(
|
|
policy: &Policy,
|
|
original_language: &Language,
|
|
candidate: Candidate<'_>,
|
|
) -> Verdict {
|
|
evaluate(
|
|
policy,
|
|
&TitleOverrides::default(),
|
|
original_language,
|
|
candidate,
|
|
None,
|
|
)
|
|
.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,
|
|
);
|
|
|
|
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))
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
);
|
|
|
|
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,
|
|
);
|
|
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,
|
|
);
|
|
|
|
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,
|
|
};
|
|
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,
|
|
};
|
|
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
|
|
)
|
|
.verdict,
|
|
Verdict::Eligible
|
|
);
|
|
|
|
let media = probed_audio(vec![track(en())]);
|
|
assert_eq!(
|
|
evaluate(
|
|
&policy,
|
|
&overrides,
|
|
&en(),
|
|
Candidate::PostDownload(&media),
|
|
None
|
|
)
|
|
.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,
|
|
);
|
|
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,
|
|
);
|
|
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,
|
|
);
|
|
assert_eq!(report.verdict, Verdict::Eligible);
|
|
assert_eq!(
|
|
report.rules[5],
|
|
RuleEvaluation::Unknown(RuleKind::RequiredAudio)
|
|
);
|
|
}
|
|
}
|