Language rule: original language, dubs and pt-BR detection (#57)
This commit was merged in pull request #57.
This commit is contained in:
@@ -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<Variant> {
|
||||
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<Variant> {
|
||||
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<Variant> {
|
||||
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<Language> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<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;
|
||||
@@ -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<ClaimedResolution>, source: Option<ClaimedSource>) -> 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<Source>) -> 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<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: &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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user