Files
arr/crates/arr-core/src/lang.rs
T
naps62-yolo 302b7cfe72
ci / web (push) Successful in 8s
ci / rust (push) Successful in 1m5s
e2e / e2e (push) Successful in 1m51s
Language rule: original language, dubs and pt-BR detection (#57)
2026-08-22 20:33:02 +01:00

252 lines
8.9 KiB
Rust

//! 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);
}
}