labels: log caught parser panics, pin label tables and selection rules with tests

This commit is contained in:
Matthew Jackson
2026-10-01 15:02:36 -07:00
parent 087297c9d5
commit 19066aa771
4 changed files with 542 additions and 21 deletions
+373 -21
View File
@@ -302,8 +302,16 @@ pub fn apply(
titles: &mut [DiscTitle],
) -> Option<FeaturePlaylistHint> {
let (labels, winner_hint) =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf)))
.unwrap_or_default();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf))) {
Ok(r) => r,
Err(payload) => {
tracing::warn!(
panic = %panic_message(payload.as_ref()),
"label extraction panicked; streams left unlabelled"
);
Default::default()
}
};
if !labels.is_empty() {
apply_labels(&labels, titles);
}
@@ -313,11 +321,26 @@ pub fn apply(
let hint = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve_feature_hint(reader, udf, winner_hint, titles)
}))
.ok()
.flatten();
.unwrap_or_else(|payload| {
tracing::warn!(
panic = %panic_message(payload.as_ref()),
"feature-hint resolution panicked; no hint"
);
None
});
hint.filter(|h| !h.is_empty())
}
// A caught panic's message, control characters escaped (it can carry disc-derived text).
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
let msg = payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("non-string panic payload");
msg.escape_debug().to_string()
}
// The disc's feature-playlist hint: the winning parser's manifest hint when it
// carries one, else the generic BD-J menu-walk (`bdj_feature::resolve`). Kept
// separate from extraction so a hint survives an empty/absent label set.
@@ -1172,7 +1195,8 @@ pub struct LabelAnalysis {
/// MPLS itself was the chosen parser. Diagnostic for the labels-analyze
/// tool.
pub gap_fill_added: usize,
/// Per-playlist chapter summary: `(playlist_filename, chapter_count, duration_secs)`.
/// Per-playlist chapter summary rows ([`ChapterSummary`]): playlist filename,
/// chapter count, duration in seconds.
/// Sourced from MPLS PlaylistMark entries with `mark_type == 1`
/// (chapter entries). Ordered by playlist filename. Empty if no
/// MPLS files have parseable marks, or the disc isn't Blu-ray.
@@ -1465,22 +1489,6 @@ mod registry_tests {
let none: Vec<(&'static str, ParseResult)> = Vec::new();
assert!(select_result(&none).is_none());
}
/// 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 this just fails to
// compile if the tuple shape changes (e.g. a 4th field is added)
// without updating callers; it also marks "these parsers exist."
let _ = (name, detect, parse);
}
// Non-empty invariant is covered separately by
// `parsers_registry_order_locked`, whose assert_eq! on the expected
// order fails if PARSERS is empty.
}
}
// ── MPLS floor merge tests ─────────────────────────────────────────────────
@@ -3409,3 +3417,347 @@ mod feature_hint_pass_tests {
);
}
}
// ── Label text tables, ordering, and extraction ───────────────────────────
#[cfg(test)]
mod label_table_tests {
use super::*;
use crate::disc::{AudioChannels, Codec, HdrFormat, Stream, SubtitleStream};
use crate::udf::fixture::*;
fn vendor(t: StreamLabelType, n: u16) -> StreamLabel {
StreamLabel {
stream_id: None,
stream_number: n,
stream_type: t,
language: "eng".into(),
name: String::new(),
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
codec_hint: String::new(),
variant: String::new(),
}
}
fn derived(clip: &str, pid: u16, n: u16) -> StreamLabel {
StreamLabel {
stream_id: Some(StreamId {
clip_id: clip.into(),
pid,
}),
..vendor(StreamLabelType::Audio, n)
}
}
#[test]
fn panic_message_escapes_control_characters() {
let p = std::panic::catch_unwind(|| panic!("bad \x1b[31m disc")).unwrap_err();
assert_eq!(panic_message(p.as_ref()), "bad \\u{1b}[31m disc");
}
#[test]
fn iso639_b_codes_map_to_their_t_codes() {
for (b, t) in [
("alb", "sqi"),
("arm", "hye"),
("baq", "eus"),
("bur", "mya"),
("chi", "zho"),
("cze", "ces"),
("dut", "nld"),
("fre", "fra"),
("geo", "kat"),
("ger", "deu"),
("gre", "ell"),
("ice", "isl"),
("mac", "mkd"),
("mao", "mri"),
("may", "msa"),
("per", "fas"),
("rum", "ron"),
("slo", "slk"),
("tib", "bod"),
("wel", "cym"),
] {
assert_eq!(to_iso639_t(b), t, "{b}");
assert_eq!(to_iso639_t(&b.to_ascii_uppercase()), t, "{b}");
assert!(same_language(b, t), "{b}");
}
assert_eq!(to_iso639_t("eng"), "eng");
}
#[test]
fn audio_channel_layouts_have_their_own_text() {
for (ch, want) in [
(AudioChannels::Mono, "1.0"),
(AudioChannels::Stereo, "2.0"),
(AudioChannels::Stereo21, "2.1"),
(AudioChannels::Surround30, "3.0"),
(AudioChannels::Surround31, "3.1"),
(AudioChannels::Quad, "4.0"),
(AudioChannels::Surround41, "4.1"),
(AudioChannels::Surround50, "5.0"),
(AudioChannels::Surround51, "5.1"),
(AudioChannels::Surround60, "6.0"),
(AudioChannels::Surround61, "6.1"),
(AudioChannels::Surround70, "7.0"),
(AudioChannels::Surround71, "7.1"),
] {
assert_eq!(
generate_audio_label(&Codec::Ac3, &ch, false),
format!("Dolby Digital {want}")
);
}
}
#[test]
fn video_resolution_token_boundaries() {
let tok = |w, h, i| generate_video_label(&Codec::Hevc, (w, h), i, &HdrFormat::Sdr, false);
let name = Codec::Hevc.name();
for (w, h, interlaced, want) in [
(7680, 4320, false, "8K"),
(7679, 4320, false, "4K"),
(3840, 2160, false, "4K"),
(1920, 1080, false, "1080p"),
(1920, 1080, true, "1080i"),
(1280, 720, false, "720p"),
(720, 576, false, "576p"),
(720, 576, true, "576i"),
(720, 480, false, "480p"),
(720, 480, true, "480i"),
(640, 479, false, ""),
] {
let want = if want.is_empty() {
name.to_string()
} else {
format!("{name} {want}")
};
assert_eq!(tok(w, h, interlaced), want, "{w}x{h} i={interlaced}");
}
}
#[test]
fn atmos_marker_only_rides_a_dolby_carrier() {
let l = |c| generate_audio_label_atmos(&c, &AudioChannels::Surround71, false);
assert_eq!(l(Codec::DtsHdMa), "DTS-HD Master Audio 7.1");
assert_eq!(l(Codec::Dts), "DTS 7.1");
assert_eq!(l(Codec::Ac3), "Dolby Digital 7.1");
}
#[test]
fn atmos_hint_is_consistent_only_with_a_dolby_carrier() {
assert!(codec_hint_consistent("Atmos", &Codec::TrueHd));
assert!(codec_hint_consistent("Atmos", &Codec::Ac3Plus));
assert!(!codec_hint_consistent("Atmos", &Codec::DtsHdMa));
assert!(!codec_hint_consistent("Atmos", &Codec::Dts));
assert!(codec_hint_consistent("High Resolution", &Codec::DtsHdHr));
assert!(codec_hint_consistent("DTS-HD HR", &Codec::DtsHdHr));
assert!(!codec_hint_consistent("Master Audio", &Codec::DtsHdHr));
assert!(!codec_hint_consistent("High Resolution", &Codec::DtsHdMa));
}
#[test]
fn merged_labels_order_by_slot_then_stream() {
let mut labels = vec![
vendor(StreamLabelType::Audio, 3),
vendor(StreamLabelType::Audio, 1),
derived("00002", 0x1100, 1),
derived("00001", 0x1101, 1),
derived("00001", 0x1100, 1),
derived("00001", 0x1102, 0),
];
sort_labels(&mut labels);
let order: Vec<(Option<(&str, u16)>, u16)> = labels
.iter()
.map(|l| {
(
l.stream_id.as_ref().map(|i| (i.clip_id.as_str(), i.pid)),
l.stream_number,
)
})
.collect();
assert_eq!(
order,
[
(None, 1),
(None, 3),
(Some(("00001", 0x1102)), 0),
(Some(("00001", 0x1100)), 1),
(Some(("00001", 0x1101)), 1),
(Some(("00002", 0x1100)), 1),
]
);
}
// Off the authoritative path, a label binds by bare ordinal only when the stream
// STATES the same language; an unknown stream language is not agreement.
#[test]
fn ordinal_binding_needs_a_stated_subtitle_language() {
let mut title = DiscTitle {
playlist: "00800.mpls".into(),
playlist_id: 800,
duration_secs: 7200.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![Stream::Subtitle(SubtitleStream {
pid: 0x12A0,
codec: Codec::Pgs,
language: String::new(),
forced: false,
qualifier: LabelQualifier::None,
codec_data: None,
})],
chapters: Vec::new(),
extents: Vec::new(),
content_format: crate::disc::ContentFormat::BdTs,
codec_privates: Vec::new(),
};
let labels = vec![StreamLabel {
qualifier: LabelQualifier::Forced,
..vendor(StreamLabelType::Subtitle, 1)
}];
apply_labels(&labels, std::slice::from_mut(&mut title));
let Stream::Subtitle(s) = &title.streams[0] else {
unreachable!()
};
assert!(!s.forced);
assert_eq!(s.qualifier, LabelQualifier::None);
}
// Minimal MPLS: one play item on `clip` whose STN lists one primary audio stream `pid`.
fn mpls_bytes(clip: &[u8; 5], pid: u16) -> Vec<u8> {
let mut item = Vec::new();
item.extend_from_slice(clip);
item.extend_from_slice(b"M2TS");
item.extend_from_slice(&[0u8; 3]);
item.extend_from_slice(&0u32.to_be_bytes());
item.extend_from_slice(&(7000u32 * 45000).to_be_bytes());
item.extend_from_slice(&[0u8; 12]); // UO mask, misc, still
item.extend_from_slice(&[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
item.extend_from_slice(&[3, 0x01]);
item.extend_from_slice(&pid.to_be_bytes());
item.extend_from_slice(&[5, 0x81, 0x61]);
item.extend_from_slice(b"eng");
let mut pl = vec![0u8; 6];
pl.extend_from_slice(&1u16.to_be_bytes());
pl.extend_from_slice(&[0u8; 2]);
pl.extend_from_slice(&(item.len() as u16).to_be_bytes());
pl.extend_from_slice(&item);
let pl_len = (pl.len() - 4) as u32;
pl[0..4].copy_from_slice(&pl_len.to_be_bytes());
let mut buf = b"MPLS0200".to_vec();
buf.extend_from_slice(&40u32.to_be_bytes());
buf.extend_from_slice(&[0u8; 28]);
buf.extend_from_slice(&pl);
buf
}
fn dir(name: &str, icb: u32, files: Vec<FileSpec>, subdirs: Vec<DirSpec>) -> DirSpec {
DirSpec {
name: name.to_string(),
icb_lba: icb,
dir_data_lba: icb + 1,
files,
subdirs,
}
}
fn open(root: DirSpec) -> (MemDisc, UdfFs) {
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
(disc, udf)
}
// A framework winner keeps its own labels and hint, and the MPLS floor adds the
// streams it named no label for.
#[test]
fn extract_merges_the_mpls_floor_into_a_framework_winner() {
let dcx = br#"<dcx><disc>
<playlist id="00800" name="feature" durs="7000"><audio id="01" lang="eng"/></playlist>
</disc></dcx>"#;
let (mut disc, udf) = open(dir(
"",
10,
vec![],
vec![dir(
"BDMV",
20,
vec![],
vec![
dir(
"JAR",
30,
vec![],
vec![dir(
"00000",
40,
vec![file_with("dcx.xml", 100, 2000, dcx.to_vec(), true)],
vec![],
)],
),
dir(
"PLAYLIST",
50,
vec![file_with(
"00800.mpls",
101,
3000,
mpls_bytes(b"00001", 0x1100),
true,
)],
vec![],
),
],
)],
));
let (labels, hint) = extract(&mut disc, &udf);
assert_eq!(labels.iter().filter(|l| l.stream_id.is_none()).count(), 1);
assert_eq!(labels.iter().filter(|l| l.stream_id.is_some()).count(), 1);
assert_eq!(hint.and_then(|h| h.playlist_id), Some(800));
}
// The first copy of a manifest that reads non-empty wins; names match without case.
#[test]
fn read_jar_file_skips_empty_copies_and_matches_names_without_case() {
let (mut disc, udf) = open(dir(
"",
10,
vec![],
vec![dir(
"BDMV",
20,
vec![],
vec![dir(
"JAR",
30,
vec![],
vec![
dir(
"00000",
40,
vec![file_with("playlists.xml", 100, 2000, Vec::new(), true)],
vec![],
),
dir(
"00001",
50,
vec![file_with(
"PLAYLISTS.XML",
101,
3000,
b"<p/>".to_vec(),
true,
)],
vec![],
),
],
)],
)],
));
let got = read_jar_file(&mut disc, &udf, "playlists.xml");
assert_eq!(got.as_deref(), Some(&b"<p/>"[..]));
}
}
+54
View File
@@ -342,6 +342,60 @@ fn pick<'a>(cands: &[Candidate<'a>], key: Key, require_audio: bool) -> Option<&'
mod tests {
use super::*;
fn hint_id(doc: &str) -> Option<u16> {
feature_hint(doc).and_then(|h| h.playlist_id)
}
// Tier 1 ranks exact `Feature` names by duration, not document order.
#[test]
fn exact_feature_tier_prefers_the_longest_duration() {
let doc = r#"
<playlist name="Feature" id="00001" duration="600" aud="eng"/>
<playlist name="Feature" id="00002" duration="7000" aud="eng"/>
"#;
assert_eq!(hint_id(doc), Some(2));
}
// A zero-audio exact `Feature` still wins over an audio-bearing extra: its id feeds the hint.
#[test]
fn zero_audio_exact_feature_is_still_selected() {
let doc = r#"
<playlist name="Feature" id="00800"/>
<playlist name="Bonus" id="00900" aud="eng,fra"/>
"#;
assert_eq!(hint_id(doc), Some(800));
}
// With equal audio counts, tier 3 breaks the tie by the longer duration.
#[test]
fn most_audio_tier_breaks_a_tie_by_longer_duration() {
let doc = r#"
<playlist name="Menu" id="00001" duration="600" aud="eng,fra"/>
<playlist name="Other" id="00002" duration="7000" aud="eng,fra"/>
"#;
assert_eq!(hint_id(doc), Some(2));
}
// Exactly the minimum feature length is a feature; one second less is a decoy.
#[test]
fn minimum_feature_length_is_inclusive() {
let doc = |secs: u64| format!(r#"<playlist name="Feature" id="00800" duration="{secs}"/>"#);
assert_eq!(hint_id(&doc(60)), Some(800));
assert_eq!(hint_id(&doc(59)), None);
}
// Every duration attribute spelling arms the sub-minute decoy guard.
#[test]
fn every_duration_attribute_spelling_is_honoured() {
for key in ["duration", "durs", "dur", "runtime", "length", "len"] {
let doc = format!(
r#"<playlist name="Feature" id="00001" {key}="10"/>
<playlist name="Feature" id="00800" {key}="7000"/>"#
);
assert_eq!(hint_id(&doc), Some(800), "{key}");
}
}
// Immunity pin, section-boundary half.
#[test]
fn a_playlists_stream_list_cannot_run_into_the_next_playlist() {
+49
View File
@@ -1186,4 +1186,53 @@ mod tests {
assert!(!is_stream_token("Video Stream 1"));
assert!(!is_stream_token("entry-markEa9"));
}
// A notice-clip name (`deu_Warning`) has the token shape: it is recorded as an
// uncatalogued component, yields no label, and still occupies its stream slot.
#[test]
fn notice_clip_token_is_recorded_unlabelled_and_takes_a_slot() {
let mut acc = UnknownParts::default();
assert!(parse_token_inner("deu_Warning", Some(&mut acc)).is_none());
assert_eq!(acc.seen.iter().collect::<Vec<_>>(), ["WARNING"]);
let mut acc = UnknownParts::default();
let labels = assign_labels(
&strs(&["FPL_Main", "Audio Stream 1", "deu_Warning", "eng_MLP_"]),
&mut acc,
);
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].stream_number, 3);
assert_eq!(acc.total, 1);
}
// Lay `bluray_project.bin` into /BDMV/JAR/00000 and run the real `parse`.
fn parse_blob(blob: &[u8]) -> Option<ParseResult> {
use crate::udf::fixture::*;
let dir = |name: &str, icb, data, files, subdirs| DirSpec {
name: name.to_string(),
icb_lba: icb,
dir_data_lba: data,
files,
subdirs,
};
let file = file_with("bluray_project.bin", 100, 2000, blob.to_vec(), true);
let sub = dir("00000", 54, 55, vec![file], vec![]);
let jar = dir("JAR", 52, 53, vec![], vec![sub]);
let bdmv = dir("BDMV", 12, 13, vec![], vec![jar]);
let root = dir("", 10, 11, vec![], vec![bdmv]);
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
parse(&mut disc, &udf)
}
// Confidence is High only when every token component was catalogued.
#[test]
fn parse_confidence_reflects_uncatalogued_components() {
let known = parse_blob(b"FPL_Main\0Audio Stream 1\0eng_MLP_\0").expect("labels");
assert_eq!(known.confidence, Confidence::High);
let unknown = parse_blob(b"FPL_Main\0Audio Stream 1\0eng_MLP_NEWFLAG_\0").expect("labels");
assert_eq!(unknown.confidence, Confidence::Medium);
}
}
+66
View File
@@ -495,6 +495,72 @@ fn has_word(haystack: &str, needle: &str) -> bool {
mod tests {
use super::*;
// ISO 639-1 -> 639-2/T, checked against an independently written list so a mistyped or
// swapped row cannot pass on the table's own shape.
#[test]
fn iso639_1_values_match_the_standard() {
let want = [
"aa:aar", "ab:abk", "ae:ave", "af:afr", "ak:aka", "am:amh", "an:arg", "ar:ara",
"as:asm", "av:ava", "ay:aym", "az:aze", "ba:bak", "be:bel", "bg:bul", "bh:bih",
"bi:bis", "bm:bam", "bn:ben", "bo:bod", "br:bre", "bs:bos", "ca:cat", "ce:che",
"ch:cha", "co:cos", "cr:cre", "cs:ces", "cu:chu", "cv:chv", "cy:cym", "da:dan",
"de:deu", "dv:div", "dz:dzo", "ee:ewe", "el:ell", "en:eng", "eo:epo", "es:spa",
"et:est", "eu:eus", "fa:fas", "ff:ful", "fi:fin", "fj:fij", "fo:fao", "fr:fra",
"fy:fry", "ga:gle", "gd:gla", "gl:glg", "gn:grn", "gu:guj", "gv:glv", "ha:hau",
"he:heb", "hi:hin", "ho:hmo", "hr:hrv", "ht:hat", "hu:hun", "hy:hye", "hz:her",
"ia:ina", "id:ind", "ie:ile", "ig:ibo", "ii:iii", "ik:ipk", "io:ido", "is:isl",
"it:ita", "iu:iku", "ja:jpn", "jv:jav", "ka:kat", "kg:kon", "ki:kik", "kj:kua",
"kk:kaz", "kl:kal", "km:khm", "kn:kan", "ko:kor", "kr:kau", "ks:kas", "ku:kur",
"kv:kom", "kw:cor", "ky:kir", "la:lat", "lb:ltz", "lg:lug", "li:lim", "ln:lin",
"lo:lao", "lt:lit", "lu:lub", "lv:lav", "mg:mlg", "mh:mah", "mi:mri", "mk:mkd",
"ml:mal", "mn:mon", "mr:mar", "ms:msa", "mt:mlt", "my:mya", "na:nau", "nb:nob",
"nd:nde", "ne:nep", "ng:ndo", "nl:nld", "nn:nno", "no:nor", "nr:nbl", "nv:nav",
"ny:nya", "oc:oci", "oj:oji", "om:orm", "or:ori", "os:oss", "pa:pan", "pi:pli",
"pl:pol", "ps:pus", "pt:por", "qu:que", "rm:roh", "rn:run", "ro:ron", "ru:rus",
"rw:kin", "sa:san", "sc:srd", "sd:snd", "se:sme", "sg:sag", "si:sin", "sk:slk",
"sl:slv", "sm:smo", "sn:sna", "so:som", "sq:sqi", "sr:srp", "ss:ssw", "st:sot",
"su:sun", "sv:swe", "sw:swa", "ta:tam", "te:tel", "tg:tgk", "th:tha", "ti:tir",
"tk:tuk", "tl:tgl", "tn:tsn", "to:ton", "tr:tur", "ts:tso", "tt:tat", "tw:twi",
"ty:tah", "ug:uig", "uk:ukr", "ur:urd", "uz:uzb", "ve:ven", "vi:vie", "vo:vol",
"wa:wln", "wo:wol", "xh:xho", "yi:yid", "yo:yor", "za:zha", "zh:zho", "zu:zul",
];
assert_eq!(want.len(), 184);
for pair in want {
let (two, three) = pair.split_once(':').unwrap();
assert_eq!(iso639_1_to_iso639_2(two), Some(three), "{two}");
}
}
#[test]
fn iso639_1_withdrawn_dvd_codes_resolve_to_their_replacements() {
for (old, three) in [
("iw", "heb"),
("in", "ind"),
("ji", "yid"),
("jw", "jav"),
("mo", "ron"),
] {
assert_eq!(iso639_1_to_iso639_2(old), Some(three), "{old}");
}
}
#[test]
fn mandarin_chinese_compound_keeps_its_variant() {
assert_eq!(
lang("Mandarin Chinese"),
Some(LangInfo {
code: "zho",
variant: "Mandarin"
})
);
}
#[test]
fn description_word_marks_descriptive_purpose() {
assert_eq!(purpose("English Description"), LabelPurpose::Descriptive);
assert_eq!(purpose("Descriptions"), LabelPurpose::Normal);
}
#[test]
fn iso639_1_accepts_jw_and_mo_dvd_aliases() {
assert_eq!(iso639_1_to_iso639_2("jw"), Some("jav"));