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
+40 -5
View File
@@ -26,6 +26,8 @@ use crate::drive::DriveSession;
pub struct UdfFs {
/// Root directory with full tree
pub root: DirEntry,
/// UDF Volume Identifier from Primary Volume Descriptor
pub volume_id: String,
/// Physical partition start (absolute sector)
partition_start: u32,
/// Metadata partition start (absolute sector)
@@ -143,11 +145,10 @@ impl UdfFs {
} else {
let name_upper = child.name.to_uppercase();
// Skip large AACS files we don't need for disc-info
if name_upper.starts_with("MKB_")
|| name_upper.starts_with("CONTENTHASH")
|| name_upper.starts_with("CONTENTREVOCATION")
{
// Skip large files we don't need for disc-info:
// MKB_RO.inf (134MB), ContentHash*.tbl (1MB), ContentRevocation (1MB)
// Everything disc-info reads is under 3MB.
if child.size > 10_000_000 {
continue;
}
@@ -244,6 +245,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let mut partition_start: u32 = 0;
let mut num_partition_maps: u32 = 0;
let mut lvd_sector: Option<u32> = None;
let mut volume_id = String::new();
for i in 32..64 {
let mut desc = [0u8; 2048];
@@ -251,6 +253,10 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
match desc_tag {
// Primary Volume Descriptor — volume identifier at offset 24, 32-byte d-string
1 => {
volume_id = parse_dstring(&desc[24..56]);
}
// Partition Descriptor — tells us where the physical partition starts
5 => {
partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]);
@@ -346,6 +352,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
Ok(UdfFs {
root,
volume_id,
partition_start,
metadata_start,
})
@@ -537,6 +544,34 @@ fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
result
}
/// Parse a UDF d-string (fixed-length field with length byte at the end).
/// Used for Volume Identifier and other UDF descriptor strings.
/// The first byte of content is a compression ID: 8 = ASCII, 16 = UTF-16BE.
fn parse_dstring(data: &[u8]) -> String {
if data.is_empty() { return String::new(); }
let len = *data.last().unwrap() as usize;
if len == 0 || len > data.len() { return String::new(); }
let content = &data[..len];
if content.is_empty() { return String::new(); }
match content[0] {
8 => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
16 => {
let mut s = String::new();
let chars = &content[1..];
for i in (0..chars.len()).step_by(2) {
if i + 1 < chars.len() {
let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
if c != 0 {
if let Some(ch) = char::from_u32(c as u32) { s.push(ch); }
}
}
}
s.trim().to_string()
}
_ => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
}
}
/// Read a single 2048-byte sector from the drive.
/// Uses standard READ(10) — no unlock required.
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {