From 7f7a66e039ca3a5b5f572a904d3311915f95846d Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:42:33 -0700 Subject: [PATCH] Add labels module: 4 disc file parsers for stream labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/labels/ with 4 parsers tried in order: 1. language_streams.txt (Warner CTRM CSV) 2. menu_base.prop (Warner CTRM properties) 3. streamproperties.xml + playbackconfig.xml (Criterion XML) 4. bluray_project.bin (Pixelogic binary tokens) Disc::scan() calls labels::extract() → apply_disc_labels(). If no disc files found, streams keep MPLS data as-is. No JAR fallback — disc files or nothing. Covers 4/8 discs with JARs (Dunkirk UHD, V for Vendetta BD, Being There, Barbie). Remaining 3 (Civil War, Dune, V for Vendetta UHD) have no disc config files. --- src/disc.rs | 65 ++++++++++- src/labels/bluray_project.rs | 161 ++++++++++++++++++++++++++++ src/labels/language_streams.rs | 88 +++++++++++++++ src/labels/menu_base.rs | 129 ++++++++++++++++++++++ src/labels/mod.rs | 88 +++++++++++++++ src/labels/stream_properties.rs | 184 ++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 7 files changed, 714 insertions(+), 2 deletions(-) create mode 100644 src/labels/bluray_project.rs create mode 100644 src/labels/language_streams.rs create mode 100644 src/labels/menu_base.rs create mode 100644 src/labels/mod.rs create mode 100644 src/labels/stream_properties.rs diff --git a/src/disc.rs b/src/disc.rs index d71b223..5107271 100644 --- a/src/disc.rs +++ b/src/disc.rs @@ -421,9 +421,12 @@ impl Disc { // Step 4: Read disc title from META/DL/bdmt_eng.xml let meta_title = Self::read_meta_title(session, &udf_fs); - // Step 5: Extract JAR track labels and merge into streams + // Step 5: Extract stream labels from disc config files + let disc_labels = crate::labels::extract(session, &udf_fs); + Self::apply_disc_labels(&mut titles, &disc_labels); + + // JAR labels (for playlist purpose markers only, not stream labels) let jar_labels = Self::read_jar_labels(session, &udf_fs); - Self::apply_jar_labels(&mut titles, &jar_labels); // Step 6: Detect AACS encryption let encrypted = udf_fs.find_dir("/AACS").is_some() @@ -584,6 +587,64 @@ impl Disc { /// Two matching strategies: /// 1. By language+codec (label format like eng_MLP_) — matches stream by content /// 2. By index (TextField format) — Nth label → Nth stream + /// Apply disc config file labels to streams by stream number. + /// Matches by STN index — label stream_number N → Nth audio/subtitle stream. + fn apply_disc_labels(titles: &mut [Title], labels: &[crate::labels::StreamLabel]) { + use crate::labels::{StreamLabelType, LabelPurpose, LabelQualifier}; + + for title in titles.iter_mut() { + let mut audio_idx: u16 = 0; + let mut sub_idx: u16 = 0; + + for stream in &mut title.streams { + match stream { + Stream::Audio(a) => { + audio_idx += 1; + // Find label with matching stream number + if let Some(label) = labels.iter().find(|l| + l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx + ) { + // Build label string from purpose + region + let mut parts = Vec::new(); + match label.purpose { + LabelPurpose::Commentary => parts.push("Commentary".to_string()), + LabelPurpose::Descriptive => parts.push("Descriptive Audio".to_string()), + LabelPurpose::Score => parts.push("Score".to_string()), + LabelPurpose::Ime => parts.push("IME".to_string()), + LabelPurpose::Normal => {} + } + if !label.region.is_empty() { parts.push(format!("({})", label.region)); } + if !label.codec_hint.is_empty() && label.codec_hint != "MLP" && label.codec_hint != "AC3" { + parts.push(label.codec_hint.clone()); + } + if !label.name.is_empty() && parts.is_empty() { + a.label = label.name.clone(); + } else if !parts.is_empty() { + a.label = parts.join(" "); + } + } + } + Stream::Subtitle(s) => { + sub_idx += 1; + if let Some(label) = labels.iter().find(|l| + l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx + ) { + if label.qualifier == LabelQualifier::Sdh { + // Set a label for SDH + s.forced = false; + // TODO: add sdh field to SubtitleStream + } + if label.qualifier == LabelQualifier::Forced { + s.forced = true; + } + } + } + _ => {} + } + } + } + } + fn apply_jar_labels(titles: &mut [Title], jar: &crate::jar::JarLabels) { if jar.audio.is_empty() && jar.subtitle.is_empty() { return; diff --git a/src/labels/bluray_project.rs b/src/labels/bluray_project.rs new file mode 100644 index 0000000..2212910 --- /dev/null +++ b/src/labels/bluray_project.rs @@ -0,0 +1,161 @@ +//! Parser for `bluray_project.bin` — Pixelogic binary format. +//! +//! Found at: `BDMV/JAR/*/bluray_project.bin` +//! +//! Binary file with embedded UTF-8 strings. Stream tokens appear in STN order +//! under playlist sections. Tokens follow the format: +//! {lang}_{purpose?}_{codec?}_{region?}_ +//! +//! Examples: eng_MLP_, eng_US_ADES_, eng_SDH_, fra_TXT_FOR_ + +use crate::drive::DriveSession; +use crate::udf::{UdfFs, DirEntry}; +use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; + +/// Known audio codec tokens +const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV"]; +/// Known audio purpose tokens +const AUDIO_PURPOSES: &[&str] = &["ADLG", "ACOM", "ADES", "ATRI"]; +/// Known subtitle types +const SUB_TYPES: &[&str] = &["SDH", "SDLG", "SCOM", "STRI"]; +/// Known region tokens +const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"]; + +pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option> { + let jar_dir = udf.find_dir("/BDMV/JAR")?; + let data = find_and_read(session, udf, jar_dir, "bluray_project.bin")?; + + // Extract all strings from the binary + let strings = extract_strings(&data); + + // Find the main feature section — look for "FPL_MainFeature" or "SEG_MainFeature" + // then collect audio and subtitle tokens that follow + let mut labels = Vec::new(); + let mut in_feature = false; + let mut audio_num: u16 = 0; + let mut sub_num: u16 = 0; + + for s in &strings { + // Detect feature section start + if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") { + if in_feature { + // Already found one — this is a second copy, skip + break; + } + in_feature = true; + audio_num = 0; + sub_num = 0; + continue; + } + + // Detect section end (next playlist starts) + if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) { + break; + } + + if !in_feature { continue; } + + // Try to parse as a stream token + if let Some(label) = parse_token(s) { + match label.stream_type { + StreamLabelType::Audio => { + audio_num += 1; + labels.push(StreamLabel { stream_number: audio_num, ..label }); + } + StreamLabelType::Subtitle => { + sub_num += 1; + labels.push(StreamLabel { stream_number: sub_num, ..label }); + } + } + } + } + + if labels.is_empty() { return None; } + Some(labels) +} + +/// Parse a token string like "eng_MLP_" or "eng_US_ADES_" into a StreamLabel. +fn parse_token(s: &str) -> Option { + let clean = s.trim_end_matches('_'); + let parts: Vec<&str> = clean.split('_').collect(); + if parts.len() < 2 { return None; } + + // First part must be 3-letter lowercase language code + let lang = parts[0]; + if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) { + return None; + } + + let mut codec = String::new(); + let mut purpose = LabelPurpose::Normal; + let mut qualifier = LabelQualifier::None; + let mut region = String::new(); + let mut is_subtitle = false; + let mut is_audio = false; + + for &part in &parts[1..] { + if part.is_empty() { continue; } + if AUDIO_CODECS.contains(&part) { codec = part.to_string(); is_audio = true; } + else if part == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; } + else if part == "ACOM" { purpose = LabelPurpose::Commentary; is_audio = true; } + else if part == "ADLG" { purpose = LabelPurpose::Normal; is_audio = true; } + else if part == "ATRI" { purpose = LabelPurpose::Normal; is_audio = true; } + else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; } + else if part == "SDLG" { is_subtitle = true; } + else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; } + else if part == "STRI" { is_subtitle = true; } + else if part == "TXT" { is_subtitle = true; } + else if part == "FOR" { qualifier = LabelQualifier::Forced; } + else if REGIONS.contains(&part) { region = part.to_string(); } + else if part.starts_with("PGStream") { is_subtitle = true; } + else { return None; } // Unknown token — not a stream ID + } + + if !is_audio && !is_subtitle { return None; } + + let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio }; + + Some(StreamLabel { + stream_number: 0, // caller sets this + stream_type, + language: lang.to_string(), + name: s.to_string(), + purpose, + qualifier, + codec_hint: codec, + region, + }) +} + +/// Extract all printable strings > 3 chars from binary data. +fn extract_strings(data: &[u8]) -> Vec { + let mut strings = Vec::new(); + let mut current = String::new(); + + for &b in data { + if b >= 0x20 && b < 0x7f { + current.push(b as char); + } else { + if current.len() > 3 { + strings.push(current.clone()); + } + current.clear(); + } + } + if current.len() > 3 { + strings.push(current); + } + strings +} + +fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option> { + for entry in &parent.entries { + if entry.is_dir { + let sub_path = format!("/BDMV/JAR/{}/{}", entry.name, filename); + if let Ok(data) = udf.read_file(session, &sub_path) { + if !data.is_empty() { return Some(data); } + } + } + } + None +} diff --git a/src/labels/language_streams.rs b/src/labels/language_streams.rs new file mode 100644 index 0000000..fe94ff6 --- /dev/null +++ b/src/labels/language_streams.rs @@ -0,0 +1,88 @@ +//! Parser for `language_streams.txt` — Warner CTRM CSV format. +//! +//! Found at: `BDMV/JAR/*/language_streams.txt` +//! +//! Format: +//! ```text +//! playlist_id, type, stream_num, language, variant +//! 100, audio_production, 1, eng, atmos +//! 100, subtitle_narrative, 7, fra, +//! ``` + +use crate::drive::DriveSession; +use crate::udf::{UdfFs, DirEntry}; +use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; + +pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option> { + // Search BDMV/JAR/*/ for language_streams.txt + let jar_dir = udf.find_dir("/BDMV/JAR")?; + let data = find_and_read(session, udf, jar_dir, "language_streams.txt")?; + let text = std::str::from_utf8(&data).ok()?; + + let mut labels = Vec::new(); + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() < 4 { continue; } + + let _playlist_id = parts[0]; // could filter by playlist later + let type_str = parts[1]; + let stream_num: u16 = match parts[2].parse() { + Ok(n) => n, + Err(_) => continue, + }; + let language = parts[3].to_string(); + let variant = if parts.len() > 4 { parts[4].to_string() } else { String::new() }; + + let (stream_type, purpose, qualifier) = match type_str { + "audio_production" => (StreamLabelType::Audio, LabelPurpose::Normal, LabelQualifier::None), + "audio_commentary" => (StreamLabelType::Audio, LabelPurpose::Commentary, LabelQualifier::None), + "audio_ime" => (StreamLabelType::Audio, LabelPurpose::Ime, LabelQualifier::None), + "subtitle_production" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None), + "subtitle_commentary" => (StreamLabelType::Subtitle, LabelPurpose::Commentary, LabelQualifier::None), + "subtitle_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::Forced), + "subtitle_dual" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None), + "subtitle_bonus" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None), + "subtitle_ime" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::None), + "subtitle_ime_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::Forced), + _ => continue, + }; + + // Skip feature_override_default lines + if type_str == "feature_override_default" { continue; } + + let codec_hint = variant.clone(); // "atmos", "eda", etc. + + labels.push(StreamLabel { + stream_number: stream_num, + stream_type, + language, + name: String::new(), + purpose, + qualifier, + codec_hint, + region: String::new(), + }); + } + + if labels.is_empty() { return None; } + Some(labels) +} + +/// Search subdirectories of a parent for a file by name. +fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option> { + for entry in &parent.entries { + if entry.is_dir { + let sub_path = format!("/BDMV/JAR/{}/{}", entry.name, filename); + if let Ok(data) = udf.read_file(session, &sub_path) { + if !data.is_empty() { + return Some(data); + } + } + } + } + None +} diff --git a/src/labels/menu_base.rs b/src/labels/menu_base.rs new file mode 100644 index 0000000..ba873fd --- /dev/null +++ b/src/labels/menu_base.rs @@ -0,0 +1,129 @@ +//! Parser for `menu_base.prop` — Warner CTRM properties format. +//! +//! Found at: `BDMV/JAR/*/menu_base.prop` +//! +//! Format: +//! ```text +//! audio_en.name=AudioEnglish +//! audio_en.audioStream=1 +//! audio_en.audioLanguage=eng +//! subtitle_en_sdh.name=SubtitleEnglishSDH +//! subtitle_en_sdh.subtitleStream=1 +//! subtitle_en_sdh.subtitleLanguage=eng +//! ``` + +use crate::drive::DriveSession; +use crate::udf::{UdfFs, DirEntry}; +use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; +use std::collections::HashMap; + +pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option> { + let jar_dir = udf.find_dir("/BDMV/JAR")?; + let data = find_and_read(session, udf, jar_dir, "menu_base.prop")?; + let text = std::str::from_utf8(&data).ok()?; + + // Parse into key=value map grouped by prefix + // e.g. "audio_en.name" → prefix="audio_en", key="name", value="AudioEnglish" + let mut entries: HashMap> = HashMap::new(); + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + let eq_pos = match line.find('=') { + Some(p) => p, + None => continue, + }; + let full_key = &line[..eq_pos]; + let value = &line[eq_pos + 1..]; + + // Split on last dot: "audio_en.audioStream" → ("audio_en", "audioStream") + if let Some(dot_pos) = full_key.rfind('.') { + let prefix = full_key[..dot_pos].to_string(); + let key = full_key[dot_pos + 1..].to_string(); + entries.entry(prefix).or_default().insert(key, value.to_string()); + } + } + + let mut labels = Vec::new(); + + for (prefix, props) in &entries { + // Audio entries have "audioStream" and "audioLanguage" + if let (Some(stream_str), Some(language)) = (props.get("audioStream"), props.get("audioLanguage")) { + let stream_num: u16 = match stream_str.parse() { + Ok(n) if n > 0 => n, + _ => continue, + }; + let name = props.get("name").cloned().unwrap_or_default(); + + // Detect purpose from name + let name_lower = name.to_lowercase(); + let purpose = if name_lower.contains("commentary") || prefix.contains("comm") { + LabelPurpose::Commentary + } else { + LabelPurpose::Normal + }; + + // Detect codec hint from name + let codec_hint = if name_lower.contains("dolby") || name_lower.contains("hd") { + "lossless".to_string() + } else { + String::new() + }; + + labels.push(StreamLabel { + stream_number: stream_num, + stream_type: StreamLabelType::Audio, + language: language.clone(), + name, + purpose, + qualifier: LabelQualifier::None, + codec_hint, + region: String::new(), + }); + } + + // Subtitle entries have "subtitleStream" and "subtitleLanguage" + if let (Some(stream_str), Some(language)) = (props.get("subtitleStream"), props.get("subtitleLanguage")) { + let stream_num: u16 = match stream_str.parse() { + Ok(n) if n > 0 => n, + _ => continue, + }; + let name = props.get("name").cloned().unwrap_or_default(); + + let name_lower = name.to_lowercase(); + let qualifier = if name_lower.contains("sdh") { + LabelQualifier::Sdh + } else { + LabelQualifier::None + }; + + labels.push(StreamLabel { + stream_number: stream_num, + stream_type: StreamLabelType::Subtitle, + language: language.clone(), + name, + purpose: LabelPurpose::Normal, + qualifier, + codec_hint: String::new(), + region: String::new(), + }); + } + } + + if labels.is_empty() { return None; } + // Sort by type then stream number + labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number)); + Some(labels) +} + +fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option> { + for entry in &parent.entries { + if entry.is_dir { + let sub_path = format!("/BDMV/JAR/{}/{}", entry.name, filename); + if let Ok(data) = udf.read_file(session, &sub_path) { + if !data.is_empty() { return Some(data); } + } + } + } + None +} diff --git a/src/labels/mod.rs b/src/labels/mod.rs new file mode 100644 index 0000000..b07c35f --- /dev/null +++ b/src/labels/mod.rs @@ -0,0 +1,88 @@ +//! Stream label extraction from BD-J disc files. +//! +//! Searches the disc UDF filesystem for known config files that contain +//! stream labels (language, purpose, codec, forced flags). Four formats +//! supported, tried in order: +//! +//! 1. `language_streams.txt` — Warner CTRM CSV format +//! 2. `menu_base.prop` — Warner CTRM properties format +//! 3. `streamproperties.xml` + `playbackconfig.xml` — Criterion XML format +//! 4. `bluray_project.bin` — Pixelogic binary format + +mod language_streams; +mod menu_base; +mod stream_properties; +mod bluray_project; + +use crate::drive::DriveSession; +use crate::udf::UdfFs; + +/// A stream label extracted from disc config files. +#[derive(Debug, Clone)] +pub struct StreamLabel { + /// STN index (1-based) + pub stream_number: u16, + /// Audio or Subtitle + pub stream_type: StreamLabelType, + /// ISO 639-2 language code + pub language: String, + /// Display name (e.g. "AudioEnglishDolby", "English Dolby Atmos") + pub name: String, + /// Stream purpose + pub purpose: LabelPurpose, + /// Additional qualifier + pub qualifier: LabelQualifier, + /// Codec hint from config (e.g. "MLP", "AC3", "atmos") + pub codec_hint: String, + /// Regional variant (e.g. "US", "UK", "CF", "CS") + pub region: String, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum StreamLabelType { + Audio, + Subtitle, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LabelPurpose { + /// Normal dialogue track + Normal, + /// Audio commentary + Commentary, + /// Descriptive audio (visually impaired) + Descriptive, + /// Music score only + Score, + /// In-movie experience + Ime, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LabelQualifier { + None, + /// Subtitles for deaf and hard of hearing + Sdh, + /// Descriptive service + DescriptiveService, + /// Forced/narrative subtitle + Forced, +} + +/// Try all parsers in order, return first successful result. +pub fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec { + // Try each format in order + if let Some(labels) = language_streams::parse(session, udf) { + return labels; + } + if let Some(labels) = menu_base::parse(session, udf) { + return labels; + } + if let Some(labels) = stream_properties::parse(session, udf) { + return labels; + } + if let Some(labels) = bluray_project::parse(session, udf) { + return labels; + } + Vec::new() +} diff --git a/src/labels/stream_properties.rs b/src/labels/stream_properties.rs new file mode 100644 index 0000000..8c26d45 --- /dev/null +++ b/src/labels/stream_properties.rs @@ -0,0 +1,184 @@ +//! Parser for `streamproperties.xml` + `playbackconfig.xml` — Criterion XML format. +//! +//! Found at: `BDMV/JAR/*/streamproperties.xml` and `BDMV/JAR/*/playbackconfig.xml` +//! +//! streamproperties.xml defines stream info IDs with Content/Qualifier. +//! playbackconfig.xml maps StreamID (number) → StreamInfo_ID. + +use crate::drive::DriveSession; +use crate::udf::{UdfFs, DirEntry}; +use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; +use std::collections::HashMap; + +pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option> { + let jar_dir = udf.find_dir("/BDMV/JAR")?; + + let sp_data = find_and_read(session, udf, jar_dir, "streamproperties.xml")?; + let sp_text = std::str::from_utf8(&sp_data).ok()?; + + // Parse stream infos from streamproperties.xml + // Simple tag-based parsing (no full XML parser needed) + let stream_infos = parse_stream_infos(sp_text); + if stream_infos.is_empty() { return None; } + + // Try to get playbackconfig.xml for stream number mapping + let mut stream_map: HashMap = HashMap::new(); + if let Some(pc_data) = find_and_read(session, udf, jar_dir, "playbackconfig.xml") { + if let Ok(pc_text) = std::str::from_utf8(&pc_data) { + parse_playback_config(pc_text, &mut stream_map); + } + } + + let mut labels = Vec::new(); + let mut audio_idx: u16 = 1; + let mut sub_idx: u16 = 1; + + for info in &stream_infos { + let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| { + // Fallback: assign by order + match info.stream_type { + StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n } + StreamLabelType::Subtitle => { let n = sub_idx; sub_idx += 1; n } + } + }); + + labels.push(StreamLabel { + stream_number: stream_num, + stream_type: info.stream_type, + language: info.language.clone(), + name: info.id.clone(), + purpose: info.purpose, + qualifier: info.qualifier, + codec_hint: String::new(), + region: info.region.clone(), + }); + } + + if labels.is_empty() { return None; } + Some(labels) +} + +struct StreamInfo { + id: String, + stream_type: StreamLabelType, + language: String, + region: String, + purpose: LabelPurpose, + qualifier: LabelQualifier, +} + +fn parse_stream_infos(xml: &str) -> Vec { + let mut infos = Vec::new(); + + // Find ... and ... + let mut pos = 0; + while pos < xml.len() { + let (tag, stream_type) = if let Some(p) = xml[pos..].find("") { + (p + pos, StreamLabelType::Audio) + } else if let Some(p) = xml[pos..].find("") { + (p + pos, StreamLabelType::Subtitle) + } else { + break; + }; + + let end_tag = match stream_type { + StreamLabelType::Audio => "", + StreamLabelType::Subtitle => "", + }; + + let block_end = match xml[tag..].find(end_tag) { + Some(p) => tag + p + end_tag.len(), + None => break, + }; + + let block = &xml[tag..block_end]; + + let id = extract_tag(block, "ID").unwrap_or_default(); + let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default(); + let content = extract_tag(block, "Content").unwrap_or_default(); + let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default(); + + // Parse language and region from LangInfoID (e.g. "ENG_US") + let (language, region) = if lang_id.contains('_') { + let parts: Vec<&str> = lang_id.splitn(2, '_').collect(); + (parts[0].to_lowercase(), parts[1].to_string()) + } else { + (lang_id.to_lowercase(), String::new()) + }; + + let purpose = match content.as_str() { + "COMMENTARY" => LabelPurpose::Commentary, + "DIALOGUE" | _ => LabelPurpose::Normal, + }; + + let qualifier = match qualifier_str.as_str() { + "SDH" => LabelQualifier::Sdh, + "DS" => LabelQualifier::DescriptiveService, + _ => LabelQualifier::None, + }; + + infos.push(StreamInfo { + id, stream_type, language, region, purpose, qualifier, + }); + + pos = block_end; + } + + infos +} + +fn parse_playback_config(xml: &str, map: &mut HashMap) { + // Find and blocks + // Each has N and STRA_xxx + let mut pos = 0; + while pos < xml.len() { + let tag_start = if let Some(p) = xml[pos..].find("") { + Some(p + pos) + } else if let Some(p) = xml[pos..].find("") { + Some(p + pos) + } else { + None + }; + + let tag_start = match tag_start { + Some(p) => p, + None => break, + }; + + // Find end of this block + let block_end = xml[tag_start..].find("") + .or_else(|| xml[tag_start..].find("")) + .map(|p| tag_start + p + 20) + .unwrap_or(xml.len()); + + let block = &xml[tag_start..block_end]; + + if let (Some(stream_id_str), Some(info_id)) = (extract_tag(block, "StreamID"), extract_tag(block, "StreamInfo_ID")) { + if let Ok(stream_num) = stream_id_str.parse::() { + map.insert(info_id, stream_num); + } + } + + pos = block_end; + } +} + +fn extract_tag(xml: &str, tag: &str) -> Option { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = xml.find(&open)? + open.len(); + let end = xml[start..].find(&close)? + start; + Some(xml[start..end].trim().to_string()) +} + +fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option> { + for entry in &parent.entries { + if entry.is_dir { + let sub_path = format!("/BDMV/JAR/{}/{}", entry.name, filename); + if let Ok(data) = udf.read_file(session, &sub_path) { + if !data.is_empty() { return Some(data); } + } + } + } + None +} diff --git a/src/lib.rs b/src/lib.rs index 83478cd..4a58a7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,7 @@ pub mod clpi; pub mod disc; pub mod jar; pub mod aacs; +pub mod labels; pub use error::{Error, Result}; pub use drive::DriveSession;