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 { 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 { 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, } /// 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: &TitleOverrides, original_language: &Language, candidate: Candidate<'_>, size_bytes: Option, ) -> 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 = 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 { 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 { 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 = 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, source: Option) -> NameClaims { NameClaims { resolution, source, ..NameClaims::default() } } fn track(language: Language) -> AudioTrack { AudioTrack { language, title: None, handler_name: None, } } fn probed(resolution: Resolution, source: Option) -> ProbedMedia { ProbedMedia { resolution, source, hdr: HdrFormat::Sdr, audio_tracks: vec![track(en())], subtitle_tracks: Vec::new(), } } fn probed_audio(tracks: Vec) -> 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, 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) ); } }