diff --git a/src/labels/criterion.rs b/src/labels/criterion.rs index 1bc698a..0a287d2 100644 --- a/src/labels/criterion.rs +++ b/src/labels/criterion.rs @@ -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 /// `apply_labels` matches on `(type, stream_number)`, so a collision /// would mislabel tracks.) fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap) -> Vec { - // 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 = Vec::new(); let mut taken_sub: Vec = 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 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. diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index cdbb3b7..8398750 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -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") } diff --git a/src/labels/dbp.rs b/src/labels/dbp.rs index 8d4eb10..6ae6010 100644 --- a/src/labels/dbp.rs +++ b/src/labels/dbp.rs @@ -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 diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs index f880587..e7d5799 100644 --- a/src/labels/deluxe.rs +++ b/src/labels/deluxe.rs @@ -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 { diff --git a/src/labels/jar.rs b/src/labels/jar.rs index ebcad1a..f07ccf4 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -26,21 +26,6 @@ const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024; /// etc. pub type Jar = ZipArchive>>; -/// 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//` 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. diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 38e614e..fc3c46f 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -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; /// 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//` 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 { 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//` subdirectory of /// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is -/// present. -fn jar_inventory(udf: &UdfFs) -> Vec { +/// 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 { 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// 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." ); } diff --git a/src/labels/mpls_universal.rs b/src/labels/mpls_universal.rs index fd52e04..fec96be 100644 --- a/src/labels/mpls_universal.rs +++ b/src/labels/mpls_universal.rs @@ -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; }; diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index a5f5506..c26aedb 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -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") } diff --git a/src/labels/pixelogic.rs b/src/labels/pixelogic.rs index da9860d..8c63fd3 100644 --- a/src/labels/pixelogic.rs +++ b/src/labels/pixelogic.rs @@ -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") } diff --git a/src/labels/png_filenames.rs b/src/labels/png_filenames.rs new file mode 100644 index 0000000..b66aa78 --- /dev/null +++ b/src/labels/png_filenames.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/src/labels/vocab.rs b/src/labels/vocab.rs index 4f49f2a..c861484 100644 --- a/src/labels/vocab.rs +++ b/src/labels/vocab.rs @@ -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`].