From 302b7cfe72d9b60ba70b85c56e9349d054c94817 Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 20:33:02 +0100 Subject: [PATCH] Language rule: original language, dubs and pt-BR detection (#57) --- crates/arr-core/src/lang.rs | 251 ++++++++++++++++++ crates/arr-core/src/lib.rs | 3 + crates/arr-core/src/policy.rs | 464 +++++++++++++++++++++++++++++++++- 3 files changed, 711 insertions(+), 7 deletions(-) create mode 100644 crates/arr-core/src/lang.rs diff --git a/crates/arr-core/src/lang.rs b/crates/arr-core/src/lang.rs new file mode 100644 index 0000000..6d8e9da --- /dev/null +++ b/crates/arr-core/src/lang.rs @@ -0,0 +1,251 @@ +//! pt-PT versus pt-BR detection (`DESIGN.md` §5.2). +//! +//! ISO-639-2 has one code, `por`, for both variants, so the variant must be +//! recovered from side channels. Three signals, tried in order: +//! +//! 1. release-name markers (`PT-BR`, `Dublado`, `Nacional` versus `PT-PT`), +//! 2. `ffprobe` stream *title* and `handler_name` strings, +//! 3. container-level BCP-47 tags (Matroska can carry `pt-BR`). +//! +//! When none resolve it — including when a signal contradicts itself — the +//! track is tagged [`Language::PortugueseUnverified`] and surfaced. Never +//! guess: guessing toward `kids` gives a child a Brazilian dub, guessing +//! toward `main` rejects a Brazilian film's own audio. + +use arr_parse::LanguageMarker; + +use crate::Language; + +/// Everything available to disambiguate a Portuguese audio track. +#[derive(Clone, Copy, Debug, Default)] +pub struct PortugueseEvidence<'a> { + /// Language markers parsed from the release name (signal 1). + pub name_markers: &'a [LanguageMarker], + /// `ffprobe` stream title (signal 2), often literally + /// `Portuguese (Brazil)`. + pub stream_title: Option<&'a str>, + /// `ffprobe` stream `handler_name` (signal 2). + pub handler_name: Option<&'a str>, + /// Container-level BCP-47 tag (signal 3), e.g. `pt-BR`. + pub container_tag: Option<&'a str>, +} + +/// Which variant one signal claims, if any. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Variant { + Portugal, + Brazil, +} + +impl Variant { + const fn language(self) -> Language { + match self { + Self::Portugal => Language::PortuguesePortugal, + Self::Brazil => Language::PortugueseBrazil, + } + } +} + +/// Resolve a `por` audio track to a concrete Portuguese variant, or +/// [`Language::PortugueseUnverified`] when no signal settles it. +#[must_use] +pub fn resolve_portuguese(evidence: PortugueseEvidence<'_>) -> Language { + let signals = [ + from_name_markers(evidence.name_markers), + from_stream_strings(evidence.stream_title, evidence.handler_name), + evidence.container_tag.and_then(from_bcp47), + ]; + signals + .into_iter() + .flatten() + .next() + .map_or(Language::PortugueseUnverified, Variant::language) +} + +/// Signal 1: explicit variant markers in the release name. A name claiming +/// both variants (a dual-dub release) resolves nothing for any one track. +fn from_name_markers(markers: &[LanguageMarker]) -> Option { + let br = markers.contains(&LanguageMarker::PtBr); + let pt = markers.contains(&LanguageMarker::PtPt); + match (br, pt) { + (true, false) => Some(Variant::Brazil), + (false, true) => Some(Variant::Portugal), + _ => None, + } +} + +/// Signal 2: free-text stream metadata. Contradictory mentions across the +/// title and handler resolve nothing. +fn from_stream_strings(title: Option<&str>, handler: Option<&str>) -> Option { + let mut seen_br = false; + let mut seen_pt = false; + for text in [title, handler].into_iter().flatten() { + let low = text.to_lowercase(); + seen_br |= ["brazil", "brasil", "pt-br", "pt_br", "ptbr"] + .iter() + .any(|needle| low.contains(needle)); + seen_pt |= ["portugal", "pt-pt", "pt_pt", "ptpt", "european"] + .iter() + .any(|needle| low.contains(needle)); + } + match (seen_br, seen_pt) { + (true, false) => Some(Variant::Brazil), + (false, true) => Some(Variant::Portugal), + _ => None, + } +} + +/// Signal 3: a BCP-47 tag whose primary subtag is Portuguese and whose +/// region subtag names the variant. A bare `pt` resolves nothing. +fn from_bcp47(tag: &str) -> Option { + let mut subtags = tag.split(['-', '_']).map(str::to_lowercase); + let primary = subtags.next()?; + if primary != "pt" && primary != "por" { + return None; + } + for subtag in subtags { + match subtag.as_str() { + "br" => return Some(Variant::Brazil), + "pt" => return Some(Variant::Portugal), + _ => {} + } + } + None +} + +/// The [`Language`] a release-name marker claims, if it claims one at all. +/// `MULTi` and `DUAL` advertise track counts, not languages. A bare +/// `Portuguese` marker has unknown flavour: pre-grab there is no stream or +/// container to consult, so it stays unverified. +#[must_use] +pub fn language_of_marker(marker: LanguageMarker) -> Option { + match marker { + LanguageMarker::PtBr => Some(Language::PortugueseBrazil), + LanguageMarker::PtPt => Some(Language::PortuguesePortugal), + LanguageMarker::Portuguese => Some(Language::PortugueseUnverified), + LanguageMarker::English => Some(Language::Other("en".to_owned())), + LanguageMarker::French => Some(Language::Other("fr".to_owned())), + LanguageMarker::German => Some(Language::Other("de".to_owned())), + LanguageMarker::Spanish => Some(Language::Other("es".to_owned())), + LanguageMarker::Italian => Some(Language::Other("it".to_owned())), + LanguageMarker::Multi | LanguageMarker::Dual => None, + } +} + +/// Whether a language is a resolved Portuguese variant. +#[must_use] +pub const fn is_resolved_portuguese(language: &Language) -> bool { + matches!( + language, + Language::PortuguesePortugal | Language::PortugueseBrazil + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_marker_resolves_first() { + let markers = [LanguageMarker::PtBr]; + let evidence = PortugueseEvidence { + name_markers: &markers, + // A contradicting later signal must not win over signal 1. + stream_title: Some("Portuguese (Portugal)"), + ..PortugueseEvidence::default() + }; + assert_eq!(resolve_portuguese(evidence), Language::PortugueseBrazil); + + let markers = [LanguageMarker::PtPt]; + let evidence = PortugueseEvidence { + name_markers: &markers, + ..PortugueseEvidence::default() + }; + assert_eq!(resolve_portuguese(evidence), Language::PortuguesePortugal); + } + + #[test] + fn contradictory_name_markers_fall_through() { + let markers = [LanguageMarker::PtBr, LanguageMarker::PtPt]; + let evidence = PortugueseEvidence { + name_markers: &markers, + stream_title: Some("Portuguese (Brazil)"), + ..PortugueseEvidence::default() + }; + assert_eq!(resolve_portuguese(evidence), Language::PortugueseBrazil); + } + + #[test] + fn stream_title_resolves_second() { + let evidence = PortugueseEvidence { + stream_title: Some("Portuguese (Brazil)"), + ..PortugueseEvidence::default() + }; + assert_eq!(resolve_portuguese(evidence), Language::PortugueseBrazil); + + let evidence = PortugueseEvidence { + handler_name: Some("Português (Portugal)"), + ..PortugueseEvidence::default() + }; + assert_eq!(resolve_portuguese(evidence), Language::PortuguesePortugal); + } + + #[test] + fn contradictory_stream_strings_fall_through() { + let evidence = PortugueseEvidence { + stream_title: Some("Portuguese (Brazil)"), + handler_name: Some("Portuguese (Portugal)"), + container_tag: Some("pt-PT"), + ..PortugueseEvidence::default() + }; + assert_eq!(resolve_portuguese(evidence), Language::PortuguesePortugal); + } + + #[test] + fn bcp47_tag_resolves_third() { + for (tag, expected) in [ + ("pt-BR", Language::PortugueseBrazil), + ("pt-PT", Language::PortuguesePortugal), + ("por-br", Language::PortugueseBrazil), + ("pt_BR", Language::PortugueseBrazil), + ] { + let evidence = PortugueseEvidence { + container_tag: Some(tag), + ..PortugueseEvidence::default() + }; + assert_eq!(resolve_portuguese(evidence), expected); + } + } + + #[test] + fn bare_or_foreign_tags_resolve_nothing() { + for tag in ["pt", "por", "en-US", "pt-AO"] { + let evidence = PortugueseEvidence { + container_tag: Some(tag), + ..PortugueseEvidence::default() + }; + assert_eq!(resolve_portuguese(evidence), Language::PortugueseUnverified); + } + } + + #[test] + fn no_signal_emits_por_unverified_not_a_guess() { + let evidence = PortugueseEvidence { + name_markers: &[LanguageMarker::Multi], + stream_title: Some("Portuguese"), + handler_name: Some("SoundHandler"), + container_tag: Some("por"), + }; + assert_eq!(resolve_portuguese(evidence), Language::PortugueseUnverified); + } + + #[test] + fn marker_languages_map_without_guessing_portuguese_flavour() { + assert_eq!( + language_of_marker(LanguageMarker::Portuguese), + Some(Language::PortugueseUnverified) + ); + assert_eq!(language_of_marker(LanguageMarker::Multi), None); + assert_eq!(language_of_marker(LanguageMarker::Dual), None); + } +} diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 04ef0a4..9a76d00 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -4,6 +4,7 @@ use std::{collections::BTreeMap, path::PathBuf, time::SystemTime}; +pub mod lang; pub mod policy; pub use arr_parse::NameClaims as ParsedRelease; @@ -195,6 +196,8 @@ pub struct ProbedMedia { pub enum Rule { RequiredAudio, DubBlacklist(Language), + /// A Portuguese track whose pt-PT / pt-BR flavour no signal resolved. + PortugueseUnverified, DolbyVisionProfile(DolbyVisionProfile), Resolution(Resolution), Source(Source), diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 1cb0b6c..b4edfd3 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -1,6 +1,10 @@ -use arr_parse::NameClaims; +use arr_parse::{LanguageMarker, NameClaims}; -use crate::{MovieOverrides, Policy, ProbedMedia, Resolution, Rule, Source, Verdict}; +use crate::{ + lang::{is_resolved_portuguese, language_of_marker}, + Language, MovieOverrides, Policy, ProbedMedia, RequiredAudio, Resolution, Rule, Source, + Verdict, +}; /// The evidence available while evaluating a candidate. #[derive(Clone, Copy, Debug)] @@ -48,6 +52,9 @@ pub enum EvaluationPhase { pub struct EvaluationContext<'a> { pub policy: &'a Policy, pub overrides: &'a MovieOverrides, + /// 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>, } @@ -89,14 +96,21 @@ pub struct Evaluation { pub fn evaluate( policy: &Policy, overrides: &MovieOverrides, + original_language: &Language, candidate: Candidate<'_>, ) -> Evaluation { let context = EvaluationContext { policy, overrides, + original_language, candidate, }; - let rules: [&dyn PolicyRule; 2] = [&ResolutionRule, &SourceRule]; + let rules: [&dyn PolicyRule; 4] = [ + &ResolutionRule, + &SourceRule, + &DubBlacklistRule, + &RequiredAudioRule, + ]; evaluate_rules(&context, &rules) } @@ -186,6 +200,162 @@ impl PolicyRule for SourceRule { } } +/// 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; @@ -193,14 +363,20 @@ mod tests { use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource}; use super::*; - use crate::{HdrFormat, HdrRules, PolicyId, RequiredAudio}; + use crate::{AudioTrack, 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::new(), + dub_blacklist: vec![Language::PortugueseBrazil], hdr_rules: HdrRules { rejected_dolby_vision_profiles: Vec::new(), }, @@ -210,6 +386,15 @@ mod tests { } } + /// 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, @@ -218,18 +403,54 @@ mod tests { } } + 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::new(), + 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: &MovieOverrides, candidate: Candidate<'_>) -> Verdict { - evaluate(policy, overrides, candidate).verdict + evaluate(policy, overrides, &en(), candidate).verdict + } + + fn verdict_for( + policy: &Policy, + original_language: &Language, + candidate: Candidate<'_>, + ) -> Verdict { + evaluate( + policy, + &MovieOverrides::default(), + original_language, + candidate, + ) + .verdict } #[test] @@ -239,6 +460,7 @@ mod tests { let report = evaluate( &policy, &MovieOverrides::default(), + &en(), Candidate::PreGrab(&claims), ); @@ -248,6 +470,8 @@ mod tests { vec![ RuleEvaluation::Unknown(RuleKind::Resolution), RuleEvaluation::Unknown(RuleKind::Source), + RuleEvaluation::Unknown(RuleKind::DubBlacklist), + RuleEvaluation::Unknown(RuleKind::RequiredAudio), ] ); } @@ -415,6 +639,7 @@ mod tests { let report = evaluate( &policy, &MovieOverrides::default(), + &en(), Candidate::PostDownload(&media), ); @@ -438,10 +663,12 @@ mod tests { fn hard_failure_wins_over_an_earlier_soft_failure() { let policy = policy(); let overrides = MovieOverrides::default(); + let original = en(); let claims = claims(None, None); let context = EvaluationContext { policy: &policy, overrides: &overrides, + original_language: &original, candidate: Candidate::PreGrab(&claims), }; let soft = FixedRule { @@ -463,10 +690,12 @@ mod tests { fn first_failure_of_the_deciding_kind_is_stable() { let policy = policy(); let overrides = MovieOverrides::default(); + let original = en(); let claims = claims(None, None); let context = EvaluationContext { policy: &policy, overrides: &overrides, + original_language: &original, candidate: Candidate::PreGrab(&claims), }; let first = FixedRule { @@ -483,4 +712,225 @@ mod tests { 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 = MovieOverrides { + allow_english_audio: true, + ..MovieOverrides::default() + }; + let claims = name_claims(&[LanguageMarker::English]); + assert_eq!( + evaluate(&policy, &overrides, &en(), Candidate::PreGrab(&claims)).verdict, + Verdict::Eligible + ); + + let media = probed_audio(vec![track(en())]); + assert_eq!( + evaluate(&policy, &overrides, &en(), Candidate::PostDownload(&media)).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(), + &MovieOverrides::default(), + &en(), + Candidate::PostDownload(&media), + ); + 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(), + &MovieOverrides::default(), + &Language::PortugueseBrazil, + Candidate::PostDownload(&media), + ); + 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(), + &MovieOverrides::default(), + &en(), + Candidate::PreGrab(&claims), + ); + assert_eq!(report.verdict, Verdict::Eligible); + assert_eq!( + report.rules[3], + RuleEvaluation::Unknown(RuleKind::RequiredAudio) + ); + } }