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:
+35
-21
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
||||||
/// inside a `/BDMV/JAR/*` archive.
|
/// 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")
|
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
|
/// `apply_labels` matches on `(type, stream_number)`, so a collision
|
||||||
/// would mislabel tracks.)
|
/// would mislabel tracks.)
|
||||||
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
|
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_audio: Vec<u16> = Vec::new();
|
||||||
let mut taken_sub: Vec<u16> = Vec::new();
|
let mut taken_sub: Vec<u16> = Vec::new();
|
||||||
for info in infos {
|
for info in infos {
|
||||||
if let Some(&n) = stream_map.get(&info.id) {
|
if let Some(&n) = stream_map.get(&info.id) {
|
||||||
|
if n == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match info.stream_type {
|
match info.stream_type {
|
||||||
StreamLabelType::Audio => taken_audio.push(n),
|
StreamLabelType::Audio => taken_audio.push(n),
|
||||||
StreamLabelType::Subtitle => taken_sub.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());
|
let mut out = Vec::with_capacity(infos.len());
|
||||||
for info in infos {
|
for info in infos {
|
||||||
let n = match stream_map.get(&info.id).copied() {
|
let n = match stream_map.get(&info.id).copied() {
|
||||||
Some(n) => n,
|
Some(n) if n != 0 => n,
|
||||||
None => {
|
_ => {
|
||||||
let (idx, taken) = match info.stream_type {
|
let (idx, taken) = match info.stream_type {
|
||||||
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
||||||
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
||||||
@@ -277,27 +284,34 @@ mod tests {
|
|||||||
assert_eq!(nums[3], 2); // subtitle 2
|
assert_eq!(nums[3], 2); // subtitle 2
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: map stream_num=0 is explicitly rejected (apply_labels uses 1-based).
|
/// Spec: a map value of 0 is unmatchable (apply_labels is 1-based), so
|
||||||
/// This is documented in parse_playback_config: `if stream_num != 0`.
|
/// assign_stream_numbers must treat it as unmapped and synthesize a real
|
||||||
/// Mutation: remove the `!= 0` guard → zero is stored in map.
|
/// 1-based number rather than emit an orphan 0.
|
||||||
|
/// Mutation: read the map value verbatim → stream_number 0 leaks out.
|
||||||
#[test]
|
#[test]
|
||||||
fn map_zero_stream_num_is_skipped() {
|
fn map_zero_stream_num_is_synthesized_not_emitted() {
|
||||||
// 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.
|
|
||||||
let mut map = HashMap::new();
|
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)];
|
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);
|
let nums = assign_stream_numbers(&infos, &map);
|
||||||
// If the map has 0 for a0, assign_stream_numbers returns 0 (map wins).
|
// 0 is treated as unmapped → the fallback counter assigns 1.
|
||||||
// This is a known limitation — the guard lives in parse_playback_config.
|
assert_eq!(nums[0], 1);
|
||||||
// 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.
|
/// A stream genuinely mapped to 1 plus another stream whose map value is 0
|
||||||
assert_eq!(nums[0], 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.
|
/// Spec: collision-avoidance works across audio AND subtitle independently.
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
|
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
|
||||||
/// `language_streams.txt` inside a `/BDMV/JAR/*` archive.
|
/// `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, "menu_base.prop")
|
||||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -35,14 +35,16 @@ use crate::sector::SectorSource;
|
|||||||
use crate::udf::UdfFs;
|
use crate::udf::UdfFs;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// dbp detect can't peek inside a jar without a SectorSource (the
|
/// The real dbp signal is the `com/dbp/` package prefix inside a top-level
|
||||||
/// trait function only takes `&UdfFs`), so we trigger on the cheap
|
/// jar's central directory. With a reader in `detect`, we check that directly
|
||||||
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
|
/// (a cheap central-directory scan, no class decode) so this parser claims
|
||||||
/// BD-J disc, but parse() does the real `com/dbp/` check and
|
/// only dbp discs instead of firing on every BD-J disc. `parse()` repeats the
|
||||||
/// returns None on a mismatch — so this parser only ever consumes
|
/// check as belt-and-suspenders.
|
||||||
/// time on discs that fell through every earlier parser.
|
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
jar::for_each_jar(reader, udf, |_entry, archive| {
|
||||||
jar::has_any_top_level_jar(udf)
|
jar::has_path_prefix(archive, "com/dbp/").then_some(())
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and
|
/// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and
|
||||||
|
|||||||
@@ -64,11 +64,15 @@ use crate::sector::SectorSource;
|
|||||||
use crate::udf::UdfFs;
|
use crate::udf::UdfFs;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
// Cheap pre-check at the dir level; the real signal is
|
// The real signal is `com/bydeluxe/` inside a top-level jar's central
|
||||||
// `com/bydeluxe/` inside any top-level jar's central directory,
|
// directory. With a reader in detect we check it directly (cheap
|
||||||
// which `parse()` confirms when given a `SectorSource`.
|
// central-directory scan, no bytecode walk) so this parser claims only
|
||||||
jar::has_any_top_level_jar(udf)
|
// 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> {
|
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||||
|
|||||||
@@ -26,21 +26,6 @@ const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
|
|||||||
/// etc.
|
/// etc.
|
||||||
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
|
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
|
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
|
||||||
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
||||||
/// produces, or `None` if every jar was visited without a hit.
|
/// produces, or `None` if every jar was visited without a hit.
|
||||||
|
|||||||
+28
-15
@@ -20,6 +20,7 @@ pub(crate) mod jar;
|
|||||||
mod mpls_universal;
|
mod mpls_universal;
|
||||||
mod paramount;
|
mod paramount;
|
||||||
mod pixelogic;
|
mod pixelogic;
|
||||||
|
mod png_filenames;
|
||||||
pub(crate) mod text;
|
pub(crate) mod text;
|
||||||
pub mod vocab;
|
pub mod vocab;
|
||||||
pub(crate) mod xml;
|
pub(crate) mod xml;
|
||||||
@@ -91,7 +92,11 @@ pub enum LabelQualifier {
|
|||||||
// the registry picks the highest-confidence parse result, falling back
|
// the registry picks the highest-confidence parse result, falling back
|
||||||
// to array order on confidence ties.
|
// 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>;
|
type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option<ParseResult>;
|
||||||
|
|
||||||
/// Per-parser claim of how reliable its output is. Used by the
|
/// 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),
|
("criterion", criterion::detect, criterion::parse),
|
||||||
("pixelogic", pixelogic::detect, pixelogic::parse),
|
("pixelogic", pixelogic::detect, pixelogic::parse),
|
||||||
("ctrm", ctrm::detect, ctrm::parse),
|
("ctrm", ctrm::detect, ctrm::parse),
|
||||||
// dbp and deluxe both detect on "any top-level .jar in /BDMV/JAR/"
|
// dbp and deluxe now detect via the real `com/<vendor>/` central-directory
|
||||||
// (every BD-J disc trips that) and do the real vendor-prefix check
|
// prefix (reader-backed), so they claim only their own discs. Order between
|
||||||
// in parse(). Order between them is the tiebreaker on equal
|
// them is the tiebreaker on equal confidence; dbp goes first because its
|
||||||
// confidence; dbp goes first because its parse path is cheaper
|
// parse path is cheaper (constant-pool iteration vs. deluxe's bytecode
|
||||||
// (constant-pool iteration vs. deluxe's bytecode walking).
|
// walking).
|
||||||
("dbp", dbp::detect, dbp::parse),
|
("dbp", dbp::detect, dbp::parse),
|
||||||
("deluxe", deluxe::detect, deluxe::parse),
|
("deluxe", deluxe::detect, deluxe::parse),
|
||||||
// Universal MPLS fallback. Returns Confidence::Low so framework
|
// Universal MPLS fallback. Returns Confidence::Low so framework
|
||||||
@@ -176,6 +181,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
|||||||
mpls_universal::detect,
|
mpls_universal::detect,
|
||||||
mpls_universal::parse,
|
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.
|
/// 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> {
|
fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||||
let mut best: Option<(&'static str, ParseResult)> = None;
|
let mut best: Option<(&'static str, ParseResult)> = None;
|
||||||
for (name, detect, parse) in PARSERS {
|
for (name, detect, parse) in PARSERS {
|
||||||
if !detect(udf) {
|
if !detect(reader, udf) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tracing::info!(parser = name, "label parser detected");
|
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();
|
let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new();
|
||||||
|
|
||||||
for (name, detect, parse) in PARSERS {
|
for (name, detect, parse) in PARSERS {
|
||||||
if !detect(udf) {
|
if !detect(reader, udf) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tracing::info!(parser = name, "label parser detected");
|
tracing::info!(parser = name, "label parser detected");
|
||||||
@@ -961,8 +971,9 @@ pub struct ChapterSummary {
|
|||||||
|
|
||||||
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
|
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
|
||||||
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
|
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
|
||||||
/// present.
|
/// present. `pub(crate)` so filename-based parsers (e.g. `png_filenames`)
|
||||||
fn jar_inventory(udf: &UdfFs) -> Vec<String> {
|
/// 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 {
|
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -1042,12 +1053,14 @@ mod registry_tests {
|
|||||||
"dbp",
|
"dbp",
|
||||||
"deluxe",
|
"deluxe",
|
||||||
"mpls_universal",
|
"mpls_universal",
|
||||||
|
"png_filenames",
|
||||||
],
|
],
|
||||||
"PARSERS array order changed — confirm dbp + deluxe stay just \
|
"PARSERS array order changed — file-presence/reader-gated High \
|
||||||
before mpls_universal (loose detect, real check in parse), \
|
parsers (paramount/criterion/pixelogic/ctrm) stay first; dbp + \
|
||||||
stricter parsers (paramount/criterion/pixelogic/ctrm — all \
|
deluxe (now real com/<vendor>/ prefix detect) stay before \
|
||||||
file-presence gated detect) stay first, and mpls_universal \
|
mpls_universal; mpls_universal stays the universal Low fallback; \
|
||||||
stays LAST as the universal Low-confidence fallback."
|
png_filenames (Low, language-only hint) stays LAST so MPLS wins \
|
||||||
|
the Low tie whenever it produces anything."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use crate::udf::UdfFs;
|
|||||||
|
|
||||||
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
|
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
|
||||||
/// `.mpls` file. Cheap directory walk only — no sector reads.
|
/// `.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 {
|
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelT
|
|||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
use crate::udf::UdfFs;
|
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")
|
super::jar_file_exists(udf, "playlists.xml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const REGIONS: &[&str] = &[
|
|||||||
"US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE",
|
"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")
|
super::jar_file_exists(udf, "bluray_project.bin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,49 @@ const BARE_LANGS: &[(&str, &str)] = &[
|
|||||||
("galician", "glg"),
|
("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 ──────────────────────────────────────────────────────────────────
|
// ── Purpose ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Classify a free-form English label string into a [`LabelPurpose`].
|
/// Classify a free-form English label string into a [`LabelPurpose`].
|
||||||
|
|||||||
Reference in New Issue
Block a user