Add disc title, format, streams to Disc::scan() — move logic from CLI to lib

- Disc.volume_id: UDF Volume ID from PVD (always present)
- Disc.meta_title: from META/DL/bdmt_eng.xml (falls back to other languages)
- Disc.format: UHD/BluRay/DVD detected from video codec
- Disc.capacity_bytes, Disc.layers
- Disc.jar_labels: extracted from BDMV/JAR
- Fixed MPLS STN parsing: 16-byte header (was 8), proper stream entry offsets
- Streams now include: HDR, color space, Dolby Vision EL, secondary audio/video
- parse_dstring() for UDF d-string fields
- wait_ready() polls TEST UNIT READY before unlock

Tested on 12 disc captures — all return correct titles, streams, format.
This commit is contained in:
MattJackson
2026-04-07 15:36:05 -07:00
parent 100e3b8126
commit dec258532f
5 changed files with 343 additions and 162 deletions
+139 -148
View File
@@ -35,22 +35,28 @@ pub struct PlayItem {
/// A stream entry from the STN table.
#[derive(Debug, Clone)]
pub struct StreamEntry {
/// Stream category: 1=primary video, 2=primary audio, 3=PG subtitle, 4=IG
/// Stream category: 1=video, 2=audio, 3=PG subtitle, 4=IG, 5=secondary audio, 6=secondary video, 7=DV EL
pub stream_type: u8,
/// MPEG-TS PID
pub pid: u16,
/// Coding type (0x24=HEVC, 0x1B=H264, 0x83=TrueHD, etc.)
pub coding_type: u8,
/// Video format (1=480i, 6=1080p, 8=2160p, etc.)
/// Video format (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p)
pub video_format: u8,
/// Video frame rate (1=23.976, 3=25, 4=29.97, etc.)
/// Video frame rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94)
pub video_rate: u8,
/// Audio format (3=stereo, 6=5.1, 12=7.1, etc.)
/// Audio channel layout (1=mono, 3=stereo, 6=5.1, 12=7.1)
pub audio_format: u8,
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz)
pub audio_rate: u8,
/// ISO 639-2 language code (e.g. "eng")
pub language: String,
/// HDR dynamic range (0=SDR, 1=HDR10, 2=Dolby Vision)
pub dynamic_range: u8,
/// Color space (0=unknown, 1=BT.709, 2=BT.2020)
pub color_space: u8,
/// Whether this is a secondary stream (commentary, PiP, DV EL)
pub secondary: bool,
}
/// Parse an MPLS file from raw bytes.
@@ -58,40 +64,31 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
if data.len() < 40 {
return Err(Error::DiscError { detail: "MPLS too short".into() });
}
// Header: "MPLS" + version (4 bytes ASCII)
if &data[0..4] != b"MPLS" {
return Err(Error::DiscError { detail: "not an MPLS file".into() });
}
let version = String::from_utf8_lossy(&data[4..8]).to_string();
// Offsets table at bytes 8-19
let version = String::from_utf8_lossy(&data[4..8]).to_string();
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
if playlist_start + 10 > data.len() {
return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() });
}
// PlayList section
let pl = &data[playlist_start..];
let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize;
let mut play_items = Vec::with_capacity(num_play_items);
let mut streams = Vec::new();
let mut pos = 10; // start of first play item
let mut pos = 10;
for item_idx in 0..num_play_items {
if playlist_start + pos + 2 > data.len() {
break;
}
if pos + 2 > pl.len() { break; }
let item_length = u16::from_be_bytes([pl[pos], pl[pos + 1]]) as usize;
if playlist_start + pos + item_length + 2 > data.len() {
break;
}
if pos + 2 + item_length > pl.len() { break; }
let item = &pl[pos + 2..pos + 2 + item_length];
if item.len() < 20 { break; }
if item.len() < 20 { pos += 2 + item_length; continue; }
let clip_id = String::from_utf8_lossy(&item[0..5]).to_string();
let connection_condition = item[9] & 0x0F;
@@ -99,83 +96,91 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
// Parse STN table from the first play item
if item_idx == 0 && item.len() > 32 {
// UO mask is 8 bytes (64 bits) at offset 20
// STN table starts after: 2 bytes length + 2 bytes reserved + UO mask (8 bytes)
// STN offset within play_item = 20 (UO start)
// Actually: offset 20 = UO_mask_table (8 bytes)
// offset 28 = random_access_flag + ... (2 bytes)
// offset 30 = still_mode (1 byte) + still_time (2 bytes if still)
// Then STN table
// The STN table position varies. Let's find it by looking for the STN length field.
// PlayItem layout after out_time:
// [20:28] UO_mask_table (8 bytes)
// [28] misc flags (1 byte)
// [29] still_mode (1 byte)
// [30:32] still_time (2 bytes)
// [32:] STN_table
const STN_OFFSET: usize = 32;
if item_idx == 0 && item.len() > STN_OFFSET + 16 {
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
let n_video = item[STN_OFFSET + 4] as usize;
let n_audio = item[STN_OFFSET + 5] as usize;
let n_pg = item[STN_OFFSET + 6] as usize;
let n_ig = item[STN_OFFSET + 7] as usize;
let n_sec_audio = item[STN_OFFSET + 8] as usize;
let n_sec_video = item[STN_OFFSET + 9] as usize;
let _n_pip_pg = item[STN_OFFSET + 10] as usize;
let n_dv = item[STN_OFFSET + 11] as usize;
// Per BD spec: play_item structure after out_time:
// [20..28] UO_mask_table (8 bytes)
// [28] misc flags (1 byte)
// [29] still_mode (1 byte)
// [30..32] still_time (2 bytes) if still_mode != 0
// Then STN_table
let mut spos = STN_OFFSET + 16;
let still_mode = if item.len() > 29 { item[29] } else { 0 };
let stn_offset = if still_mode != 0 { 32 } else { 32 };
// Actually it's always 32 based on our previous work (UO mask = 8 bytes, not 64)
if item.len() > stn_offset + 6 {
let stn = &item[stn_offset..];
let _stn_length = u16::from_be_bytes([stn[0], stn[1]]) as usize;
if stn.len() > 6 {
// reserved 2 bytes at [2..4]
let n_video = stn[4] as usize;
let n_audio = stn[5] as usize;
let n_pg = if stn.len() > 6 { stn[6] as usize } else { 0 };
let n_ig = if stn.len() > 7 { stn[7] as usize } else { 0 };
// Parse stream entries starting at offset 8
// But there's another 2 bytes reserved before entries
let mut stn_pos = 8;
// Possible 2 more reserved bytes
// Let's skip and parse entries
// Primary video streams
for _ in 0..n_video {
if let Some((entry, len)) = parse_stream_entry(stn, stn_pos, 1) {
streams.push(entry);
stn_pos += len;
} else {
break;
}
}
// Primary audio streams
for _ in 0..n_audio {
if let Some((entry, len)) = parse_stream_entry(stn, stn_pos, 2) {
streams.push(entry);
stn_pos += len;
} else {
break;
}
}
// PG (subtitle) streams
for _ in 0..n_pg {
if let Some((entry, len)) = parse_stream_entry(stn, stn_pos, 3) {
streams.push(entry);
stn_pos += len;
} else {
break;
}
}
// IG streams
for _ in 0..n_ig {
if let Some((entry, len)) = parse_stream_entry(stn, stn_pos, 4) {
streams.push(entry);
stn_pos += len;
} else {
break;
}
}
}
// Primary video
for _ in 0..n_video {
if let Some((entry, next)) = parse_stream_entry(item, spos, 1) {
streams.push(entry);
spos = next;
} else { break; }
}
// Primary audio
for _ in 0..n_audio {
if let Some((entry, next)) = parse_stream_entry(item, spos, 2) {
streams.push(entry);
spos = next;
} else { break; }
}
// PG subtitles
for _ in 0..n_pg {
if let Some((entry, next)) = parse_stream_entry(item, spos, 3) {
streams.push(entry);
spos = next;
} else { break; }
}
// IG (skip but advance)
for _ in 0..n_ig {
if let Some((_, next)) = parse_stream_entry(item, spos, 4) {
spos = next;
} else { break; }
}
// Secondary audio
for _ in 0..n_sec_audio {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 2) {
entry.stream_type = 5;
entry.secondary = true;
streams.push(entry);
// Skip extra ref bytes: num_refs(1) + reserved(1) + refs + padding
if next < item.len() {
let n_refs = item[next] as usize;
spos = next + 2 + n_refs + (n_refs % 2);
} else { spos = next; }
} else { break; }
}
// Secondary video (PiP)
for _ in 0..n_sec_video {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
entry.stream_type = 6;
entry.secondary = true;
streams.push(entry);
// Skip extra ref bytes (audio refs + PG refs)
if next + 2 < item.len() {
let n_arefs = item[next] as usize;
let after_arefs = next + 2 + n_arefs + (n_arefs % 2);
if after_arefs < item.len() {
let n_prefs = item[after_arefs] as usize;
spos = after_arefs + 2 + n_prefs + (n_prefs % 2);
} else { spos = after_arefs; }
} else { spos = next; }
} else { break; }
}
// Dolby Vision enhancement layer
for _ in 0..n_dv {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
entry.stream_type = 7;
entry.secondary = true;
streams.push(entry);
spos = next;
} else { break; }
}
}
@@ -197,87 +202,70 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
}
/// Parse one stream entry from the STN table.
/// Returns (StreamEntry, bytes consumed) or None.
fn parse_stream_entry(stn: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
if pos + 2 > stn.len() { return None; }
/// Returns (StreamEntry, next position) or None.
fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
if pos + 2 > item.len() { return None; }
// Stream entry format:
// [0] length of stream entry (1 byte)
// [1] stream_type (1=PlayItem, 2=SubPath, 3=InMux)
// Then stream_pid reference (varies by stream_type)
// Then stream attributes
// Stream entry: length(1) + data
let se_len = item[pos] as usize;
let se_end = pos + 1 + se_len;
if se_end > item.len() { return None; }
let entry_len = stn[pos] as usize;
if entry_len < 4 || pos + 1 + entry_len > stn.len() { return None; }
let entry = &stn[pos + 1..pos + 1 + entry_len];
// Stream entry: type(1) + ref_type-dependent PID extraction
// For type 1 (PlayItem stream): [0]=type, [1..3]=ref_to_stream_PID_of_playItem
// ref format: [0] = subpath/playitem ref type, [1..2] = PID (big-endian u16)
let pid = if entry.len() >= 3 {
u16::from_be_bytes([entry[1], entry[2]])
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
} else {
0
};
// Stream attributes follow after the PID reference
// Attributes block: length(1) + coding_type(1) + format-specific data + language(3)
let attr_start = pos + 1 + entry_len;
if attr_start + 2 > stn.len() { return None; }
// Stream attributes: length(1) + coding_type(1) + format-specific data
if se_end + 2 > item.len() { return None; }
let sa_len = item[se_end] as usize;
let sa_end = se_end + 1 + sa_len;
if sa_end > item.len() || sa_len < 1 { return None; }
let attr_len = stn[attr_start] as usize;
if attr_len < 4 || attr_start + 1 + attr_len > stn.len() {
return Some((StreamEntry {
stream_type, pid, coding_type: 0, video_format: 0, video_rate: 0,
audio_format: 0, audio_rate: 0, language: String::new(),
}, 1 + entry_len + 1 + attr_len.max(1)));
}
let attr = &stn[attr_start + 1..attr_start + 1 + attr_len];
let coding_type = attr[0];
let sa = &item[se_end + 1..se_end + 1 + sa_len];
let coding_type = sa[0];
let mut video_format = 0u8;
let mut video_rate = 0u8;
let mut audio_format = 0u8;
let mut audio_rate = 0u8;
let mut dynamic_range = 0u8;
let mut color_space_val = 0u8;
let mut language = String::new();
match stream_type {
1 => {
// Video: coding_type(1) + format_and_rate(1) + ...
if attr.len() >= 2 {
video_format = (attr[1] >> 4) & 0x0F;
video_rate = attr[1] & 0x0F;
// Video: coding_type(1) + format_rate(1) + [hdr_info(1) if HEVC]
if sa.len() >= 2 {
video_format = (sa[1] >> 4) & 0x0F;
video_rate = sa[1] & 0x0F;
}
if coding_type == 0x24 && sa.len() > 2 {
dynamic_range = (sa[2] >> 4) & 0x0F;
color_space_val = sa[2] & 0x0F;
}
}
2 => {
// Audio: coding_type(1) + format_and_rate(1) + language(3)
if attr.len() >= 2 {
audio_format = (attr[1] >> 4) & 0x0F;
audio_rate = attr[1] & 0x0F;
// Audio: coding_type(1) + format_rate(1) + language(3)
if sa.len() >= 2 {
audio_format = (sa[1] >> 4) & 0x0F;
audio_rate = sa[1] & 0x0F;
}
if attr.len() >= 5 {
language = String::from_utf8_lossy(&attr[2..5]).to_string();
if sa.len() >= 5 {
language = String::from_utf8_lossy(&sa[2..5]).to_string();
}
}
3 => {
// PG subtitle: coding_type(1) + language(3)
if attr.len() >= 4 {
language = String::from_utf8_lossy(&attr[1..4]).to_string();
}
}
4 => {
// IG: coding_type(1) + language(3)
if attr.len() >= 4 {
language = String::from_utf8_lossy(&attr[1..4]).to_string();
3 | 4 => {
// PG/IG: coding_type(1) + language(3)
if sa.len() >= 4 {
language = String::from_utf8_lossy(&sa[1..4]).to_string();
}
}
_ => {}
}
let total_consumed = 1 + entry_len + 1 + attr_len;
Some((StreamEntry {
stream_type,
pid,
@@ -287,5 +275,8 @@ fn parse_stream_entry(stn: &[u8], pos: usize, stream_type: u8) -> Option<(Stream
audio_format,
audio_rate,
language,
}, total_consumed))
dynamic_range,
color_space: color_space_val,
secondary: false,
}, sa_end))
}