//! Stream label extraction from BD-J disc files. //! //! Each parser module represents one BD-J authoring framework. //! To add a new format: //! 1. Create `src/labels/myformat.rs` //! 2. Implement `pub fn detect(udf: &UdfFs) -> bool` //! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option>` //! 4. Add `mod myformat;` below and one line to `PARSERS` array pub(crate) mod class_reader; mod criterion; mod ctrm; mod dbp; mod deluxe; pub(crate) mod jar; mod paramount; mod pixelogic; pub(crate) mod text; pub mod vocab; pub(crate) mod xml; use crate::disc::{DiscTitle, Stream}; use crate::sector::SectorReader; use crate::udf::UdfFs; // 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. /// A stream label extracted from disc config files. #[derive(Debug, Clone)] #[allow(dead_code)] pub struct StreamLabel { /// STN index (1-based) pub stream_number: u16, /// Audio or Subtitle pub stream_type: StreamLabelType, /// ISO 639-2 language code pub language: String, /// Display name (e.g. "Commentary", "Descriptive Audio") pub name: String, /// Stream purpose pub purpose: LabelPurpose, /// Additional qualifier pub qualifier: LabelQualifier, /// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos") pub codec_hint: String, /// Regional variant (e.g. "US", "UK", "Castilian", "Canadian") pub variant: String, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum StreamLabelType { Audio, Subtitle, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum LabelPurpose { Normal, Commentary, Descriptive, Score, Ime, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum LabelQualifier { None, Sdh, DescriptiveService, Forced, } // ── Parser registry ──────────────────────────────────────────────────────── // // Each entry: (name, detect_fn, parse_fn). Order = tiebreaker only — // the registry picks the highest-confidence parse result, falling back // to array order on confidence ties. type DetectFn = fn(&UdfFs) -> bool; type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option; /// Per-parser claim of how reliable its output is. Used by the /// registry to pick between parsers when more than one matches (e.g. /// a disc that has both `bluray_project.bin` and `playlists.xml`). /// /// 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. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Confidence { Medium, High, } /// Successful parser result. `None` from `parse()` still means "this /// isn't my disc" (no labels at all); `Some(ParseResult { labels, .. })` /// with `labels.is_empty()` is also a "no labels" case but reachable /// via the analyzer (used by deluxe today to signal "I recognized the /// framework but Phase D not yet implemented"). #[derive(Debug, Clone)] pub struct ParseResult { pub labels: Vec, pub confidence: Confidence, } impl ParseResult { /// Convenience for the common "I parsed N labels with full schema /// coverage" case. pub fn high(labels: Vec) -> Self { ParseResult { labels, confidence: Confidence::High, } } /// Convenience for "I matched but had to fall back on some fields". pub fn medium(labels: Vec) -> Self { ParseResult { labels, confidence: Confidence::Medium, } } } const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ ("paramount", paramount::detect, paramount::parse), ("criterion", criterion::detect, criterion::parse), ("pixelogic", pixelogic::detect, pixelogic::parse), ("ctrm", ctrm::detect, ctrm::parse), // dbp and deluxe both detect on "any top-level .jar in /BDMV/JAR/" // (every BD-J disc trips that) and do the real vendor-prefix check // in parse(). Order between them is the tiebreaker on equal // confidence; dbp goes first because its parse path is cheaper // (constant-pool iteration vs. deluxe's bytecode walking). ("dbp", dbp::detect, dbp::parse), ("deluxe", deluxe::detect, deluxe::parse), ]; /// Search disc for config files, extract labels, apply to streams. /// This is 100% optional — if anything fails, streams are untouched. pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) { let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf))) .unwrap_or_default(); if labels.is_empty() { return; } for title in titles.iter_mut() { let mut audio_idx: u16 = 0; let mut sub_idx: u16 = 0; for stream in &mut title.streams { match stream { Stream::Audio(a) => { audio_idx += 1; if let Some(label) = labels.iter().find(|l| { l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx }) { // Structured fields — callers translate purpose to UI text. a.purpose = label.purpose; // a.label only carries codec/variant info. NEVER any // English purpose text — the CLI handles that via i18n. let mut parts = Vec::new(); if !label.variant.is_empty() { parts.push(format!("({})", label.variant)); } if !label.codec_hint.is_empty() { parts.push(label.codec_hint.clone()); } if !parts.is_empty() { a.label = parts.join(" "); } else if !label.name.is_empty() && label.purpose == LabelPurpose::Normal { // Only fall back to the parser-supplied display // name when there's no purpose to flag — the CLI // handles purpose rendering itself. a.label = label.name.clone(); } } } Stream::Subtitle(s) => { sub_idx += 1; if let Some(label) = labels.iter().find(|l| { l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx }) { s.qualifier = label.qualifier; if label.qualifier == LabelQualifier::Forced { s.forced = true; } } } _ => {} } } } } /// Fill in default labels for any streams that don't have one. /// Runs after BD-J label extraction — fills gaps with codec + channel descriptions. /// This is the central place for all fallback label generation. pub fn fill_defaults(titles: &mut [crate::disc::DiscTitle]) { use crate::disc::Stream; for title in titles.iter_mut() { for stream in &mut title.streams { match stream { Stream::Audio(a) if a.label.is_empty() => { a.label = generate_audio_label(&a.codec, &a.channels, a.secondary); } Stream::Video(v) if v.label.is_empty() => { v.label = generate_video_label(&v.codec, v.resolution.pixels(), &v.hdr, v.secondary); } Stream::Subtitle(s) if s.forced => { // Ensure forced subs are labeled even if BD-J didn't set a name // (subtitle labels are generally not set — this just marks forced) } _ => {} } } } } fn generate_video_label( codec: &crate::disc::Codec, pixels: (u32, u32), hdr: &crate::disc::HdrFormat, secondary: bool, ) -> String { use crate::disc::HdrFormat; if secondary { // "Dolby Vision EL" is a brand identifier, not English prose, so the // library may emit it. Other "secondary video" wording is a CLI // concern — the library just leaves the label empty. return match hdr { HdrFormat::DolbyVision => "Dolby Vision EL".to_string(), _ => String::new(), }; } let mut parts = Vec::new(); // Codec parts.push(codec.name().to_string()); // Resolution let (w, h) = pixels; let res = if w >= 7680 { "8K" } else if w >= 3840 { "4K" } else if w >= 1920 { "1080p" } else if w >= 1280 { "720p" } else if h >= 576 { "576p" } else if h >= 480 { "480p" } else { "" }; if !res.is_empty() { parts.push(res.into()); } // HDR match hdr { HdrFormat::Sdr => {} _ => parts.push(hdr.name().to_string()), } parts.join(" ") } fn generate_audio_label( codec: &crate::disc::Codec, channels: &crate::disc::AudioChannels, _secondary: bool, ) -> String { use crate::disc::{AudioChannels, Codec}; // Full marketing names for disc audio codecs. // These are codec brand identifiers, not user-facing English prose. let codec_name = match codec { Codec::TrueHd => "Dolby TrueHD", Codec::Ac3 => "Dolby Digital", Codec::Ac3Plus => "Dolby Digital Plus", Codec::DtsHdMa => "DTS-HD Master Audio", Codec::DtsHdHr => "DTS-HD High Resolution", Codec::Dts => "DTS", Codec::Lpcm => "LPCM", Codec::Aac => "AAC", Codec::Mp2 => "MPEG Audio", Codec::Mp3 => "MP3", Codec::Flac => "FLAC", Codec::Opus => "Opus", _ => return String::new(), }; // Channel layout let channel_str = match channels { AudioChannels::Mono => "1.0", AudioChannels::Stereo => "2.0", AudioChannels::Stereo21 => "2.1", AudioChannels::Quad => "4.0", AudioChannels::Surround50 => "5.0", AudioChannels::Surround51 => "5.1", AudioChannels::Surround61 => "6.1", AudioChannels::Surround71 => "7.1", AudioChannels::Unknown => "", }; // The "(Secondary)" suffix is a CLI/UI concern — callers display it from // the AudioStream::secondary bool, not the library. if channel_str.is_empty() { codec_name.to_string() } else { format!("{} {}", codec_name, channel_str) } } fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec { let mut best: Option<(&'static str, ParseResult)> = None; for (name, detect, parse) in PARSERS { if !detect(udf) { continue; } tracing::info!(parser = name, "label parser detected"); let Some(result) = parse(reader, udf) else { continue; }; if result.labels.is_empty() { continue; } // Pick highest confidence. Equal confidence → first wins // (array order tiebreaker). match &best { None => best = Some((name, result)), Some((_, b)) if result.confidence > b.confidence => best = Some((name, result)), _ => {} } } match best { Some((name, r)) => { tracing::info!( parser = name, confidence = ?r.confidence, label_count = r.labels.len(), "label parser selected", ); r.labels } None => { tracing::info!("no label parser matched"); Vec::new() } } } /// Diagnostic introspection — returns the parser that matched, the /// labels it emitted, and the inventory of files under `/BDMV/JAR/*/` /// that the discriminators looked at. Intended for `freemkv-tools /// labels-analyze` and corpus regression tooling, not production code /// paths. The matching/parsing logic is identical to [`extract`]; only /// the return shape is richer (includes confidence, all detected /// parsers, and any parsers that produced empty results). #[doc(hidden)] pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis { let inventory = jar_inventory(udf); let mut parsers_detected: Vec<&'static str> = Vec::new(); let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new(); for (name, detect, parse) in PARSERS { if !detect(udf) { continue; } tracing::info!(parser = name, "label parser detected"); parsers_detected.push(name); if let Some(r) = parse(reader, udf) { all_results.push((name, r)); } } // Selection logic mirrors `extract`: highest confidence + non-empty, // array order tiebreaker. let chosen = all_results .iter() .filter(|(_, r)| !r.labels.is_empty()) .max_by(|(_, a), (_, b)| { // Cmp first by confidence (higher first), then position // (earlier first). max_by yields the maximum, so we // invert the index comparison. a.confidence .cmp(&b.confidence) .then(std::cmp::Ordering::Equal) }); let (parser, confidence, labels) = match chosen { Some((name, r)) => (Some(*name), Some(r.confidence), r.labels.clone()), None => (None, None, Vec::new()), }; if parsers_detected.is_empty() { tracing::info!("no label parser matched"); } else if parser.is_none() { tracing::info!( detected = ?parsers_detected, "label parsers detected but produced no labels" ); } LabelAnalysis { parser, parsers_detected, confidence, jar_inventory: inventory, labels, } } /// Result of [`analyze`]. #[doc(hidden)] #[derive(Debug, Clone)] pub struct LabelAnalysis { /// Which parser was SELECTED — the one whose `ParseResult` had /// the highest confidence among non-empty results (array order /// tiebreaker). `None` means either no parser recognized the /// disc, OR every parser that recognized it returned no labels. /// Use `parsers_detected` to disambiguate. pub parser: Option<&'static str>, /// Confidence of the selected parser, `None` if no parser was /// selected. pub confidence: Option, /// Every parser whose discriminator matched, in registry order. /// Distinguishes "we recognized this disc but couldn't extract /// labels" from "we don't recognize this disc at all" — the /// former points at a parser bug or a truncated capture, the /// latter points at a missing parser. pub parsers_detected: Vec<&'static str>, /// Filenames found under any `/BDMV/JAR/*/` subdirectory, deduped /// and sorted. Helps spot unknown authoring formats when no /// parser detected. pub jar_inventory: Vec, /// Raw labels emitted by the selected parser (empty if `parser` /// is `None`). pub labels: Vec, } /// List filenames found under any `/BDMV/JAR//` subdirectory of /// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is /// present. fn jar_inventory(udf: &UdfFs) -> Vec { let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else { return Vec::new(); }; let mut out: Vec = Vec::new(); for entry in &jar_dir.entries { if entry.is_dir { for child in &entry.entries { if !child.is_dir && !out.contains(&child.name) { out.push(child.name.clone()); } } } } out.sort(); out } // ── Shared helpers ───────────────────────────────────────────────────────── /// Check if a file exists in any BDMV/JAR subdirectory. pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool { find_jar_file(udf, filename).is_some() } /// Find a file in any BDMV/JAR subdirectory, return its path. pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option { let jar_dir = udf.find_dir("/BDMV/JAR")?; for entry in &jar_dir.entries { if entry.is_dir { let path = format!("/BDMV/JAR/{}/{}", entry.name, filename); // Check if file exists in this subdirectory for child in &entry.entries { if !child.is_dir && child.name.eq_ignore_ascii_case(filename) { return Some(path); } } } } None } /// Read a file from any BDMV/JAR subdirectory by filename. pub(crate) fn read_jar_file( reader: &mut dyn SectorReader, udf: &UdfFs, filename: &str, ) -> Option> { let path = find_jar_file(udf, filename)?; udf.read_file(reader, &path).ok().filter(|d| !d.is_empty()) } // ── Registry-level tests ──────────────────────────────────────────────────── #[cfg(test)] mod registry_tests { use super::*; /// Lock the parser roster + order. If someone reorders the array /// or adds/removes a parser, this test forces them to update the /// expectation explicitly. The order is load-bearing: first /// matching `parse()` wins, so reordering changes which parser /// claims a disc on overlapping detect signals. /// /// dbp + deluxe MUST stay at the end (their detect triggers on /// "any BD-J disc"; placing them earlier would short-circuit the /// stricter parsers above them). #[test] fn parsers_registry_order_locked() { let names: Vec<&str> = PARSERS.iter().map(|(n, _, _)| *n).collect(); assert_eq!( names, vec![ "paramount", "criterion", "pixelogic", "ctrm", "dbp", "deluxe" ], "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." ); } /// Per-parser sanity: every parser has both detect and parse /// hooked up. Catches accidental nullification (e.g. someone /// stubbing `parse` to always-None during a refactor). #[test] fn parsers_registry_all_entries_populated() { for (name, detect, parse) in PARSERS { // Function pointers can't be Null in safe Rust, so the // assertion is just that the array entry was constructed // — which the iter above already implies. The test // exists to fail compile if someone changes the tuple // shape (e.g. adds a 4th field) without updating callers, // and as a marker for "these parsers exist." let _ = (name, detect, parse); } assert!(!PARSERS.is_empty(), "PARSERS array must not be empty"); } }