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,
/// 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),
+ /// DVD regions (1-8 or combination)
+ Dvd(Vec),
+}
+
+/// 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 {
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> {
- 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> {
+ 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 = 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
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
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 {
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)
@@ -92,14 +88,12 @@ fn parse_stream_infos(xml: &str) -> Vec {
};
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 {
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 {
_ => 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) {
- // 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("") {
@@ -145,7 +133,6 @@ fn parse_playback_config(xml: &str, map: &mut HashMap) {
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)
@@ -170,15 +157,3 @@ fn extract_tag(xml: &str, tag: &str) -> Option {
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/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> {
+ // 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, mb: Vec) -> Vec {
+ // 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> {
+ 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> {
+ 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> = 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> {
- // 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
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> {
- 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
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>`
+//! 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>;
+
+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 {
- 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 {
+ 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> {
+ 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
+//!
+//! ```
+
+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> {
+ 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::().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 = extract_attr(&feature, "forced_sub")
+ .map(|s| s.split(',').map(|f| f.trim() == "1").collect())
+ .unwrap_or_default();
+
+ let com_indices: Vec = 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 {
+ let mut best: Option = None;
+ let mut best_aud_count = 0;
+
+ let mut pos = 0;
+ while let Some(start) = xml[pos..].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 {
+ 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> {
- 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> {
+ 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
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
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 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 {
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 {
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 {
let mut strings = Vec::new();
let mut current = String::new();
@@ -147,15 +132,3 @@ fn extract_strings(data: &[u8]) -> Vec {
}
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/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;
+
}
// ── 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(())