From 222f77fd099bbb879ae19e0567b49ac8af9ea816 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Sun, 10 May 2026 20:53:56 -0700 Subject: [PATCH] labels: universal MPLS fallback + bdmt disc metadata + png stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new modules in the labels platform, all layered so framework- specific parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always take precedence over the universal layer. **mpls_universal.rs** (~600 LOC, 9 tests): consumes the already-parsed `crate::mpls::Playlist::streams` and emits StreamLabel entries with language + codec_hint per stream. Returns `Confidence::Low` (new variant) so framework parsers' Medium/High always win the registry's max-by-confidence tiebreaker; MPLS only gets picked when no framework matched. Closes the "no BD-J disc" case (HDMV-only navigation) that previously produced zero labels — language and base codec are spec-mandated in MPLS STN tables on every Blu-ray ever made. **bdmt.rs** (~350 LOC, 10 tests): reads `/BDMV/META/DL/bdmt_.xml` files into a new `DiscMetadata` struct (localized title names per ISO 639-2 code, descriptions, optional box-set position). Runs independently of the parser registry — disc-level metadata, not per-stream, so the registry's confidence selection doesn't apply. Surfaced on a new `LabelAnalysis::disc_metadata` field. **png_filenames.rs** (noop stub): pattern documentation + dead-code detect/parse for future reactivation. Deferred because MPLS already delivers per-stream lang/codec/type on every disc; PNG filename language tokens only add studio variant disambiguation (FRC vs FRP, LAS vs CSP) — niche enough to not justify the implementation cost right now. Wiring changes in `mod.rs`: - New `Confidence::Low` variant (PartialOrd places it below Medium/High) - New `ParseResult::low()` constructor - `mpls_universal` appended last to `PARSERS` registry - `LabelAnalysis::disc_metadata: Option` field - `analyze()` runs `bdmt::parse` independently and surfaces result - `pub use bdmt::DiscMetadata` re-export so the labels-analyze tool in freemkv-tools can construct the JSON payload Total: 151 of 151 labels tests passing (was 132 — added 19 new). --- src/labels/bdmt.rs | 350 +++++++++++++++++++++ src/labels/mod.rs | 65 +++- src/labels/mpls_universal.rs | 595 +++++++++++++++++++++++++++++++++++ src/labels/png_filenames.rs | 72 +++++ 4 files changed, 1075 insertions(+), 7 deletions(-) create mode 100644 src/labels/bdmt.rs create mode 100644 src/labels/mpls_universal.rs create mode 100644 src/labels/png_filenames.rs diff --git a/src/labels/bdmt.rs b/src/labels/bdmt.rs new file mode 100644 index 0000000..b0b77b2 --- /dev/null +++ b/src/labels/bdmt.rs @@ -0,0 +1,350 @@ +//! BDMV disc-library metadata (`/BDMV/META/DL/bdmt_.xml`). +//! +//! Every commercial Blu-ray carries a disc-library metadata directory +//! with one XML file per shipped language. The schema is the Blu-ray +//! "disc library metadata" namespace (`urn:BDA:bdmv;disclibmeta`), +//! conventionally prefixed `di:`. Fields commonly present: +//! +//! - `` or `` — the title string. Vendor practice +//! varies (Paramount discs tend to use ``). +//! - `` — optional synopsis (often absent on retail +//! discs; common on box sets and special editions). +//! - `` / `` (or ``) — +//! set position for multi-disc releases. +//! +//! This module is intentionally separate from the BD-J `StreamLabel` +//! parsers under `labels/*.rs`. The XML here is disc-level (title, +//! description, set position), not per-stream — wiring into the main +//! parser registry happens elsewhere. +//! +//! Real-world XML is irregular: missing description elements, multiple +//! title elements (first one wins), and occasional malformed content. +//! Extraction is best-effort — a malformed file is treated as "no +//! metadata" (returns `None` from the helper), and the caller can +//! still get metadata from sibling-language XML files. + +// The module wiring (registry hook + public re-export) is added +// separately. Until then the parse/detect entry points have no +use super::xml; +use crate::sector::SectorReader; +use crate::udf::UdfFs; +use std::collections::BTreeMap; + +/// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`. +/// +/// All maps are keyed by 3-char ISO 639-2 language code (e.g. +/// `"eng"`, `"fra"`, `"jpn"`) — the same key segment used in the +/// `bdmt_.xml` filename. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +pub struct DiscMetadata { + /// Localized titles, keyed by 3-char ISO 639-2 lang code + /// (e.g. "eng" → "Dune Part Two") + pub titles: BTreeMap, + /// First-line / short description, per lang + pub descriptions: BTreeMap, + /// Disc N of M for box sets (None if not a box set) + pub disc_number: Option<(u32, u32)>, +} + +/// True if `/BDMV/META/DL/` exists and contains at least one +/// `bdmt_*.xml` file. +pub fn detect(udf: &UdfFs) -> bool { + let Some(dir) = udf.find_dir("/BDMV/META/DL") else { + return false; + }; + dir.entries + .iter() + .any(|e| !e.is_dir && is_bdmt_filename(&e.name)) +} + +/// Read every `bdmt_.xml` under `/BDMV/META/DL/` and return the +/// aggregated [`DiscMetadata`]. Returns `None` if no titles could be +/// extracted from any file. +pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { + let dir = udf.find_dir("/BDMV/META/DL")?; + let mut out = DiscMetadata::default(); + + for entry in &dir.entries { + if entry.is_dir { + continue; + } + let Some(lang) = lang_code_from_filename(&entry.name) else { + continue; + }; + let path = format!("/BDMV/META/DL/{}", entry.name); + let Ok(bytes) = udf.read_file(reader, &path) else { + continue; + }; + let Ok(text) = std::str::from_utf8(&bytes) else { + continue; + }; + let Some((title, description, disc_set)) = parse_bdmt_xml(&lang, text) else { + continue; + }; + out.titles.insert(lang.clone(), title); + if let Some(desc) = description { + out.descriptions.insert(lang.clone(), desc); + } + // Disc-set position is disc-global; first one we successfully + // read wins. (All bdmt_*.xml on a given disc carry the same + // value in practice.) + if out.disc_number.is_none() { + if let Some(ds) = disc_set { + out.disc_number = Some(ds); + } + } + } + + if out.titles.is_empty() { + None + } else { + Some(out) + } +} + +/// True if `name` matches the `bdmt_.xml` convention with a +/// 3-character ISO 639-2 lang code segment. Case-insensitive. +fn is_bdmt_filename(name: &str) -> bool { + lang_code_from_filename(name).is_some() +} + +/// Extract the 3-char language code from a `bdmt_.xml` filename. +/// Returns `None` if the filename doesn't match. Lang code is +/// lowercased so callers always see e.g. `"eng"` not `"ENG"`. +fn lang_code_from_filename(name: &str) -> Option { + let lower = name.to_ascii_lowercase(); + let stem = lower.strip_suffix(".xml")?; + let lang = stem.strip_prefix("bdmt_")?; + // ISO 639-2 codes are exactly 3 ASCII letters. Be strict — keeps + // us from picking up unrelated `bdmt_foo.xml` siblings. + if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + Some(lang.to_string()) +} + +/// Tuple returned by [`parse_bdmt_xml`]: `(title, description?, disc_set?)`. +/// Aliased so the function signature isn't a clippy::type-complexity offender. +pub(crate) type BdmtFields = (String, Option, Option<(u32, u32)>); + +/// Parse one `bdmt_.xml` document and return +/// `(title, description?, disc_set?)`. Returns `None` if no title +/// could be located — the caller treats this as "skip this file". +/// +/// Title-element preference: `` → `` → +/// `/` (first match wins, per the +/// authoring-tool conventions documented at the module level). +pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option { + let title = extract_title(xml_text)?; + let description = xml::text(xml_text, "description").filter(|s| !s.is_empty()); + let disc_set = extract_disc_set(xml_text); + Some((title, description, disc_set)) +} + +/// Try title-bearing element variants in priority order. The `xml` +/// helpers are case- and namespace-insensitive, so callers pass the +/// bare local name (no `di:` prefix). +fn extract_title(xml_text: &str) -> Option { + // Order matches the module-level convention: first + // (Paramount-style), then , then the nested + // tableOfContents/titleName form. + for tag in ["name", "title"] { + if let Some(s) = xml::text(xml_text, tag) { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + // tableOfContents/titleName: search inside the toc block so we + // don't accidentally pick a stray from elsewhere. + if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) { + let block = &xml_text[s..e]; + if let Some(t) = xml::text(block, "titleName") { + let trimmed = t.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +/// Extract `(discNumber, numSets)` if both are present and parse as +/// `u32`. Accepts either `` or `` for +/// the denominator (both forms appear in the wild). +fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> { + let n = xml::text(xml_text, "discNumber")? + .trim() + .parse::() + .ok()?; + let total = xml::text(xml_text, "numSets") + .or_else(|| xml::text(xml_text, "numberOfSets"))? + .trim() + .parse::() + .ok()?; + Some((n, total)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_simple_title() { + // Minimal Paramount-style document: as the title + // carrier inside a root. + let xml = r#" + + Dune Part Two +"#; + let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse"); + assert_eq!(title, "Dune Part Two"); + assert_eq!(desc, None); + assert_eq!(set, None); + } + + #[test] + fn extract_title_element_variant() { + // is the alternate carrier; should be picked up + // when is absent. + let xml = r#" + The Matrix + A film about computers. +"#; + let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap(); + assert_eq!(title, "The Matrix"); + assert_eq!(desc.as_deref(), Some("A film about computers.")); + } + + #[test] + fn extract_title_from_table_of_contents_fallback() { + // Some authoring tools nest the title under tableOfContents. + // No or at top level → fall back to + // titleName inside tableOfContents. + let xml = r#" + + Inside Out 2 + +"#; + let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap(); + assert_eq!(title, "Inside Out 2"); + } + + #[test] + fn extract_box_set_position() { + let xml = r#" + LOTR Disc 2 + 2 + 5 +"#; + let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap(); + assert_eq!(set, Some((2, 5))); + } + + #[test] + fn extract_box_set_position_alternate_total_tag() { + // is an alternate spelling we've seen. + let xml = r#" + X + 3 + 6 +"#; + let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap(); + assert_eq!(set, Some((3, 6))); + } + + #[test] + fn extract_box_set_requires_both_fields() { + // discNumber alone (no total) yields None — we don't fabricate + // a denominator. + let xml = r#" + X + 1 +"#; + let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap(); + assert_eq!(set, None); + } + + #[test] + fn multiple_languages_keyed_correctly() { + // Simulate driving parse_bdmt_xml from two synthetic XML + // blobs and aggregating into DiscMetadata the same way parse() + // would. This exercises the BTreeMap key handling without + // needing a UdfFs. + let eng_xml = r#" + Dune Part Two +"#; + let fra_xml = r#" + Dune Deuxième Partie + Suite du film de 2021. +"#; + + let mut meta = DiscMetadata::default(); + for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] { + let (title, desc, ds) = parse_bdmt_xml(lang, blob).unwrap(); + meta.titles.insert(lang.to_string(), title); + if let Some(d) = desc { + meta.descriptions.insert(lang.to_string(), d); + } + if meta.disc_number.is_none() { + if let Some(d) = ds { + meta.disc_number = Some(d); + } + } + } + + assert_eq!( + meta.titles.get("eng").map(String::as_str), + Some("Dune Part Two") + ); + assert_eq!( + meta.titles.get("fra").map(String::as_str), + Some("Dune Deuxième Partie") + ); + assert!(meta.descriptions.get("eng").is_none()); + assert_eq!( + meta.descriptions.get("fra").map(String::as_str), + Some("Suite du film de 2021.") + ); + assert_eq!(meta.disc_number, None); + } + + #[test] + fn malformed_xml_returns_none() { + // Random gibberish has no recognizable title element. We + // document the contract: parse_bdmt_xml returns None, and + // parse() (the caller) skips the file. Aggregating across + // zero files leaves DiscMetadata::default() — which parse() + // surfaces as None to its caller. Either is documented as + // acceptable per the module spec. + let bad = "this is not xml &&& <<< nope"; + assert!(parse_bdmt_xml("eng", bad).is_none()); + + // Half-open tag, no body, no close: also yields no title. + let truncated = ""; + assert!(parse_bdmt_xml("eng", truncated).is_none()); + } + + #[test] + fn whitespace_in_title_is_trimmed() { + let xml = r#" + Dune Part Two + "#; + let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap(); + assert_eq!(title, "Dune Part Two"); + } + + #[test] + fn lang_code_extraction() { + assert_eq!(lang_code_from_filename("bdmt_eng.xml"), Some("eng".into())); + assert_eq!(lang_code_from_filename("BDMT_FRA.XML"), Some("fra".into())); + assert_eq!(lang_code_from_filename("bdmt_jpn.xml"), Some("jpn".into())); + // Non-matching cases: + assert_eq!(lang_code_from_filename("bdmt_.xml"), None); + assert_eq!(lang_code_from_filename("bdmt_engl.xml"), None); + assert_eq!(lang_code_from_filename("bdmt_e1g.xml"), None); + assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None); + assert_eq!(lang_code_from_filename("foo.xml"), None); + } +} diff --git a/src/labels/mod.rs b/src/labels/mod.rs index eea65a9..20c0ffa 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -7,14 +7,17 @@ //! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option>` //! 4. Add `mod myformat;` below and one line to `PARSERS` array +mod bdmt; pub(crate) mod class_reader; mod criterion; mod ctrm; mod dbp; mod deluxe; pub(crate) mod jar; +mod mpls_universal; mod paramount; mod pixelogic; +mod png_filenames; pub(crate) mod text; pub mod vocab; pub(crate) mod xml; @@ -23,6 +26,11 @@ use crate::disc::{DiscTitle, Stream}; use crate::sector::SectorReader; use crate::udf::UdfFs; +// Re-export bdmt's public type so callers can construct/inspect +// disc-level metadata via `labels::DiscMetadata`. The module itself +// stays private — analyze() drives the parse path. +pub use bdmt::DiscMetadata; + // Re-exported via crate::disc — the public API surfaces these next to // AudioStream/SubtitleStream so callers can map purpose/qualifier to display // text in their own locale. @@ -88,10 +96,14 @@ type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option; /// A parser SHOULD return `High` only when its full schema was /// extracted with no fallback or guessing. `Medium` is for matched- /// but-degraded outputs (some streams missing fields, fingerprint -/// matched but a sub-table couldn't be decoded, etc.). The registry -/// prefers `High` over `Medium`; ties fall to array order. +/// matched but a sub-table couldn't be decoded, etc.). `Low` is for +/// the universal MPLS fallback — spec-mandated stream metadata +/// (language + base codec) that's correct but lacks editorial labels +/// (commentary, SDH, etc.). The registry prefers `High > Medium > Low`; +/// ties fall to array order. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Confidence { + Low, Medium, High, } @@ -124,6 +136,16 @@ impl ParseResult { confidence: Confidence::Medium, } } + + /// Convenience for the universal MPLS fallback: spec-derived + /// stream language + codec, but no editorial labels (commentary, + /// SDH, etc.). Framework parsers always win over `low`. + pub fn low(labels: Vec) -> Self { + ParseResult { + labels, + confidence: Confidence::Low, + } + } } const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ @@ -138,6 +160,17 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ // (constant-pool iteration vs. deluxe's bytecode walking). ("dbp", dbp::detect, dbp::parse), ("deluxe", deluxe::detect, deluxe::parse), + // Universal MPLS fallback. Returns Confidence::Low so framework + // parsers always win when they match. Closes the "no framework + // matched" gap (e.g. HDMV-only discs) with spec-derived language + // + base codec for every stream the playlist references. Runs + // last in registry order so it's only the chosen parser when + // nothing else fired. + ( + "mpls_universal", + mpls_universal::detect, + mpls_universal::parse, + ), ]; /// Search disc for config files, extract labels, apply to streams. @@ -426,12 +459,23 @@ pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis { ); } + // bdmt runs independently of the parser registry: it's disc-level + // metadata (localized titles, box-set position), not per-stream + // labels, so the "highest confidence wins" logic doesn't apply. + // Always run if detected; surface result as a separate field. + let disc_metadata = if bdmt::detect(udf) { + bdmt::parse(reader, udf) + } else { + None + }; + LabelAnalysis { parser, parsers_detected, confidence, jar_inventory: inventory, labels, + disc_metadata, } } @@ -461,6 +505,11 @@ pub struct LabelAnalysis { /// Raw labels emitted by the selected parser (empty if `parser` /// is `None`). pub labels: Vec, + /// Disc-level metadata from `/BDMV/META/DL/bdmt_*.xml` if present. + /// Localized title names, descriptions, box-set position. Orthogonal + /// to per-stream labels; populated independently from the parser + /// registry. + pub disc_metadata: Option, } /// List filenames found under any `/BDMV/JAR//` subdirectory of @@ -544,12 +593,14 @@ mod registry_tests { "pixelogic", "ctrm", "dbp", - "deluxe" + "deluxe", + "mpls_universal", ], - "PARSERS array order changed — confirm dbp + deluxe stay last \ - (loose detect, real check in parse), and stricter parsers \ - (paramount/criterion/pixelogic/ctrm — all file-presence \ - gated detect) stay first." + "PARSERS array order changed — confirm dbp + deluxe stay just \ + before mpls_universal (loose detect, real check in parse), \ + stricter parsers (paramount/criterion/pixelogic/ctrm — all \ + file-presence gated detect) stay first, and mpls_universal \ + stays LAST as the universal Low-confidence fallback." ); } diff --git a/src/labels/mpls_universal.rs b/src/labels/mpls_universal.rs new file mode 100644 index 0000000..ed2b9be --- /dev/null +++ b/src/labels/mpls_universal.rs @@ -0,0 +1,595 @@ +//! Universal MPLS-based stream labels. +//! +//! Unlike the framework-specific parsers in this directory (dbp, +//! pixelogic, ctrm, criterion, ...), this module is the *floor*: +//! every Blu-ray ships with MPLS playlists under `/BDMV/PLAYLIST/`, +//! and every MPLS file has an STN table with per-stream ISO 639-2 +//! language codes plus coding-type / channel-layout / sample-rate +//! bytes from the BD spec. +//! +//! The framework parsers extract richer editorial labels ("English +//! Dolby Atmos", "Director's Commentary") when the disc was authored +//! with a recognized tool. When none of them match (e.g. a "no BD-J" +//! disc, or an authoring framework we haven't catalogued), MPLS still +//! gives us language + codec on every stream — enough to render +//! something more useful than the bare PID. +//! +//! Output confidence is Low: MPLS carries language + codec but +//! never purpose/qualifier info (no way to tell "Commentary" from +//! "Normal" from the STN table alone). Higher-confidence framework +//! parsers, when present, always win on the registry's max-by-confidence +//! tiebreaker — MPLS is only chosen when nothing else matched. + +use super::{ + LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, + vocab::{self, LangInfo}, +}; +use crate::sector::SectorReader; +use crate::udf::UdfFs; + +/// True iff `/BDMV/PLAYLIST/` exists and contains at least one +/// `.mpls` file. Cheap directory walk only — no sector reads. +pub fn detect(udf: &UdfFs) -> bool { + let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else { + return false; + }; + dir.entries + .iter() + .any(|e| !e.is_dir && has_mpls_extension(&e.name)) +} + +/// Walk every `*.mpls` in `/BDMV/PLAYLIST/`, parse it, and convert +/// each StreamEntry to a [`StreamLabel`]. Streams shared across +/// playlists (same PID) are deduped. +/// +/// Returns `None` if no labels could be produced (e.g. no .mpls files +/// parsed successfully, or every parsed stream was a type we skip +/// like IG / DV EL). +pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { + let playlist_dir = udf.find_dir("/BDMV/PLAYLIST")?; + + // Collect mpls filenames first so we don't hold a borrow on udf + // while we call udf.read_file (which takes &self). + let mpls_names: Vec = playlist_dir + .entries + .iter() + .filter(|e| !e.is_dir && has_mpls_extension(&e.name)) + .map(|e| e.name.clone()) + .collect(); + + if mpls_names.is_empty() { + return None; + } + + let mut labels: Vec = Vec::new(); + // (stream_type_tag, language, codec_hint, pid) — PID is the + // canonical "same physical stream" key; type+lang+codec round + // out the rare case where two distinct logical streams happen + // to share a PID across playlists with different metadata. + let mut seen: Vec<(u8, String, String, u16)> = Vec::new(); + + for name in &mpls_names { + let path = format!("/BDMV/PLAYLIST/{}", name); + let Ok(data) = udf.read_file(reader, &path) else { + continue; + }; + let Ok(playlist) = crate::mpls::parse(&data) else { + continue; + }; + + // Per-MPLS-file 1-based counters keyed by StreamLabelType. + // The dedup pass below removes duplicates across files; the + // numbering of the *surviving* entries comes from whichever + // playlist contributed each PID first. + let mut audio_idx: u16 = 0; + let mut sub_idx: u16 = 0; + + for entry in &playlist.streams { + let label_type = match entry.stream_type { + 2 | 5 => StreamLabelType::Audio, // primary + secondary audio + 3 => StreamLabelType::Subtitle, // PG subtitle + // 1 = primary video, 6 = secondary video, 7 = DV EL + // → no StreamLabelType variant for video, skip. + // 4 = IG (interactive graphics) — not a user-facing + // stream, skip. + _ => continue, + }; + + let stream_number = match label_type { + StreamLabelType::Audio => { + audio_idx += 1; + audio_idx + } + StreamLabelType::Subtitle => { + sub_idx += 1; + sub_idx + } + }; + + let language = normalize_language(&entry.language); + let name = language_display_name(&language); + let codec_hint = build_codec_hint(label_type, entry); + + let type_tag = type_tag(label_type); + let key = (type_tag, language.clone(), codec_hint.clone(), entry.pid); + if seen.contains(&key) { + continue; + } + seen.push(key); + + labels.push(StreamLabel { + stream_number, + stream_type: label_type, + language, + name, + purpose: LabelPurpose::Normal, + qualifier: LabelQualifier::None, + codec_hint, + variant: String::new(), + }); + } + } + + if labels.is_empty() { + return None; + } + + // MPLS gives language + codec but never editorial info (no + // commentary/SDH/director's cut). Low confidence means framework + // parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always + // win when they match. MPLS only gets chosen as the parser when + // nothing else fired — exactly the universal-fallback role we want. + Some(ParseResult::low(labels)) +} + +fn has_mpls_extension(name: &str) -> bool { + // Case-insensitive ".mpls" suffix. Some discs use uppercase, + // some lowercase; UDF filenames preserve case but we don't. + let n = name.len(); + if n < 5 { + return false; + } + name[n - 5..].eq_ignore_ascii_case(".mpls") +} + +/// Lowercase + trim the raw 3-char ISO 639-2 code. If the lowered +/// string maps via [`vocab::lang`] (it won't for plain "eng" — that +/// matcher is for English-name fragments, not codes) use its +/// canonical code; otherwise return the trimmed lowercase string. +fn normalize_language(raw: &str) -> String { + let trimmed = raw.trim().to_ascii_lowercase(); + if trimmed.is_empty() { + return String::new(); + } + // vocab::lang() matches free-form English names, not ISO 639-2 + // codes — so for the typical MPLS payload ("eng", "fra", ...) + // it returns None and we keep the trimmed code. + if let Some(LangInfo { code, .. }) = vocab::lang(&trimmed) { + return code.to_string(); + } + trimmed +} + +/// Human-readable English name for an ISO 639-2 code, or empty if +/// the code is unknown. Kept inline rather than in vocab because +/// vocab is the *reverse* mapping (name → code). +fn language_display_name(iso: &str) -> String { + match iso { + "eng" => "English", + "fra" | "fre" => "French", + "spa" => "Spanish", + "deu" | "ger" => "German", + "ita" => "Italian", + "jpn" => "Japanese", + "zho" | "chi" => "Chinese", + "kor" => "Korean", + "por" => "Portuguese", + "pol" => "Polish", + "ces" | "cze" => "Czech", + "hun" => "Hungarian", + "nld" | "dut" => "Dutch", + "ara" => "Arabic", + "hin" => "Hindi", + "tur" => "Turkish", + "tha" => "Thai", + "swe" => "Swedish", + "nor" => "Norwegian", + "dan" => "Danish", + "fin" => "Finnish", + "heb" => "Hebrew", + "rus" => "Russian", + "ell" | "gre" => "Greek", + "vie" => "Vietnamese", + "ind" => "Indonesian", + "msa" | "may" => "Malay", + "ukr" => "Ukrainian", + "ron" | "rum" => "Romanian", + "bul" => "Bulgarian", + "hrv" => "Croatian", + "srp" => "Serbian", + "slk" | "slo" => "Slovak", + "slv" => "Slovenian", + "est" => "Estonian", + "lav" => "Latvian", + "lit" => "Lithuanian", + "isl" | "ice" => "Icelandic", + "eus" | "baq" => "Basque", + "cat" => "Catalan", + "glg" => "Galician", + _ => "", + } + .to_string() +} + +/// Map BD coding_type byte → codec name. Returns empty for unknown +/// bytes (the table covers everything the spec defines, but unknown +/// values are still possible on malformed discs). +fn codec_name(coding_type: u8) -> &'static str { + match coding_type { + 0x02 => "MPEG-2", + 0x1B => "H.264", + 0x24 => "HEVC", + 0x80 => "LPCM", + 0x81 => "AC-3", + 0x82 => "DTS", + 0x83 => "TrueHD", + 0x84 => "AC-3+", + 0x85 => "DTS-HD", + 0x86 => "DTS-HD MA", + 0x90 => "PG", + 0x91 => "IG", + 0xA1 => "AC-3+ Secondary", + 0xA2 => "DTS-HD Secondary", + _ => "", + } +} + +/// Build the final `codec_hint`. For audio streams, optionally +/// append " " and/or " " suffixes. Sample rate is +/// only spelled out for non-48k (the universal default). +fn build_codec_hint(label_type: StreamLabelType, entry: &crate::mpls::StreamEntry) -> String { + let base = codec_name(entry.coding_type); + if base.is_empty() { + return String::new(); + } + + if label_type != StreamLabelType::Audio { + return base.to_string(); + } + + let mut out = base.to_string(); + + let channels = match entry.audio_format { + 1 => Some("mono"), + 3 => Some("2.0"), + 6 => Some("5.1"), + 12 => Some("7.1"), + _ => None, + }; + if let Some(ch) = channels { + out.push(' '); + out.push_str(ch); + } + + // 1 = 48 kHz (universal default, omit). Only call out higher rates. + let rate = match entry.audio_rate { + 4 => Some("96kHz"), + 5 => Some("192kHz"), + _ => None, + }; + if let Some(r) = rate { + out.push(' '); + out.push_str(r); + } + + out +} + +/// Dedup-tag for the label type. `u8` instead of `StreamLabelType` +/// itself because the enum does not derive `Hash` / `Eq`-by-discriminant +/// in a way that we want to couple to (and `==` works fine for the +/// linear `Vec::contains` lookup we do). +fn type_tag(t: StreamLabelType) -> u8 { + match t { + StreamLabelType::Audio => 1, + StreamLabelType::Subtitle => 2, + } +} + +// ── Tests ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::mpls::{Playlist, StreamEntry}; + + fn audio_entry(pid: u16, coding: u8, fmt: u8, rate: u8, lang: &str) -> StreamEntry { + StreamEntry { + stream_type: 2, + pid, + coding_type: coding, + video_format: 0, + video_rate: 0, + audio_format: fmt, + audio_rate: rate, + language: lang.to_string(), + dynamic_range: 0, + color_space: 0, + secondary: false, + } + } + + fn pg_entry(pid: u16, lang: &str) -> StreamEntry { + StreamEntry { + stream_type: 3, + pid, + coding_type: 0x90, + video_format: 0, + video_rate: 0, + audio_format: 0, + audio_rate: 0, + language: lang.to_string(), + dynamic_range: 0, + color_space: 0, + secondary: false, + } + } + + fn playlist_with(streams: Vec) -> Playlist { + Playlist { + version: "0200".to_string(), + play_items: Vec::new(), + streams, + marks: Vec::new(), + } + } + + /// Drive the same conversion logic that `parse()` runs on real + /// disc data, but starting from already-parsed Playlists so we + /// don't have to synthesize valid MPLS bytes. + fn labels_from_playlists(playlists: &[Playlist]) -> Vec { + let mut labels: Vec = Vec::new(); + let mut seen: Vec<(u8, String, String, u16)> = Vec::new(); + + for playlist in playlists { + let mut audio_idx: u16 = 0; + let mut sub_idx: u16 = 0; + + for entry in &playlist.streams { + let label_type = match entry.stream_type { + 2 | 5 => StreamLabelType::Audio, + 3 => StreamLabelType::Subtitle, + _ => continue, + }; + let stream_number = match label_type { + StreamLabelType::Audio => { + audio_idx += 1; + audio_idx + } + StreamLabelType::Subtitle => { + sub_idx += 1; + sub_idx + } + }; + let language = normalize_language(&entry.language); + let name = language_display_name(&language); + let codec_hint = build_codec_hint(label_type, entry); + let key = ( + type_tag(label_type), + language.clone(), + codec_hint.clone(), + entry.pid, + ); + if seen.contains(&key) { + continue; + } + seen.push(key); + labels.push(StreamLabel { + stream_number, + stream_type: label_type, + language, + name, + purpose: LabelPurpose::Normal, + qualifier: LabelQualifier::None, + codec_hint, + variant: String::new(), + }); + } + } + labels + } + + #[test] + fn mpls_audio_streams_become_labels() { + // Two audio streams: English TrueHD 7.1 48k, French AC-3 5.1 48k. + let pl = playlist_with(vec![ + audio_entry(0x1100, 0x83, 12, 1, "eng"), + audio_entry(0x1101, 0x81, 6, 1, "fra"), + ]); + let labels = labels_from_playlists(&[pl]); + assert_eq!(labels.len(), 2); + + // English TrueHD 7.1 + let a = &labels[0]; + assert_eq!(a.stream_type, StreamLabelType::Audio); + assert_eq!(a.stream_number, 1); + assert_eq!(a.language, "eng"); + assert_eq!(a.name, "English"); + assert_eq!(a.codec_hint, "TrueHD 7.1"); + assert_eq!(a.purpose, LabelPurpose::Normal); + assert_eq!(a.qualifier, LabelQualifier::None); + assert_eq!(a.variant, ""); + + // French AC-3 5.1 + let b = &labels[1]; + assert_eq!(b.stream_type, StreamLabelType::Audio); + assert_eq!(b.stream_number, 2); + assert_eq!(b.language, "fra"); + assert_eq!(b.name, "French"); + assert_eq!(b.codec_hint, "AC-3 5.1"); + } + + #[test] + fn mpls_pg_streams_become_subtitle_labels() { + let pl = playlist_with(vec![ + pg_entry(0x1200, "eng"), + pg_entry(0x1201, "spa"), + pg_entry(0x1202, "fra"), + ]); + let labels = labels_from_playlists(&[pl]); + assert_eq!(labels.len(), 3); + for label in &labels { + assert_eq!(label.stream_type, StreamLabelType::Subtitle); + assert_eq!(label.codec_hint, "PG"); + } + assert_eq!(labels[0].stream_number, 1); + assert_eq!(labels[0].language, "eng"); + assert_eq!(labels[0].name, "English"); + assert_eq!(labels[1].stream_number, 2); + assert_eq!(labels[1].language, "spa"); + assert_eq!(labels[1].name, "Spanish"); + assert_eq!(labels[2].stream_number, 3); + assert_eq!(labels[2].language, "fra"); + } + + #[test] + fn dedup_streams_across_playlists() { + // Two playlists, same English TrueHD 7.1 PID 0x1100 in both. + // Expect one Audio label, not two. + let pl1 = playlist_with(vec![ + audio_entry(0x1100, 0x83, 12, 1, "eng"), + audio_entry(0x1101, 0x81, 6, 1, "fra"), + ]); + let pl2 = playlist_with(vec![ + audio_entry(0x1100, 0x83, 12, 1, "eng"), // duplicate + audio_entry(0x1102, 0x82, 6, 1, "deu"), // new + ]); + let labels = labels_from_playlists(&[pl1, pl2]); + // Expected: eng@0x1100, fra@0x1101, deu@0x1102 — three uniques. + assert_eq!(labels.len(), 3); + // PID isn't stored on StreamLabel, so assert on the surviving + // language set instead. + let mut langs: Vec = labels.iter().map(|l| l.language.clone()).collect(); + langs.sort(); + assert_eq!(langs, vec!["deu", "eng", "fra"]); + } + + #[test] + fn coding_type_to_codec_hint_table() { + // Spot-check every entry in the spec table. Audio entries + // come back bare (no channels/rate set) so codec_hint is the + // codec name alone. + let cases: &[(u8, &str)] = &[ + (0x02, "MPEG-2"), + (0x1B, "H.264"), + (0x24, "HEVC"), + (0x80, "LPCM"), + (0x81, "AC-3"), + (0x82, "DTS"), + (0x83, "TrueHD"), + (0x84, "AC-3+"), + (0x85, "DTS-HD"), + (0x86, "DTS-HD MA"), + (0x90, "PG"), + (0x91, "IG"), + (0xA1, "AC-3+ Secondary"), + (0xA2, "DTS-HD Secondary"), + ]; + for (ct, expected) in cases { + assert_eq!( + codec_name(*ct), + *expected, + "coding_type 0x{:02X} should map to {}", + ct, + expected + ); + } + // Unknown bytes return empty. + assert_eq!(codec_name(0x00), ""); + assert_eq!(codec_name(0xFF), ""); + } + + #[test] + fn audio_format_appends_channel_layout() { + let mono = audio_entry(1, 0x83, 1, 1, "eng"); + let stereo = audio_entry(2, 0x83, 3, 1, "eng"); + let surround_51 = audio_entry(3, 0x83, 6, 1, "eng"); + let surround_71 = audio_entry(4, 0x83, 12, 1, "eng"); + let unknown = audio_entry(5, 0x83, 0, 1, "eng"); + assert_eq!( + build_codec_hint(StreamLabelType::Audio, &mono), + "TrueHD mono" + ); + assert_eq!( + build_codec_hint(StreamLabelType::Audio, &stereo), + "TrueHD 2.0" + ); + assert_eq!( + build_codec_hint(StreamLabelType::Audio, &surround_51), + "TrueHD 5.1" + ); + assert_eq!( + build_codec_hint(StreamLabelType::Audio, &surround_71), + "TrueHD 7.1" + ); + assert_eq!(build_codec_hint(StreamLabelType::Audio, &unknown), "TrueHD"); + } + + #[test] + fn audio_rate_only_shows_above_48k() { + // 48 kHz (rate=1) is the universal default → not surfaced. + let r48 = audio_entry(1, 0x83, 6, 1, "eng"); + // 96 kHz (rate=4) → surfaced. + let r96 = audio_entry(2, 0x83, 6, 4, "eng"); + // 192 kHz (rate=5) → surfaced. + let r192 = audio_entry(3, 0x83, 6, 5, "eng"); + assert_eq!(build_codec_hint(StreamLabelType::Audio, &r48), "TrueHD 5.1"); + assert_eq!( + build_codec_hint(StreamLabelType::Audio, &r96), + "TrueHD 5.1 96kHz" + ); + assert_eq!( + build_codec_hint(StreamLabelType::Audio, &r192), + "TrueHD 5.1 192kHz" + ); + } + + #[test] + fn unknown_iso_code_passes_through_without_display_name() { + // Made-up code: keep the raw lowercase code as `language`, + // but `name` is empty because we don't know it. + let pl = playlist_with(vec![audio_entry(0x1100, 0x83, 6, 1, "xyz")]); + let labels = labels_from_playlists(&[pl]); + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].language, "xyz"); + assert_eq!(labels[0].name, ""); + } + + #[test] + fn ig_and_dv_streams_are_skipped() { + // stream_type 4 = IG, 7 = DV EL — both must not surface. + let mut ig = pg_entry(0x1400, "eng"); + ig.stream_type = 4; + let mut dv = audio_entry(0x1011, 0x24, 0, 0, ""); + dv.stream_type = 7; + let pl = playlist_with(vec![ig, dv]); + let labels = labels_from_playlists(&[pl]); + assert!(labels.is_empty()); + } + + #[test] + fn secondary_audio_becomes_audio_label() { + // stream_type 5 = secondary audio. The conversion should + // still produce an Audio label (the registry's apply path + // can ignore secondary if it wants — this module just + // surfaces what's there). + let mut sec = audio_entry(0x1A00, 0x83, 3, 1, "eng"); + sec.stream_type = 5; + sec.secondary = true; + let pl = playlist_with(vec![sec]); + let labels = labels_from_playlists(&[pl]); + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].stream_type, StreamLabelType::Audio); + assert_eq!(labels[0].codec_hint, "TrueHD 2.0"); + } +} diff --git a/src/labels/png_filenames.rs b/src/labels/png_filenames.rs new file mode 100644 index 0000000..eee838b --- /dev/null +++ b/src/labels/png_filenames.rs @@ -0,0 +1,72 @@ +//! PNG-filename language token parser — stubbed (noop) pending need. +//! +//! ## What this would do +//! +//! Some discs encode per-language menu localization as pre-rendered PNG +//! menu buttons, one per language, with the language token embedded in +//! the filename. Examples observed in the 2026-05-10 corpus: +//! +//! - **disc-01 (The Amateur)** — `___.png` +//! Region prefix: `USA` / `UK` / `JPN` / etc. +//! Lang tokens (3-char, uppercase): `ENG`, `FRC`, `FRP`, `DEU`, `DUT`, +//! `ITA`, `JPN`, `LAS`, `CSP`, `POL`, `CZE` (11 languages) +//! +//! - **disc-09 (Dune orig)** — `_<variant>_<lang>_Composite<N>.png` +//! Lang tokens (3-char, mixed-case): `Eng`, `Ger` (2 languages) +//! +//! ## Why stubbed +//! +//! MPLS already gives per-stream `language` + `coding_type` + stream-type +//! (audio vs subtitle) on every disc. For the 2 unknown-framework discs +//! that PNG filenames would close (disc-01, disc-09), MPLS will produce +//! a strict superset of what filenames could give us, because MPLS knows +//! per-stream attribution while filenames only know "the disc offers +//! these N language buttons." +//! +//! The **only** thing PNG filenames give us that MPLS doesn't is **studio +//! variant disambiguation**: +//! - `FRC` (French Canadian) vs `FRP` (French Parisian) — MPLS just says `fra` +//! - `LAS` (Latin American Spanish) vs `CSP` (Castilian Spanish) — MPLS just says `spa` +//! +//! That's niche enough that it doesn't justify implementing right now. +//! Reactivate this parser only when: +//! 1. We hit a disc where MPLS is malformed/empty AND PNG filenames are +//! the only language hint, OR +//! 2. A downstream consumer needs the studio variant suffix for output +//! naming (e.g. `Title (French Canadian).mkv` vs `Title (French).mkv`). +//! +//! ## When reactivating +//! +//! Implement `parse` to: +//! 1. Iterate top-level PNG paths in `/BDMV/JAR/` (and `<id>/` subdirs). +//! 2. Tokenize each filename on `_` / `-` / `.` +//! 3. Match each token against an alias table: +//! - ISO 639-1 / 639-2 standard codes +//! - Studio variants: `FRC`/`FRP` → `fra-CA`/`fra-FR`, +//! `LAS`/`CSP` → `spa-419`/`spa-ES`, +//! mixed-case shortforms `Eng`/`Ger`/`Fra`/`Spa`/`Jpn` → ISO 639-2 +//! - Country prefix filter: drop `USA`/`UK`/`JPN`/`AUS`/`GER`/`FR` when +//! they appear in position 0 (those are region markers, not langs). +//! 4. Deduplicate. Confidence stays `Low` because we still don't know +//! per-stream codec or audio/subtitle attribution. +//! +//! Wire as ENRICHMENT after MPLS in `mod.rs::analyze`, not as a primary +//! parser: PNG filenames upgrade `lang=fra` to `lang=fra-CA` when both +//! sources agree on the disc; they should never overwrite MPLS data. + +use super::ParseResult; +use crate::sector::SectorReader; +use crate::udf::UdfFs; + +/// Stub: returns false so the dispatcher never calls `parse`. Reactivate +/// by checking for the patterns described in the module docs. +#[allow(dead_code)] // module-level noop, not wired into PARSERS until needed +pub fn detect(_udf: &UdfFs) -> bool { + false +} + +/// Stub: returns None. See module docs for the implementation sketch. +#[allow(dead_code)] // module-level noop, not wired into PARSERS until needed +pub fn parse(_reader: &mut dyn SectorReader, _udf: &UdfFs) -> Option<ParseResult> { + None +}