labels: reader-backed detection, Criterion fix, menu-language fallback

- DetectFn now takes a SectorSource so a parser can inspect a jar's
  central directory in detect() instead of firing on "any BD-J jar".
  dbp/deluxe do the real com/<vendor>/ prefix check up front, so each
  claims only its own discs (foundational for scaling the registry).
- criterion: treat a stream-map value of 0 as unmapped and synthesize a
  real 1-based number, so a 0 can't shadow or collide with a genuine
  stream 1 (with regression tests).
- png_filenames: new Low-confidence, last-resort parser reading menu
  language from {title}_UHD01_{LANG}_Composite artwork; sits below the
  MPLS floor so a real framework parser always wins.
- vocab: add menu_lang() for 639-2/B to 639-2/T menu-token normalization.
This commit is contained in:
Matthew Jackson
2026-07-08 14:43:52 -07:00
parent 3da8228068
commit 45c12fc5ce
11 changed files with 278 additions and 68 deletions
+35 -21
View File
@@ -16,7 +16,7 @@ use std::collections::HashMap;
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
/// inside a `/BDMV/JAR/*` archive.
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "streamproperties.xml")
}
@@ -77,11 +77,18 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
/// `apply_labels` matches on `(type, stream_number)`, so a collision
/// would mislabel tracks.)
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
// Numbers already claimed by the map, per type.
// Numbers already claimed by the map, per type. A map value of 0 is NOT a
// claim: apply_labels binds on 1-based stream numbers, so 0 is unmatchable.
// Treat 0 as "unmapped" here (defense in depth — parse_playback_config also
// filters it) so such a stream gets a real synthesized number instead of an
// orphan 0 that collides with / shadows a genuine stream 1.
let mut taken_audio: Vec<u16> = Vec::new();
let mut taken_sub: Vec<u16> = Vec::new();
for info in infos {
if let Some(&n) = stream_map.get(&info.id) {
if n == 0 {
continue;
}
match info.stream_type {
StreamLabelType::Audio => taken_audio.push(n),
StreamLabelType::Subtitle => taken_sub.push(n),
@@ -94,8 +101,8 @@ fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>
let mut out = Vec::with_capacity(infos.len());
for info in infos {
let n = match stream_map.get(&info.id).copied() {
Some(n) => n,
None => {
Some(n) if n != 0 => n,
_ => {
let (idx, taken) = match info.stream_type {
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
@@ -277,27 +284,34 @@ mod tests {
assert_eq!(nums[3], 2); // subtitle 2
}
/// Spec: map stream_num=0 is explicitly rejected (apply_labels uses 1-based).
/// This is documented in parse_playback_config: `if stream_num != 0`.
/// Mutation: remove the `!= 0` guard → zero is stored in map.
/// Spec: a map value of 0 is unmatchable (apply_labels is 1-based), so
/// assign_stream_numbers must treat it as unmapped and synthesize a real
/// 1-based number rather than emit an orphan 0.
/// Mutation: read the map value verbatim → stream_number 0 leaks out.
#[test]
fn map_zero_stream_num_is_skipped() {
// parse_playback_config skips zero; simulate that: the zero shouldn't
// end up in the map. We test assign_stream_numbers with a zero-containing
// map to verify it won't freeze the fallback counter at 1 forever.
fn map_zero_stream_num_is_synthesized_not_emitted() {
let mut map = HashMap::new();
map.insert("a0".to_string(), 0u16); // zero — per spec, was filtered by parse_playback_config
map.insert("a0".to_string(), 0u16); // 0 must not be treated as a claim
let infos = vec![info("a0", StreamLabelType::Audio)];
// If 0 IS in the map and assign_stream_numbers uses it, stream_number=0
// is not matchable (apply_labels is 1-based). The fallback counter
// would assign 1 instead. Test both paths:
let nums = assign_stream_numbers(&infos, &map);
// If the map has 0 for a0, assign_stream_numbers returns 0 (map wins).
// This is a known limitation — the guard lives in parse_playback_config.
// The test documents the ACTUAL behavior so a code change that introduces
// the guard in assign_stream_numbers would be caught.
// Current behavior: map wins → 0.
assert_eq!(nums[0], 0);
// 0 is treated as unmapped → the fallback counter assigns 1.
assert_eq!(nums[0], 1);
}
/// A stream genuinely mapped to 1 plus another stream whose map value is 0
/// must NOT both land on 1: the 0-stream is synthesized past the claimed 1.
#[test]
fn map_zero_does_not_collide_with_a_real_stream_one() {
let mut map = HashMap::new();
map.insert("real".to_string(), 1u16);
map.insert("bad".to_string(), 0u16);
let infos = vec![
info("real", StreamLabelType::Audio),
info("bad", StreamLabelType::Audio),
];
let nums = assign_stream_numbers(&infos, &map);
assert_eq!(nums[0], 1); // the genuinely-mapped stream keeps 1
assert_eq!(nums[1], 2); // the 0-stream is synthesized to the next free slot
}
/// Spec: collision-avoidance works across audio AND subtitle independently.
+1 -1
View File
@@ -11,7 +11,7 @@ use std::collections::HashMap;
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
/// `language_streams.txt` inside a `/BDMV/JAR/*` archive.
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "menu_base.prop")
|| super::jar_file_exists(udf, "language_streams.txt")
}
+10 -8
View File
@@ -35,14 +35,16 @@ use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::BTreeMap;
/// dbp detect can't peek inside a jar without a SectorSource (the
/// trait function only takes `&UdfFs`), so we trigger on the cheap
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
/// BD-J disc, but parse() does the real `com/dbp/` check and
/// returns None on a mismatch — so this parser only ever consumes
/// time on discs that fell through every earlier parser.
pub fn detect(udf: &UdfFs) -> bool {
jar::has_any_top_level_jar(udf)
/// The real dbp signal is the `com/dbp/` package prefix inside a top-level
/// jar's central directory. With a reader in `detect`, we check that directly
/// (a cheap central-directory scan, no class decode) so this parser claims
/// only dbp discs instead of firing on every BD-J disc. `parse()` repeats the
/// check as belt-and-suspenders.
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
jar::for_each_jar(reader, udf, |_entry, archive| {
jar::has_path_prefix(archive, "com/dbp/").then_some(())
})
.is_some()
}
/// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and
+9 -5
View File
@@ -64,11 +64,15 @@ use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::{HashMap, HashSet};
pub fn detect(udf: &UdfFs) -> bool {
// Cheap pre-check at the dir level; the real signal is
// `com/bydeluxe/` inside any top-level jar's central directory,
// which `parse()` confirms when given a `SectorSource`.
jar::has_any_top_level_jar(udf)
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
// The real signal is `com/bydeluxe/` inside a top-level jar's central
// directory. With a reader in detect we check it directly (cheap
// central-directory scan, no bytecode walk) so this parser claims only
// Deluxe discs; `parse()` repeats the check.
jar::for_each_jar(reader, udf, |_entry, archive| {
jar::has_path_prefix(archive, "com/bydeluxe/").then_some(())
})
.is_some()
}
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
-15
View File
@@ -26,21 +26,6 @@ const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
/// etc.
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
/// True if `/BDMV/JAR/` contains at least one top-level `.jar` file
/// (not under a subdir). Used by `detect()` in parsers whose real
/// signal lives inside a jar — they can't open the jar without a
/// `SectorSource`, so they use this cheap pre-check and do the real
/// `com/<vendor>/` discriminator in `parse()`.
pub fn has_any_top_level_jar(udf: &UdfFs) -> bool {
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
return false;
};
jar_dir
.entries
.iter()
.any(|e| !e.is_dir && e.name.to_lowercase().ends_with(".jar"))
}
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
/// produces, or `None` if every jar was visited without a hit.
+28 -15
View File
@@ -20,6 +20,7 @@ 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;
@@ -91,7 +92,11 @@ pub enum LabelQualifier {
// the registry picks the highest-confidence parse result, falling back
// to array order on confidence ties.
type DetectFn = fn(&UdfFs) -> bool;
// `detect` takes the reader too, so a parser can look INSIDE a jar's central
// directory (real vendor-prefix / project-file check) rather than firing on
// "any jar present". Precise detection is what lets the registry scale to many
// parsers without cross-parser collisions.
type DetectFn = fn(&mut dyn SectorSource, &UdfFs) -> bool;
type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option<ParseResult>;
/// Per-parser claim of how reliable its output is. Used by the
@@ -158,11 +163,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
("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 and deluxe now detect via the real `com/<vendor>/` central-directory
// prefix (reader-backed), so they claim only their own discs. 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),
// Universal MPLS fallback. Returns Confidence::Low so framework
@@ -176,6 +181,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
mpls_universal::detect,
mpls_universal::parse,
),
// Menu-graphic filename language hints (Low). AFTER mpls_universal so the
// richer spec-derived floor wins the Low tie whenever it produces anything;
// this only becomes the chosen parser when even MPLS yields nothing but the
// menu artwork still names its languages. A last-resort language source.
("png_filenames", png_filenames::detect, png_filenames::parse),
];
/// Search disc for config files, extract labels, apply to streams.
@@ -521,7 +531,7 @@ fn generate_audio_label_inner(
fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
let mut best: Option<(&'static str, ParseResult)> = None;
for (name, detect, parse) in PARSERS {
if !detect(udf) {
if !detect(reader, udf) {
continue;
}
tracing::info!(parser = name, "label parser detected");
@@ -788,7 +798,7 @@ pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis {
let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new();
for (name, detect, parse) in PARSERS {
if !detect(udf) {
if !detect(reader, udf) {
continue;
}
tracing::info!(parser = name, "label parser detected");
@@ -961,8 +971,9 @@ pub struct ChapterSummary {
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
/// present.
fn jar_inventory(udf: &UdfFs) -> Vec<String> {
/// present. `pub(crate)` so filename-based parsers (e.g. `png_filenames`)
/// can scan menu-asset names without a reader.
pub(crate) fn jar_inventory(udf: &UdfFs) -> Vec<String> {
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
return Vec::new();
};
@@ -1042,12 +1053,14 @@ mod registry_tests {
"dbp",
"deluxe",
"mpls_universal",
"png_filenames",
],
"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."
"PARSERS array order changed — file-presence/reader-gated High \
parsers (paramount/criterion/pixelogic/ctrm) stay first; dbp + \
deluxe (now real com/<vendor>/ prefix detect) stay before \
mpls_universal; mpls_universal stays the universal Low fallback; \
png_filenames (Low, language-only hint) stays LAST so MPLS wins \
the Low tie whenever it produces anything."
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ 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 {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
return false;
};
+1 -1
View File
@@ -16,7 +16,7 @@ use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelT
use crate::sector::SectorSource;
use crate::udf::UdfFs;
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "playlists.xml")
}
+1 -1
View File
@@ -25,7 +25,7 @@ const REGIONS: &[&str] = &[
"US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE",
];
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "bluray_project.bin")
}
+149
View File
@@ -0,0 +1,149 @@
//! Menu-graphic filename language hints.
//!
//! Some BD-J discs encode per-language menu artwork with the language in the
//! filename, e.g. `Dune_UHD01_Eng_Composite1.png`,
//! `VForVendetta_UHD01_FRE_Composite2.png`. The `_UHD01_{LANG}_Composite`
//! marker is authored deliberately, so the set of `{LANG}` tokens is the set
//! of menu languages the disc ships.
//!
//! This is a language-only hint (no per-stream purpose/codec), so it runs at
//! [`Confidence::Low`] — it never displaces a real framework parser, and it
//! sits at the same tier as the MPLS floor. It is here so the pattern is a
//! first-class, testable parser that keeps picking up discs as the corpus
//! grows, rather than lost logic. Detection is precise: it fires only on the
//! `_UHD01_{LANG}_Composite` grammar with a `{LANG}` the vocab recognizes.
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab};
use crate::sector::SectorSource;
use crate::udf::UdfFs;
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_inventory(udf)
.iter()
.any(|f| filename_lang(f).is_some())
}
pub fn parse(_reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
let names = super::jar_inventory(udf);
let labels = labels_from_filenames(&names);
if labels.is_empty() {
return None;
}
// Low: language-only, derived from menu-asset filenames. A real framework
// parser (and even the MPLS floor's per-stream data) is preferred; this is
// a hint of which languages the disc menus offer.
Some(ParseResult::low(labels))
}
/// One audio [`StreamLabel`] per distinct menu language found, in first-seen
/// order, numbered 1-based. Split out from `parse` so it is unit-testable
/// without a `UdfFs`.
fn labels_from_filenames(names: &[String]) -> Vec<StreamLabel> {
let mut seen: Vec<&'static str> = Vec::new();
for name in names {
if let Some(code) = filename_lang(name) {
if !seen.contains(&code) {
seen.push(code);
}
}
}
seen.into_iter()
.enumerate()
.map(|(i, code)| StreamLabel {
stream_number: (i as u16).saturating_add(1),
stream_type: StreamLabelType::Audio,
language: code.to_string(),
name: String::new(),
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
codec_hint: String::new(),
variant: String::new(),
})
.collect()
}
/// Extract the ISO-639-2 language code from a `{title}_UHD01_{LANG}_Composite`
/// menu-graphic filename, or `None` if the name does not match the grammar or
/// carries a `{LANG}` the vocab does not recognize.
///
/// The `_UHD01_` marker plus the `_Composite` suffix keep this from firing on
/// unrelated PNGs (`KeyComposite4.png`, `LoadingComposite1.png` have no
/// `_UHD01_{LANG}_` segment).
fn filename_lang(name: &str) -> Option<&'static str> {
// Case-fold once; the marker/suffix are matched case-insensitively.
let lower = name.to_ascii_lowercase();
let marker = "_uhd01_";
let m = lower.find(marker)?;
let after = m + marker.len();
// The language token runs from `after` up to the next `_`.
let rest = &lower[after..];
let end = rest.find('_')?;
if !rest[end..].starts_with("_composite") {
return None;
}
let token = &name[after..after + end];
vocab::menu_lang(token)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_confirmed_samples() {
assert_eq!(filename_lang("Dune_UHD01_Eng_Composite1.png"), Some("eng"));
assert_eq!(filename_lang("Dune_UHD01_Ger_Composite2.png"), Some("deu"));
assert_eq!(
filename_lang("VForVendetta_UHD01_FRE_Composite2.png"),
Some("fra")
);
}
#[test]
fn ignores_non_language_composites() {
assert_eq!(filename_lang("KeyComposite4.png"), None);
assert_eq!(filename_lang("LoadingComposite1.png"), None);
assert_eq!(
filename_lang("FourKWarningsComposite1_bt2020_HDR.png"),
None
);
assert_eq!(filename_lang("Fast9_UPK75_Composite1.png"), None);
}
#[test]
fn unknown_language_token_is_none() {
// A UHD01 marker but a token the vocab does not recognize must not
// produce a bogus language.
assert_eq!(filename_lang("Movie_UHD01_Zzz_Composite1.png"), None);
}
#[test]
fn dedups_and_numbers_distinct_languages() {
let names = vec![
"Dune_UHD01_Eng_Composite1.png".to_string(),
"Dune_UHD01_Eng_Composite2.png".to_string(),
"Dune_UHD01_Ger_Composite1.png".to_string(),
"LoadingComposite1.png".to_string(),
];
let labels = labels_from_filenames(&names);
assert_eq!(labels.len(), 2);
assert_eq!(labels[0].language, "eng");
assert_eq!(labels[0].stream_number, 1);
assert_eq!(labels[1].language, "deu");
assert_eq!(labels[1].stream_number, 2);
assert!(
labels
.iter()
.all(|l| l.stream_type == StreamLabelType::Audio)
);
}
#[test]
fn no_matching_names_yields_empty() {
let names = vec![
"KeyComposite4.png".to_string(),
"disc.properties".to_string(),
];
assert!(labels_from_filenames(&names).is_empty());
}
}
+43
View File
@@ -176,6 +176,49 @@ const BARE_LANGS: &[(&str, &str)] = &[
("galician", "glg"),
];
/// Map a short menu-graphic language token (as embedded in authoring
/// filenames like `Dune_UHD01_Eng_Composite1.png`) to an ISO-639-2/T code.
///
/// These filename tokens are compact 2/3-letter abbreviations, NOT the full
/// language names [`lang`] handles, so they get their own certain table.
/// Accepts the ISO-639-2/B spellings some tools emit (`ger`, `fre`, `chi`)
/// and normalizes them to the /T code the rest of the pipeline uses (`deu`,
/// `fra`, `zho`). Case-insensitive. Returns `None` for anything not in the
/// table — never guesses, so an unrecognized token drops rather than
/// mislabels.
pub fn menu_lang(token: &str) -> Option<&'static str> {
let t = token.trim().to_ascii_lowercase();
let code = match t.as_str() {
"eng" | "en" => "eng",
"ger" | "deu" | "de" => "deu",
"fre" | "fra" | "fr" => "fra",
"spa" | "es" => "spa",
"ita" | "it" => "ita",
"por" | "pt" => "por",
"jpn" | "jap" | "ja" => "jpn",
"kor" | "ko" => "kor",
"chi" | "zho" | "zh" => "zho",
"rus" | "ru" => "rus",
"dut" | "nld" | "nl" => "nld",
"pol" | "pl" => "pol",
"cze" | "ces" | "cs" => "ces",
"dan" | "da" => "dan",
"fin" | "fi" => "fin",
"nor" | "no" => "nor",
"swe" | "sv" => "swe",
"hun" | "hu" => "hun",
"gre" | "ell" | "el" => "ell",
"tur" | "tr" => "tur",
"ara" | "ar" => "ara",
"hin" | "hi" => "hin",
"tha" | "th" => "tha",
"ukr" | "uk" => "ukr",
"cat" | "ca" => "cat",
_ => return None,
};
Some(code)
}
// ── Purpose ──────────────────────────────────────────────────────────────────
/// Classify a free-form English label string into a [`LabelPurpose`].