diff --git a/src/disc.rs b/src/disc.rs index 6c0d410..2bbc946 100644 --- a/src/disc.rs +++ b/src/disc.rs @@ -34,8 +34,8 @@ pub struct Disc { pub layers: u8, /// Titles sorted by duration (longest first), then playlist name pub titles: Vec, - /// JAR track labels (audio/subtitle names from BD-J menus) - pub jar_labels: crate::jar::JarLabels, + /// Disc region + pub region: DiscRegion, /// AACS state — None if disc is unencrypted or keys unavailable pub aacs: Option<AacsState>, /// Whether this disc requires AACS decryption @@ -55,6 +55,28 @@ pub enum DiscFormat { Unknown, } +/// Disc playback region. +#[derive(Debug, Clone, PartialEq)] +pub enum DiscRegion { + /// Region-free (all UHD discs, some BD/DVD) + Free, + /// Blu-ray regions (A/B/C or combination) + BluRay(Vec<BdRegion>), + /// DVD regions (1-8 or combination) + Dvd(Vec<u8>), +} + +/// Blu-ray region codes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BdRegion { + /// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia) + A, + /// Region B/2 — Europe, Africa, Australia, Middle East + B, + /// Region C/3 — Central/South Asia, China, Russia + C, +} + /// A title (one MPLS playlist). #[derive(Debug, Clone)] pub struct Title { @@ -424,9 +446,6 @@ impl Disc { // Step 5: Enhance streams with disc config file labels (if available) crate::labels::apply(session, &udf_fs, &mut titles); - // JAR labels (for playlist purpose markers only, not stream labels) - let jar_labels = Self::read_jar_labels(session, &udf_fs); - // Step 6: Detect AACS encryption let encrypted = udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); @@ -454,6 +473,13 @@ impl Disc { // BD-66/100 UHD: 25M+ sectors let layers = if capacity > 24_000_000 { 2 } else { 1 }; + // UHD is always region-free. BD/DVD region parsing TODO. + let region = if format == DiscFormat::Uhd { + DiscRegion::Free + } else { + DiscRegion::Free // TODO: parse from index.bdmv + }; + Ok(Disc { volume_id: udf_fs.volume_id.clone(), meta_title: meta_title, @@ -462,7 +488,7 @@ impl Disc { capacity_bytes: capacity as u64 * 2048, layers, titles, - jar_labels, + region, aacs, encrypted, }) @@ -581,125 +607,6 @@ impl Disc { DiscFormat::Unknown } - /// Merge JAR labels into title streams. - /// - /// 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; - } - - // Check if labels have language+codec info (label format) - let has_content_match = jar.audio.iter().any(|l| !l.language.is_empty() && !l.codec_hint.is_empty()); - - for title in titles.iter_mut() { - if has_content_match { - // Match by language + codec - Self::apply_labels_by_content(title, jar); - } else { - // Match by index - Self::apply_labels_by_index(title, jar); - } - } - } - - /// Match labels to streams by language + codec hint. - fn apply_labels_by_content(title: &mut Title, jar: &crate::jar::JarLabels) { - // For each JAR audio label, find the matching stream - let mut used = vec![false; jar.audio.len()]; - - for stream in &mut title.streams { - if let Stream::Audio(a) = stream { - // Find a JAR label matching this stream's language + codec - for (i, label) in jar.audio.iter().enumerate() { - if used[i] { continue; } - if label.language == a.language && codec_matches(&label.codec_hint, a.codec) { - if !label.description.is_empty() { - a.label = label.description.clone(); - } - used[i] = true; - break; - } - } - } - } - } - - /// Match labels to streams by STN index position. - fn apply_labels_by_index(title: &mut Title, jar: &crate::jar::JarLabels) { - let mut audio_idx = 0; - for stream in &mut title.streams { - if let Stream::Audio(a) = stream { - if let Some(label) = jar.audio.get(audio_idx) { - if !label.description.is_empty() { - a.label = label.description.clone(); - } - } - audio_idx += 1; - } - } - } - /// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table). /// Prefers English, falls back to first available language. /// Returns None if META directory is empty or XML has no usable title. @@ -736,23 +643,6 @@ impl Disc { None } - /// Extract track labels from BD-J JAR files. - fn read_jar_labels(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> crate::jar::JarLabels { - if let Some(jar_dir) = udf_fs.find_dir("/BDMV/JAR") { - for entry in &jar_dir.entries { - if !entry.is_dir && entry.name.to_lowercase().ends_with(".jar") { - let path = format!("/BDMV/JAR/{}", entry.name); - if let Ok(jar_data) = udf_fs.read_file(session, &path) { - if let Some(labels) = crate::jar::extract_labels(&jar_data) { - return labels; - } - } - } - } - } - crate::jar::JarLabels::default() - } - fn read_capacity(session: &mut DriveSession) -> Result<u32> { let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; let mut buf = [0u8; 8]; @@ -980,18 +870,6 @@ fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048 // ─── Format helpers ──────────────────────────────────────────────────────── -/// Check if a JAR codec hint matches a stream codec. -fn codec_matches(hint: &str, codec: Codec) -> bool { - match hint { - "MLP" => codec == Codec::TrueHd, - "AC3" => codec == Codec::Ac3 || codec == Codec::Ac3Plus, - "DTS" => codec == Codec::Dts || codec == Codec::DtsHdMa || codec == Codec::DtsHdHr, - "LPCM" => codec == Codec::Lpcm, - "ADES" => codec == Codec::Ac3, // descriptive audio is usually DD - _ => false, - } -} - fn format_resolution(video_format: u8, _video_rate: u8) -> String { match video_format { 1 => "480i".into(), diff --git a/src/drive.rs b/src/drive.rs index 2b67736..a33731e 100644 --- a/src/drive.rs +++ b/src/drive.rs @@ -163,6 +163,22 @@ impl DriveSession { Ok(result.bytes_transferred) } + /// Eject the disc tray. + /// + /// Sends PREVENT ALLOW MEDIUM REMOVAL (allow) first to release any + /// locks, then START STOP UNIT with LoEj=1 to open the tray. + pub fn eject(&mut self) -> Result<()> { + // PREVENT ALLOW MEDIUM REMOVAL: allow removal + let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0]; + let mut buf = [0u8; 0]; + let _ = self.scsi.as_mut().execute(&allow_cdb, crate::scsi::DataDirection::None, &mut buf, 5_000); + + // START STOP UNIT: LoEj=1, Start=0 + let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0]; + self.scsi.as_mut().execute(&eject_cdb, crate::scsi::DataDirection::None, &mut buf, 30_000)?; + Ok(()) + } + /// Execute a raw SCSI CDB. Used by parsers and AACS handshake. pub fn scsi_execute( &mut self, diff --git a/src/labels/stream_properties.rs b/src/labels/criterion.rs similarity index 70% rename from src/labels/stream_properties.rs rename to src/labels/criterion.rs index 8c26d45..541ba9d 100644 --- a/src/labels/stream_properties.rs +++ b/src/labels/criterion.rs @@ -1,29 +1,27 @@ -//! Parser for `streamproperties.xml` + `playbackconfig.xml` — Criterion XML format. +//! Criterion Collection — `streamproperties.xml` + `playbackconfig.xml` //! -//! 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. +//! Clean structured XML with Content/Qualifier per stream and +//! stream number mapping via playbackconfig. use crate::drive::DriveSession; -use crate::udf::{UdfFs, DirEntry}; +use crate::udf::UdfFs; use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; use std::collections::HashMap; -pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> { - let jar_dir = udf.find_dir("/BDMV/JAR")?; +pub fn detect(udf: &UdfFs) -> bool { + super::jar_file_exists(udf, "streamproperties.xml") +} - let sp_data = find_and_read(session, udf, jar_dir, "streamproperties.xml")?; +pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> { + let sp_data = super::read_jar_file(session, udf, "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 + // Stream number mapping from playbackconfig.xml let mut stream_map: HashMap<String, u16> = HashMap::new(); - if let Some(pc_data) = find_and_read(session, udf, jar_dir, "playbackconfig.xml") { + if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") { if let Ok(pc_text) = std::str::from_utf8(&pc_data) { parse_playback_config(pc_text, &mut stream_map); } @@ -35,7 +33,6 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel> 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 } @@ -46,11 +43,11 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel> stream_number: stream_num, stream_type: info.stream_type, language: info.language.clone(), - name: info.id.clone(), + name: String::new(), purpose: info.purpose, qualifier: info.qualifier, codec_hint: String::new(), - region: info.region.clone(), + variant: info.variant.clone(), }); } @@ -62,16 +59,15 @@ struct StreamInfo { id: String, stream_type: StreamLabelType, language: String, - region: String, + variant: String, purpose: LabelPurpose, qualifier: LabelQualifier, } fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> { let mut infos = Vec::new(); - - // Find <AudioStreamInfos>...</AudioStreamInfos> and <SubtitleStreamInfos>...</SubtitleStreamInfos> let mut pos = 0; + while pos < xml.len() { let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") { (p + pos, StreamLabelType::Audio) @@ -92,14 +88,12 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> { }; 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 (language, variant) = if lang_id.contains('_') { let parts: Vec<&str> = lang_id.splitn(2, '_').collect(); (parts[0].to_lowercase(), parts[1].to_string()) } else { @@ -108,7 +102,7 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> { let purpose = match content.as_str() { "COMMENTARY" => LabelPurpose::Commentary, - "DIALOGUE" | _ => LabelPurpose::Normal, + _ => LabelPurpose::Normal, }; let qualifier = match qualifier_str.as_str() { @@ -117,19 +111,13 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> { _ => LabelQualifier::None, }; - infos.push(StreamInfo { - id, stream_type, language, region, purpose, qualifier, - }); - + infos.push(StreamInfo { id, stream_type, language, variant, purpose, qualifier }); pos = block_end; } - infos } fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) { - // Find <AudioStreams> and <SubtitlesStreams> blocks - // Each has <StreamID>N</StreamID> and <StreamInfo_ID>STRA_xxx</StreamInfo_ID> let mut pos = 0; while pos < xml.len() { let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") { @@ -145,7 +133,6 @@ fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) { None => break, }; - // Find end of this block let block_end = xml[tag_start..].find("</AudioStreams>") .or_else(|| xml[tag_start..].find("</SubtitlesStreams>")) .map(|p| tag_start + p + 20) @@ -170,15 +157,3 @@ fn extract_tag(xml: &str, tag: &str) -> Option<String> { 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<Vec<u8>> { - 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/ctrm.rs b/src/labels/ctrm.rs new file mode 100644 index 0000000..7d782aa --- /dev/null +++ b/src/labels/ctrm.rs @@ -0,0 +1,208 @@ +//! Warner CTRM — `menu_base.prop` and/or `language_streams.txt` +//! +//! Two sub-formats from the same framework. A disc may have one or both. +//! When both exist, language_streams.txt provides structured types while +//! menu_base.prop provides stream number → button name mapping. + +use crate::drive::DriveSession; +use crate::udf::UdfFs; +use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab}; +use std::collections::HashMap; + +pub fn detect(udf: &UdfFs) -> bool { + super::jar_file_exists(udf, "menu_base.prop") + || super::jar_file_exists(udf, "language_streams.txt") +} + +pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> { + // Try language_streams.txt first (richer structured data) + let ls_labels = parse_language_streams(session, udf); + + // Try menu_base.prop (stream numbers + key names) + let mb_labels = parse_menu_base(session, udf); + + // If we have both, merge: language_streams for structure, menu_base for names + match (ls_labels, mb_labels) { + (Some(ls), Some(mb)) => Some(merge(ls, mb)), + (Some(ls), None) => Some(ls), + (None, Some(mb)) => Some(mb), + (None, None) => None, + } +} + +fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> { + // language_streams has better type/purpose data, menu_base has button names + // Match by stream number + type, take name from menu_base + let mut result = ls; + for label in &mut result { + if let Some(mb_match) = mb.iter().find(|m| + m.stream_type == label.stream_type && m.stream_number == label.stream_number + ) { + if label.name.is_empty() && !mb_match.name.is_empty() { + label.name = mb_match.name.clone(); + } + } + } + result +} + +// ── language_streams.txt parser ──────────────────────────────────────────── + +fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> { + let data = super::read_jar_file(session, udf, "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 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, + }; + + // Classify variant code + let mut codec_hint = String::new(); + let mut variant_code = String::new(); + let mut final_purpose = purpose; + + if !variant.is_empty() { + match variant.as_str() { + // Codec variants — use shared label vocab + "atmos" | "MLP" | "AC3" | "DTS" | "DDL" => { + codec_hint = vocab::codec(&variant).to_string(); + } + // Purpose variants + "eda" => final_purpose = LabelPurpose::Descriptive, + // Dialect variants — pass through raw code from disc + "csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => { + variant_code = variant.clone(); + } + // Unknown — store as-is in codec_hint + _ => codec_hint = variant.clone(), + } + } + + labels.push(StreamLabel { + stream_number: stream_num, + stream_type, + language, + name: String::new(), + purpose: final_purpose, + qualifier, + codec_hint, + variant: variant_code, + }); + } + + if labels.is_empty() { return None; } + Some(labels) +} + +// ── menu_base.prop parser ────────────────────────────────────────────────── + +fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> { + let data = super::read_jar_file(session, udf, "menu_base.prop")?; + let text = std::str::from_utf8(&data).ok()?; + + // Parse key=value, group by prefix + let mut entries: HashMap<String, HashMap<String, String>> = 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..]; + + 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: has "streamNumber" or "audioStream" and audio-related class + let is_audio = props.get("class").map_or(false, |c| c.contains("AudioButton")) + || prefix.starts_with("audio_"); + let is_subtitle = props.get("class").map_or(false, |c| c.contains("SubtitleButton")) + || prefix.starts_with("subtitle_"); + + let stream_num_str = props.get("streamNumber") + .or_else(|| props.get("audioStream")) + .or_else(|| props.get("subtitleStream")); + + let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) { + Some(n) if n > 0 => n, + _ => continue, + }; + + if !is_audio && !is_subtitle { continue; } + + let name = props.get("name").cloned().unwrap_or_default(); + let name_lower = name.to_lowercase(); + + let purpose = if name_lower.contains("comment") || prefix.contains("comm") { + LabelPurpose::Commentary + } else { + LabelPurpose::Normal + }; + + let qualifier = if is_subtitle && name_lower.contains("sdh") { + LabelQualifier::Sdh + } else { + LabelQualifier::None + }; + + let stream_type = if is_audio { StreamLabelType::Audio } else { StreamLabelType::Subtitle }; + + // Try to extract language from audioLanguage/subtitleLanguage prop + let language = props.get("audioLanguage") + .or_else(|| props.get("subtitleLanguage")) + .cloned() + .unwrap_or_default(); + + labels.push(StreamLabel { + stream_number: stream_num, + stream_type, + language, + name, + purpose, + qualifier, + codec_hint: String::new(), + variant: String::new(), + }); + } + + if labels.is_empty() { return None; } + labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number)); + Some(labels) +} diff --git a/src/labels/language_streams.rs b/src/labels/language_streams.rs deleted file mode 100644 index fe94ff6..0000000 --- a/src/labels/language_streams.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! 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<Vec<StreamLabel>> { - // 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<Vec<u8>> { - 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 deleted file mode 100644 index ba873fd..0000000 --- a/src/labels/menu_base.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! 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<Vec<StreamLabel>> { - 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<String, HashMap<String, String>> = 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<Vec<u8>> { - 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 index 3914e80..858f781 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -1,14 +1,17 @@ //! 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. If found, labels are applied directly -//! to the title streams. If not found, streams keep MPLS data as-is. +//! Each parser module represents one BD-J authoring framework. +//! To add a new format: +//! 1. Create `src/labels/myformat.rs` +//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool` +//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>` +//! 4. Add `mod myformat;` below and one line to `PARSERS` array -mod language_streams; -mod menu_base; -mod stream_properties; -mod bluray_project; +mod paramount; +mod criterion; +mod pixelogic; +mod ctrm; +pub mod vocab; use crate::drive::DriveSession; use crate::udf::UdfFs; @@ -23,16 +26,16 @@ pub struct StreamLabel { pub stream_type: StreamLabelType, /// ISO 639-2 language code pub language: String, - /// Display name (e.g. "AudioEnglishDolby", "English Dolby Atmos") + /// Display name (e.g. "Commentary", "Descriptive Audio") pub name: String, /// Stream purpose pub purpose: LabelPurpose, /// Additional qualifier pub qualifier: LabelQualifier, - /// Codec hint from config (e.g. "MLP", "AC3", "atmos") + /// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos") pub codec_hint: String, - /// Regional variant (e.g. "US", "UK", "CF", "CS") - pub region: String, + /// Regional variant (e.g. "US", "UK", "Castilian", "Canadian") + pub variant: String, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -58,8 +61,23 @@ pub enum LabelQualifier { Forced, } +// ── Parser registry ──────────────────────────────────────────────────────── +// +// Each entry: (name, detect_fn, parse_fn) +// Order = priority. First match wins. Highest quality output first. + +type DetectFn = fn(&UdfFs) -> bool; +type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>; + +const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ + ("paramount", paramount::detect, paramount::parse), + ("criterion", criterion::detect, criterion::parse), + ("pixelogic", pixelogic::detect, pixelogic::parse), + ("ctrm", ctrm::detect, ctrm::parse), + // ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser +]; + /// Search disc for config files, extract labels, apply to streams. -/// If no config files found, streams are left unchanged. pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) { let labels = extract(session, udf); if labels.is_empty() { return; } @@ -83,12 +101,10 @@ pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) { LabelPurpose::Ime => parts.push("IME".to_string()), LabelPurpose::Normal => {} } - if !label.region.is_empty() { - parts.push(format!("({})", label.region)); + if !label.variant.is_empty() { + parts.push(format!("({})", label.variant)); } - if !label.codec_hint.is_empty() - && !matches!(label.codec_hint.as_str(), "MLP" | "AC3" | "DTS") - { + if !label.codec_hint.is_empty() { parts.push(label.codec_hint.clone()); } if !parts.is_empty() { @@ -115,9 +131,42 @@ pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) { } fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> { - 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; } + for (_name, detect, parse) in PARSERS { + if detect(udf) { + if let Some(labels) = parse(session, udf) { + return labels; + } + } + } Vec::new() } + +// ── Shared helpers ───────────────────────────────────────────────────────── + +/// Check if a file exists in any BDMV/JAR subdirectory. +pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool { + find_jar_file(udf, filename).is_some() +} + +/// Find a file in any BDMV/JAR subdirectory, return its path. +pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> { + let jar_dir = udf.find_dir("/BDMV/JAR")?; + for entry in &jar_dir.entries { + if entry.is_dir { + let path = format!("/BDMV/JAR/{}/{}", entry.name, filename); + // Check if file exists in this subdirectory + for child in &entry.entries { + if !child.is_dir && child.name.eq_ignore_ascii_case(filename) { + return Some(path); + } + } + } + } + None +} + +/// Read a file from any BDMV/JAR subdirectory by filename. +pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> { + let path = find_jar_file(udf, filename)?; + udf.read_file(session, &path).ok().filter(|d| !d.is_empty()) +} diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs new file mode 100644 index 0000000..cbeb352 --- /dev/null +++ b/src/labels/paramount.rs @@ -0,0 +1,142 @@ +//! Paramount/onQ — `playlists.xml` +//! +//! Richest structured format. Complete language lists with forced flags +//! and commentary indices per playlist, all in XML attributes. +//! +//! ```xml +//! <playlist name="Feature" id="00222" +//! aud="eng,deu,spa,spa,fra" +//! sub="eng,eng,zho,ces,dan" +//! forced_sub="0,0,0,1,0" +//! aud_com1_idx="10" +//! sub_com1_idx="23,24,25" /> +//! ``` + +use crate::drive::DriveSession; +use crate::udf::UdfFs; +use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; + +pub fn detect(udf: &UdfFs) -> bool { + super::jar_file_exists(udf, "playlists.xml") +} + +pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> { + let data = super::read_jar_file(session, udf, "playlists.xml")?; + let text = std::str::from_utf8(&data).ok()?; + + // Find the feature playlist — longest duration or name="Feature" + let feature = find_feature_playlist(text)?; + + let mut labels = Vec::new(); + + // Parse audio streams + if let Some(aud) = extract_attr(&feature, "aud") { + let com_idx = extract_attr(&feature, "aud_com1_idx") + .and_then(|s| s.parse::<usize>().ok()); + + for (i, lang) in aud.split(',').enumerate() { + let lang = lang.trim(); + if lang.is_empty() { continue; } + let purpose = if com_idx == Some(i) { + LabelPurpose::Commentary + } else { + LabelPurpose::Normal + }; + labels.push(StreamLabel { + stream_number: (i + 1) as u16, + stream_type: StreamLabelType::Audio, + language: lang.to_string(), + name: String::new(), + purpose, + qualifier: LabelQualifier::None, + codec_hint: String::new(), + variant: String::new(), + }); + } + } + + // Parse subtitle streams + if let Some(sub) = extract_attr(&feature, "sub") { + let forced: Vec<bool> = extract_attr(&feature, "forced_sub") + .map(|s| s.split(',').map(|f| f.trim() == "1").collect()) + .unwrap_or_default(); + + let com_indices: Vec<usize> = extract_attr(&feature, "sub_com1_idx") + .map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect()) + .unwrap_or_default(); + + for (i, lang) in sub.split(',').enumerate() { + let lang = lang.trim(); + if lang.is_empty() { continue; } + + let purpose = if com_indices.contains(&i) { + LabelPurpose::Commentary + } else { + LabelPurpose::Normal + }; + + let qualifier = if forced.get(i).copied().unwrap_or(false) { + LabelQualifier::Forced + } else { + LabelQualifier::None + }; + + labels.push(StreamLabel { + stream_number: (i + 1) as u16, + stream_type: StreamLabelType::Subtitle, + language: lang.to_string(), + name: String::new(), + purpose, + qualifier, + codec_hint: String::new(), + variant: String::new(), + }); + } + } + + if labels.is_empty() { return None; } + Some(labels) +} + +/// Find the feature playlist element (the one with the most audio tracks). +fn find_feature_playlist(xml: &str) -> Option<String> { + let mut best: Option<String> = None; + let mut best_aud_count = 0; + + let mut pos = 0; + while let Some(start) = xml[pos..].find("<playlist ") { + let abs_start = pos + start; + let end = match xml[abs_start..].find("/>") { + Some(p) => abs_start + p + 2, + None => break, + }; + let element = &xml[abs_start..end]; + + // Prefer name="Feature" explicitly + if let Some(name) = extract_attr(element, "name") { + if name.eq_ignore_ascii_case("Feature") { + return Some(element.to_string()); + } + } + + // Otherwise pick the one with the most audio streams + if let Some(aud) = extract_attr(element, "aud") { + let count = aud.split(',').count(); + if count > best_aud_count { + best_aud_count = count; + best = Some(element.to_string()); + } + } + + pos = end; + } + best +} + +/// Extract an XML attribute value from an element string. +fn extract_attr<'a>(element: &'a str, name: &str) -> Option<String> { + let needle = format!("{}=\"", name); + let start = element.find(&needle)? + needle.len(); + let end = element[start..].find('"')? + start; + Some(element[start..end].to_string()) +} diff --git a/src/labels/bluray_project.rs b/src/labels/pixelogic.rs similarity index 60% rename from src/labels/bluray_project.rs rename to src/labels/pixelogic.rs index 2212910..8010763 100644 --- a/src/labels/bluray_project.rs +++ b/src/labels/pixelogic.rs @@ -1,35 +1,27 @@ -//! Parser for `bluray_project.bin` — Pixelogic binary format. +//! Pixelogic — `bluray_project.bin` //! -//! Found at: `BDMV/JAR/*/bluray_project.bin` +//! Binary file with embedded UTF-8 token strings in STN order per +//! playlist section. Most common format (5/10 test discs). //! -//! 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_ +//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_` use crate::drive::DriveSession; -use crate::udf::{UdfFs, DirEntry}; -use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; +use crate::udf::UdfFs; +use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab}; /// 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"]; +const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"]; /// 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<Vec<StreamLabel>> { - let jar_dir = udf.find_dir("/BDMV/JAR")?; - let data = find_and_read(session, udf, jar_dir, "bluray_project.bin")?; +pub fn detect(udf: &UdfFs) -> bool { + super::jar_file_exists(udf, "bluray_project.bin") +} - // Extract all strings from the binary +pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> { + let data = super::read_jar_file(session, udf, "bluray_project.bin")?; 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; @@ -38,24 +30,20 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel> 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; - } + if in_feature { break; } in_feature = true; audio_num = 0; sub_num = 0; continue; } - // Detect section end (next playlist starts) + // Detect section end 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 => { @@ -74,13 +62,11 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel> Some(labels) } -/// Parse a token string like "eng_MLP_" or "eng_US_ADES_" into a StreamLabel. fn parse_token(s: &str) -> Option<StreamLabel> { - let clean = s.trim_end_matches('_'); + let clean = s.trim().trim_start_matches('\t').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; @@ -89,26 +75,26 @@ fn parse_token(s: &str) -> Option<StreamLabel> { let mut codec = String::new(); let mut purpose = LabelPurpose::Normal; let mut qualifier = LabelQualifier::None; - let mut region = String::new(); + let mut variant = 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; } + if AUDIO_CODECS.contains(&part) { codec = vocab::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 == "ADLG" { is_audio = true; } + else if part == "ATRI" { 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 REGIONS.contains(&part) { variant = part.to_string(); } else if part.starts_with("PGStream") { is_subtitle = true; } - else { return None; } // Unknown token — not a stream ID + else { return None; } } if !is_audio && !is_subtitle { return None; } @@ -116,18 +102,17 @@ fn parse_token(s: &str) -> Option<StreamLabel> { let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio }; Some(StreamLabel { - stream_number: 0, // caller sets this + stream_number: 0, stream_type, language: lang.to_string(), - name: s.to_string(), + name: String::new(), purpose, qualifier, codec_hint: codec, - region, + variant, }) } -/// Extract all printable strings > 3 chars from binary data. fn extract_strings(data: &[u8]) -> Vec<String> { let mut strings = Vec::new(); let mut current = String::new(); @@ -147,15 +132,3 @@ fn extract_strings(data: &[u8]) -> Vec<String> { } strings } - -fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option<Vec<u8>> { - 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/vocab.rs b/src/labels/vocab.rs new file mode 100644 index 0000000..06ee608 --- /dev/null +++ b/src/labels/vocab.rs @@ -0,0 +1,27 @@ +//! Shared label vocabulary — values we are 100% confident about. +//! +//! Labels come from BD-J authoring tool files (bluray_project.bin, +//! playlists.xml, menu_base.prop, etc.) — NOT from BD spec fields. +//! This is NOT for MPLS/CLPI/STN data. Those follow the BD spec directly. +//! +//! Rules: +//! - Only map values we are 100% certain about (published codec names). +//! - Unknown codes (csp, eda, cf, etc.) pass through raw from disc. +//! - The app/CLI handles display text, not the lib. + +/// Map a codec identifier found in label data to its display name. +/// +/// These are well-known codec identifiers used across multiple BD-J +/// authoring tools. NOT BD spec STN codec IDs — those are decoded +/// separately in mpls.rs. +pub fn codec(code: &str) -> &str { + match code { + "MLP" => "TrueHD", + "AC3" | "AC" => "Dolby Digital", + "DTS" => "DTS", + "DDL" => "Dolby Digital Plus", + "WAV" => "PCM", + "atmos" => "Dolby Atmos", + _ => code, + } +} diff --git a/src/lib.rs b/src/lib.rs index 4a58a7b..3fe4b66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,7 +77,6 @@ pub mod udf; pub mod mpls; pub mod clpi; pub mod disc; -pub mod jar; pub mod aacs; pub mod labels; diff --git a/src/scsi/linux.rs b/src/scsi/linux.rs index 3c9b549..ea92f05 100644 --- a/src/scsi/linux.rs +++ b/src/scsi/linux.rs @@ -116,4 +116,5 @@ impl ScsiTransport for SgIoTransport { sense, }) } + } diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index b46473f..b8e16b6 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -368,6 +368,7 @@ impl ScsiTransport for MacScsiTransport { sense, }) } + } // ── IOKit service discovery ───────────────────────────────────────────────── diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index 50e1dae..2df7159 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -56,6 +56,7 @@ pub trait ScsiTransport { data: &mut [u8], timeout_ms: u32, ) -> Result<ScsiResult>; + } // ── Platform-agnostic open ────────────────────────────────────────────────── diff --git a/src/udf.rs b/src/udf.rs index 7e73c24..58eaa68 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -141,30 +141,25 @@ impl UdfFs { fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> { for child in &entry.entries { if child.is_dir { - let name_upper = child.name.to_uppercase(); - if matches!(name_upper.as_str(), "STREAM" | "BACKUP" | "DUPLICATE") { + // Only skip STREAM — those are the multi-GB video files + if child.name.eq_ignore_ascii_case("STREAM") { continue; } self.collect_file_ranges(session, child, ranges)?; } else { - let name_upper = child.name.to_uppercase(); + // Include the ICB sector itself (in metadata partition) + ranges.push((self.meta_to_abs(child.meta_lba), 1)); - // 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 { + // Include file data — skip only truly huge files (MKB_RO.inf = 134MB) + if child.size > 50_000_000 { continue; } - // Include everything else (mpls, clpi, jar, bdmv, cer, cci, xml, png, inf) if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) { let abs_start = self.partition_start + data_lba; let sector_count = (data_len + 2047) / 2048; ranges.push((abs_start, sector_count)); } - - // Also include the ICB sector itself (in metadata partition) - ranges.push((self.meta_to_abs(child.meta_lba), 1)); } } Ok(())