Merge branch 'fix/label-pid-binding' into dev

This commit is contained in:
Matthew Jackson
2026-08-02 18:56:10 -07:00
11 changed files with 1053 additions and 307 deletions
+1
View File
@@ -47,6 +47,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
let mut labels = Vec::new();
for (info, &stream_num) in stream_infos.iter().zip(stream_nums.iter()) {
labels.push(StreamLabel {
stream_id: None,
stream_number: stream_num,
stream_type: info.stream_type,
language: info.language.clone(),
+3
View File
@@ -207,6 +207,7 @@ fn parse_language_streams_text(text: &str) -> Vec<StreamLabel> {
}
labels.push(StreamLabel {
stream_id: None,
stream_number: stream_num,
stream_type,
language,
@@ -401,6 +402,7 @@ mod tests {
fn lbl(t: StreamLabelType, n: u16, name: &str) -> StreamLabel {
StreamLabel {
stream_id: None,
stream_number: n,
stream_type: t,
language: String::new(),
@@ -910,6 +912,7 @@ fn parse_menu_base_text(text: &str) -> Vec<StreamLabel> {
.unwrap_or_default();
labels.push(StreamLabel {
stream_id: None,
stream_number: stream_num,
stream_type,
language,
+1
View File
@@ -171,6 +171,7 @@ fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLa
let qualifier = vocab::qualifier(&label);
let purpose = vocab::purpose(&label);
StreamLabel {
stream_id: None,
stream_number: num,
stream_type,
language,
+1
View File
@@ -1066,6 +1066,7 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -
}
out.push(StreamLabel {
stream_id: None,
stream_number,
stream_type,
language,
+803 -253
View File
File diff suppressed because it is too large Load Diff
+122 -54
View File
@@ -86,44 +86,50 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
Some(ParseResult::low(labels))
}
/// Convert every stream entry across `playlists` into deduped
/// [`StreamLabel`]s. Factored out of [`parse`] so unit tests can drive
/// the actual conversion logic (stream-type mapping, dedup key, dense
/// global counters) directly from already-parsed [`crate::mpls::Playlist`]
/// values, without needing a synthetic on-disc UDF image.
/// Convert every stream entry across `playlists` into one [`StreamLabel`] per
/// physical stream. Factored out of [`parse`] so unit tests can drive the
/// actual conversion logic (stream-type mapping, identity, slot numbering)
/// directly from already-parsed [`crate::mpls::Playlist`] values, without
/// needing a synthetic on-disc UDF image.
///
/// Identity is `(clip, PID)` — what the STN entry states — and it is both the
/// dedup key and the label's [`StreamId`]. A stream twenty playlists list is
/// one label; two clips that both open their first audio at 0x1100 are two.
/// This replaced a disc-global dense counter that numbered surviving entries
/// 1, 2, 3, … in playlist-directory order: that number was not an STN slot in
/// anything, but it was handed to a binder that reads `stream_number` as one.
fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec<StreamLabel> {
use std::collections::HashSet;
let mut labels: Vec<StreamLabel> = Vec::new();
// (stream_type_tag, language, codec_hint, pid) — PID is the
// canonical "same physical stream" key; type+lang+codec round
// out the rare case where two distinct logical streams happen
// to share a PID across playlists with different metadata.
let mut seen: Vec<(StreamLabelType, String, String, u16)> = Vec::new();
// Global 1-based counters keyed by StreamLabelType. Incremented
// only when an entry survives dedup, so stream_numbers are dense
// (1, 2, 3, ...) per type across the whole disc — not reset per
// playlist. A disc with 2 MPLS files that each list the same
// 8 audio streams ends up with audio_1..audio_8, not audio_1..
// audio_16 or audio_1..audio_8 with audio_1 duplicated.
let mut audio_idx: u16 = 0;
let mut sub_idx: u16 = 0;
let mut seen: HashSet<super::StreamId> = HashSet::new();
for playlist in playlists {
// `Playlist::streams` is the FIRST play item's STN table, so every
// entry here is a stream of that play item's clip — the same clip
// `disc::bluray` records as the title's `clips[0]`. That pairing is
// what makes the PID an identity rather than a 16-bit number.
//
// Streams cannot be non-empty without a play item to have read them
// from, so the empty case is unreachable on a real disc; entries we
// cannot identify are skipped rather than emitted as unbindable
// labels.
let Some(clip_id) = playlist.play_items.first().map(|pi| pi.clip_id.clone()) else {
continue;
};
// 1-based STN slot within THIS playlist's table, per type — the
// `stream_number` field's documented meaning, counted the same way
// `disc::bluray` counts the stream list it builds from these entries.
// Nothing binds through it (these labels bind by id); it is stated
// truthfully rather than invented so that a reader of the label list
// sees where on its own playlist each stream sits.
let mut audio_idx: u16 = 0;
let mut sub_idx: u16 = 0;
for entry in &playlist.streams {
let Some(label_type) = label_type_for(entry) else {
continue;
};
let language = normalize_language(&entry.language);
let name = language_display_name(&language);
let codec_hint = build_codec_hint(label_type, entry);
let key = (label_type, language.clone(), codec_hint.clone(), entry.pid);
if seen.contains(&key) {
continue;
}
seen.push(key);
let stream_number = match label_type {
StreamLabelType::Audio => {
audio_idx += 1;
@@ -135,7 +141,20 @@ fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec<StreamLabel> {
}
};
let stream_id = super::StreamId {
clip_id: clip_id.clone(),
pid: entry.pid,
};
if !seen.insert(stream_id.clone()) {
continue;
}
let language = normalize_language(&entry.language);
let name = language_display_name(&language);
let codec_hint = build_codec_hint(label_type, entry);
labels.push(StreamLabel {
stream_id: Some(stream_id),
stream_number,
stream_type: label_type,
language,
@@ -365,10 +384,24 @@ mod tests {
}
}
/// A playlist over clip "00001". `Playlist::streams` is read out of the
/// first play item's STN table, so a playlist that has streams always has
/// a play item to have read them from — the fixture carries one so tests
/// exercise the shape production sees, and so each label gets the
/// `(clip, PID)` identity it is bound by.
fn playlist_with(streams: Vec<StreamEntry>) -> Playlist {
playlist_on("00001", streams)
}
fn playlist_on(clip_id: &str, streams: Vec<StreamEntry>) -> Playlist {
Playlist {
version: "0200".to_string(),
play_items: Vec::new(),
play_items: vec![crate::mpls::PlayItem {
clip_id: clip_id.to_string(),
in_time: 0,
out_time: 0,
connection_condition: 1,
}],
streams,
marks: Vec::new(),
}
@@ -503,31 +536,48 @@ mod tests {
);
}
/// Two playlists over the SAME clip that both list PID 0x1100: one
/// physical stream, so one label. Identity is `(clip, PID)`, and each
/// label states the STN slot it holds in its own playlist.
#[test]
fn dedup_streams_across_playlists() {
// Two playlists, same English TrueHD 7.1 PID 0x1100 in both.
// Expect one Audio label, not two.
let pl1 = playlist_with(vec![
audio_entry(0x1100, 0x83, 12, 1, "eng"),
audio_entry(0x1101, 0x81, 6, 1, "fra"),
]);
let pl2 = playlist_with(vec![
audio_entry(0x1100, 0x83, 12, 1, "eng"), // duplicate
audio_entry(0x1102, 0x82, 6, 1, "deu"), // new
]);
fn one_label_per_stream_across_playlists_on_the_same_clip() {
let pl1 = playlist_on(
"00001",
vec![
audio_entry(0x1100, 0x83, 12, 1, "eng"),
audio_entry(0x1101, 0x81, 6, 1, "fra"),
],
);
let pl2 = playlist_on(
"00001",
vec![
audio_entry(0x1100, 0x83, 12, 1, "eng"), // same stream
audio_entry(0x1102, 0x82, 6, 1, "deu"), // new
],
);
let labels = labels_from_playlists(&[pl1, pl2]);
// Expected: eng@0x1100, fra@0x1101, deu@0x1102 — three uniques.
assert_eq!(labels.len(), 3);
// PID isn't stored on StreamLabel, so assert on the surviving
// language set instead.
let mut langs: Vec<String> = labels.iter().map(|l| l.language.clone()).collect();
langs.sort();
assert_eq!(langs, vec!["deu", "eng", "fra"]);
assert_eq!(
labels.len(),
3,
"eng/fra/deu — the duplicate eng is one stream"
);
// Stream numbers must be DENSE and GLOBAL across playlists, not
// reset per playlist. eng (pl1) = 1, fra (pl1) = 2, the duplicate
// eng in pl2 is deduped (no number consumed), and deu (pl2) = 3.
// Regression guard for the per-playlist counter-reset divergence.
let id = |lang: &str| {
labels
.iter()
.find(|l| l.language == lang)
.and_then(|l| l.stream_id.clone())
.map(|i| (i.clip_id, i.pid))
};
assert_eq!(id("eng"), Some(("00001".into(), 0x1100)));
assert_eq!(id("fra"), Some(("00001".into(), 0x1101)));
assert_eq!(id("deu"), Some(("00001".into(), 0x1102)));
// `stream_number` is the entry's slot in ITS OWN playlist's STN table
// — deu is pl2's second audio, so 2, not "the third distinct stream
// seen while scanning the disc". It used to be the latter: a dense
// disc-global counter that named no table anyone could count against,
// handed to a binder that reads the field as an STN slot.
let num = |lang: &str| {
labels
.iter()
@@ -536,7 +586,25 @@ mod tests {
};
assert_eq!(num("eng"), Some(1));
assert_eq!(num("fra"), Some(2));
assert_eq!(num("deu"), Some(3));
assert_eq!(num("deu"), Some(2), "pl2's second audio slot");
}
/// The same PID in two DIFFERENT clips is two different streams — a PID is
/// only unique within one clip. Deduping on the PID alone (as the old
/// key's `(type, language, codec_hint, pid)` did across clips) collapses
/// them into one label, and the second clip's stream is then described by
/// the first clip's.
#[test]
fn same_pid_in_two_clips_is_two_streams() {
let pl1 = playlist_on("00001", vec![audio_entry(0x1100, 0x83, 12, 1, "eng")]);
let pl2 = playlist_on("00002", vec![audio_entry(0x1100, 0x83, 12, 1, "eng")]);
let labels = labels_from_playlists(&[pl1, pl2]);
assert_eq!(labels.len(), 2, "different clips: two distinct streams");
let clips: Vec<String> = labels
.iter()
.filter_map(|l| l.stream_id.as_ref().map(|i| i.clip_id.clone()))
.collect();
assert_eq!(clips, vec!["00001", "00002"]);
}
#[test]
+2
View File
@@ -157,6 +157,7 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
LabelPurpose::Normal
};
labels.push(StreamLabel {
stream_id: None,
stream_number,
stream_type: StreamLabelType::Audio,
language: lang.to_string(),
@@ -210,6 +211,7 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
};
labels.push(StreamLabel {
stream_id: None,
stream_number,
stream_type: StreamLabelType::Subtitle,
language: lang.to_string(),
+3
View File
@@ -272,6 +272,7 @@ fn assign_labels(strings: &[String], unknown: &mut UnknownParts) -> Vec<StreamLa
}
audio_num += 1;
labels.push(StreamLabel {
stream_id: None,
stream_number: audio_num,
..label
});
@@ -282,6 +283,7 @@ fn assign_labels(strings: &[String], unknown: &mut UnknownParts) -> Vec<StreamLa
}
sub_num += 1;
labels.push(StreamLabel {
stream_id: None,
stream_number: sub_num,
..label
});
@@ -464,6 +466,7 @@ fn parse_token_inner(s: &str, mut unknown: Option<&mut UnknownParts>) -> Option<
};
Some(StreamLabel {
stream_id: None,
stream_number: 0,
stream_type,
language: lang.to_string(),
+1
View File
@@ -50,6 +50,7 @@ fn labels_from_filenames(names: &[String]) -> Vec<StreamLabel> {
seen.into_iter()
.enumerate()
.map(|(i, code)| StreamLabel {
stream_id: None,
stream_number: (i as u16).saturating_add(1),
stream_type: StreamLabelType::Audio,
language: code.to_string(),