Restructure labels: detect-then-parse, named parsers, raw disc data

Architecture:
- Each BD-J format in own file: paramount.rs, criterion.rs, pixelogic.rs, ctrm.rs
- Standard interface: detect() → bool, parse() → Option<Vec<StreamLabel>>
- PARSERS array in mod.rs — drop in a new parser with one line
- Shared vocab.rs for BD spec codec names only (MLP→TrueHD, AC3→Dolby Digital)
- All other label data passes through raw from disc — no guessing

Changes:
- New: paramount.rs (playlists.xml — Paramount/onQ format)
- Renamed: bluray_project.rs → pixelogic.rs
- Renamed: stream_properties.rs → criterion.rs
- Merged: language_streams.rs + menu_base.rs → ctrm.rs
- Removed: jar module (superseded by labels), dead apply functions
- Added: DriveSession::eject() with PREVENT ALLOW MEDIUM REMOVAL
- Added: DiscRegion enum (Free/BluRay/Dvd)
- Fixed: capture sector ranges now include all files (only skip STREAM/)
- Renamed: StreamLabel.region → variant (not a BD spec field)
This commit is contained in:
MattJackson
2026-04-07 20:29:44 -07:00
parent 8e7778c572
commit 89c127b3b6
15 changed files with 547 additions and 499 deletions
+32 -154
View File
@@ -34,8 +34,8 @@ pub struct Disc {
pub layers: u8, pub layers: u8,
/// Titles sorted by duration (longest first), then playlist name /// Titles sorted by duration (longest first), then playlist name
pub titles: Vec<Title>, pub titles: Vec<Title>,
/// JAR track labels (audio/subtitle names from BD-J menus) /// Disc region
pub jar_labels: crate::jar::JarLabels, pub region: DiscRegion,
/// AACS state — None if disc is unencrypted or keys unavailable /// AACS state — None if disc is unencrypted or keys unavailable
pub aacs: Option<AacsState>, pub aacs: Option<AacsState>,
/// Whether this disc requires AACS decryption /// Whether this disc requires AACS decryption
@@ -55,6 +55,28 @@ pub enum DiscFormat {
Unknown, 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). /// A title (one MPLS playlist).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Title { pub struct Title {
@@ -424,9 +446,6 @@ impl Disc {
// Step 5: Enhance streams with disc config file labels (if available) // Step 5: Enhance streams with disc config file labels (if available)
crate::labels::apply(session, &udf_fs, &mut titles); 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 // Step 6: Detect AACS encryption
let encrypted = udf_fs.find_dir("/AACS").is_some() let encrypted = udf_fs.find_dir("/AACS").is_some()
|| udf_fs.find_dir("/BDMV/AACS").is_some(); || udf_fs.find_dir("/BDMV/AACS").is_some();
@@ -454,6 +473,13 @@ impl Disc {
// BD-66/100 UHD: 25M+ sectors // BD-66/100 UHD: 25M+ sectors
let layers = if capacity > 24_000_000 { 2 } else { 1 }; 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 { Ok(Disc {
volume_id: udf_fs.volume_id.clone(), volume_id: udf_fs.volume_id.clone(),
meta_title: meta_title, meta_title: meta_title,
@@ -462,7 +488,7 @@ impl Disc {
capacity_bytes: capacity as u64 * 2048, capacity_bytes: capacity as u64 * 2048,
layers, layers,
titles, titles,
jar_labels, region,
aacs, aacs,
encrypted, encrypted,
}) })
@@ -581,125 +607,6 @@ impl Disc {
DiscFormat::Unknown 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). /// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
/// Prefers English, falls back to first available language. /// Prefers English, falls back to first available language.
/// Returns None if META directory is empty or XML has no usable title. /// Returns None if META directory is empty or XML has no usable title.
@@ -736,23 +643,6 @@ impl Disc {
None 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> { fn read_capacity(session: &mut DriveSession) -> Result<u32> {
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut buf = [0u8; 8]; let mut buf = [0u8; 8];
@@ -980,18 +870,6 @@ fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048
// ─── Format helpers ──────────────────────────────────────────────────────── // ─── 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 { fn format_resolution(video_format: u8, _video_rate: u8) -> String {
match video_format { match video_format {
1 => "480i".into(), 1 => "480i".into(),
+16
View File
@@ -163,6 +163,22 @@ impl DriveSession {
Ok(result.bytes_transferred) 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. /// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
pub fn scsi_execute( pub fn scsi_execute(
&mut self, &mut self,
@@ -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` //! Clean structured XML with Content/Qualifier per stream and
//! //! stream number mapping via playbackconfig.
//! streamproperties.xml defines stream info IDs with Content/Qualifier.
//! playbackconfig.xml maps StreamID (number) → StreamInfo_ID.
use crate::drive::DriveSession; use crate::drive::DriveSession;
use crate::udf::{UdfFs, DirEntry}; use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
use std::collections::HashMap; use std::collections::HashMap;
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> { pub fn detect(udf: &UdfFs) -> bool {
let jar_dir = udf.find_dir("/BDMV/JAR")?; 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()?; 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); let stream_infos = parse_stream_infos(sp_text);
if stream_infos.is_empty() { return None; } 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(); 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) { if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
parse_playback_config(pc_text, &mut stream_map); 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 { for info in &stream_infos {
let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| { let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| {
// Fallback: assign by order
match info.stream_type { match info.stream_type {
StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n } StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n }
StreamLabelType::Subtitle => { let n = sub_idx; sub_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_number: stream_num,
stream_type: info.stream_type, stream_type: info.stream_type,
language: info.language.clone(), language: info.language.clone(),
name: info.id.clone(), name: String::new(),
purpose: info.purpose, purpose: info.purpose,
qualifier: info.qualifier, qualifier: info.qualifier,
codec_hint: String::new(), codec_hint: String::new(),
region: info.region.clone(), variant: info.variant.clone(),
}); });
} }
@@ -62,16 +59,15 @@ struct StreamInfo {
id: String, id: String,
stream_type: StreamLabelType, stream_type: StreamLabelType,
language: String, language: String,
region: String, variant: String,
purpose: LabelPurpose, purpose: LabelPurpose,
qualifier: LabelQualifier, qualifier: LabelQualifier,
} }
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> { fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
let mut infos = Vec::new(); let mut infos = Vec::new();
// Find <AudioStreamInfos>...</AudioStreamInfos> and <SubtitleStreamInfos>...</SubtitleStreamInfos>
let mut pos = 0; let mut pos = 0;
while pos < xml.len() { while pos < xml.len() {
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") { let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
(p + pos, StreamLabelType::Audio) (p + pos, StreamLabelType::Audio)
@@ -92,14 +88,12 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
}; };
let block = &xml[tag..block_end]; let block = &xml[tag..block_end];
let id = extract_tag(block, "ID").unwrap_or_default(); let id = extract_tag(block, "ID").unwrap_or_default();
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default(); let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
let content = extract_tag(block, "Content").unwrap_or_default(); let content = extract_tag(block, "Content").unwrap_or_default();
let qualifier_str = extract_tag(block, "Qualifier").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, variant) = if lang_id.contains('_') {
let (language, region) = if lang_id.contains('_') {
let parts: Vec<&str> = lang_id.splitn(2, '_').collect(); let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
(parts[0].to_lowercase(), parts[1].to_string()) (parts[0].to_lowercase(), parts[1].to_string())
} else { } else {
@@ -108,7 +102,7 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
let purpose = match content.as_str() { let purpose = match content.as_str() {
"COMMENTARY" => LabelPurpose::Commentary, "COMMENTARY" => LabelPurpose::Commentary,
"DIALOGUE" | _ => LabelPurpose::Normal, _ => LabelPurpose::Normal,
}; };
let qualifier = match qualifier_str.as_str() { let qualifier = match qualifier_str.as_str() {
@@ -117,19 +111,13 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
_ => LabelQualifier::None, _ => LabelQualifier::None,
}; };
infos.push(StreamInfo { infos.push(StreamInfo { id, stream_type, language, variant, purpose, qualifier });
id, stream_type, language, region, purpose, qualifier,
});
pos = block_end; pos = block_end;
} }
infos infos
} }
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) { 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; let mut pos = 0;
while pos < xml.len() { while pos < xml.len() {
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") { 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, None => break,
}; };
// Find end of this block
let block_end = xml[tag_start..].find("</AudioStreams>") let block_end = xml[tag_start..].find("</AudioStreams>")
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>")) .or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
.map(|p| tag_start + p + 20) .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; let end = xml[start..].find(&close)? + start;
Some(xml[start..end].trim().to_string()) 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
}
+208
View File
@@ -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)
}
-88
View File
@@ -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
}
-129
View File
@@ -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
}
+71 -22
View File
@@ -1,14 +1,17 @@
//! Stream label extraction from BD-J disc files. //! Stream label extraction from BD-J disc files.
//! //!
//! Searches the disc UDF filesystem for known config files that contain //! Each parser module represents one BD-J authoring framework.
//! stream labels (language, purpose, codec, forced flags). Four formats //! To add a new format:
//! supported, tried in order. If found, labels are applied directly //! 1. Create `src/labels/myformat.rs`
//! to the title streams. If not found, streams keep MPLS data as-is. //! 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 paramount;
mod menu_base; mod criterion;
mod stream_properties; mod pixelogic;
mod bluray_project; mod ctrm;
pub mod vocab;
use crate::drive::DriveSession; use crate::drive::DriveSession;
use crate::udf::UdfFs; use crate::udf::UdfFs;
@@ -23,16 +26,16 @@ pub struct StreamLabel {
pub stream_type: StreamLabelType, pub stream_type: StreamLabelType,
/// ISO 639-2 language code /// ISO 639-2 language code
pub language: String, pub language: String,
/// Display name (e.g. "AudioEnglishDolby", "English Dolby Atmos") /// Display name (e.g. "Commentary", "Descriptive Audio")
pub name: String, pub name: String,
/// Stream purpose /// Stream purpose
pub purpose: LabelPurpose, pub purpose: LabelPurpose,
/// Additional qualifier /// Additional qualifier
pub qualifier: LabelQualifier, 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, pub codec_hint: String,
/// Regional variant (e.g. "US", "UK", "CF", "CS") /// Regional variant (e.g. "US", "UK", "Castilian", "Canadian")
pub region: String, pub variant: String,
} }
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@@ -58,8 +61,23 @@ pub enum LabelQualifier {
Forced, 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. /// 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]) { pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
let labels = extract(session, udf); let labels = extract(session, udf);
if labels.is_empty() { return; } 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::Ime => parts.push("IME".to_string()),
LabelPurpose::Normal => {} LabelPurpose::Normal => {}
} }
if !label.region.is_empty() { if !label.variant.is_empty() {
parts.push(format!("({})", label.region)); parts.push(format!("({})", label.variant));
} }
if !label.codec_hint.is_empty() if !label.codec_hint.is_empty() {
&& !matches!(label.codec_hint.as_str(), "MLP" | "AC3" | "DTS")
{
parts.push(label.codec_hint.clone()); parts.push(label.codec_hint.clone());
} }
if !parts.is_empty() { 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> { fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
if let Some(labels) = language_streams::parse(session, udf) { return labels; } for (_name, detect, parse) in PARSERS {
if let Some(labels) = menu_base::parse(session, udf) { return labels; } if detect(udf) {
if let Some(labels) = stream_properties::parse(session, udf) { return labels; } if let Some(labels) = parse(session, udf) {
if let Some(labels) = bluray_project::parse(session, udf) { return labels; } return labels;
}
}
}
Vec::new() 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())
}
+142
View File
@@ -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())
}
@@ -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 //! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
//! under playlist sections. Tokens follow the format:
//! {lang}_{purpose?}_{codec?}_{region?}_
//!
//! Examples: eng_MLP_, eng_US_ADES_, eng_SDH_, fra_TXT_FOR_
use crate::drive::DriveSession; use crate::drive::DriveSession;
use crate::udf::{UdfFs, DirEntry}; use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
/// Known audio codec tokens /// Known audio codec tokens
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV"]; const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
/// Known audio purpose tokens
const AUDIO_PURPOSES: &[&str] = &["ADLG", "ACOM", "ADES", "ATRI"];
/// Known subtitle types
const SUB_TYPES: &[&str] = &["SDH", "SDLG", "SCOM", "STRI"];
/// Known region tokens /// Known region tokens
const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"]; 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>> { pub fn detect(udf: &UdfFs) -> bool {
let jar_dir = udf.find_dir("/BDMV/JAR")?; super::jar_file_exists(udf, "bluray_project.bin")
let data = find_and_read(session, udf, jar_dir, "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); 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 labels = Vec::new();
let mut in_feature = false; let mut in_feature = false;
let mut audio_num: u16 = 0; 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 { for s in &strings {
// Detect feature section start // Detect feature section start
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") { if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
if in_feature { if in_feature { break; }
// Already found one — this is a second copy, skip
break;
}
in_feature = true; in_feature = true;
audio_num = 0; audio_num = 0;
sub_num = 0; sub_num = 0;
continue; 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_")) { if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
break; break;
} }
if !in_feature { continue; } if !in_feature { continue; }
// Try to parse as a stream token
if let Some(label) = parse_token(s) { if let Some(label) = parse_token(s) {
match label.stream_type { match label.stream_type {
StreamLabelType::Audio => { StreamLabelType::Audio => {
@@ -74,13 +62,11 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>
Some(labels) Some(labels)
} }
/// Parse a token string like "eng_MLP_" or "eng_US_ADES_" into a StreamLabel.
fn parse_token(s: &str) -> Option<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(); let parts: Vec<&str> = clean.split('_').collect();
if parts.len() < 2 { return None; } if parts.len() < 2 { return None; }
// First part must be 3-letter lowercase language code
let lang = parts[0]; let lang = parts[0];
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) { if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
return None; return None;
@@ -89,26 +75,26 @@ fn parse_token(s: &str) -> Option<StreamLabel> {
let mut codec = String::new(); let mut codec = String::new();
let mut purpose = LabelPurpose::Normal; let mut purpose = LabelPurpose::Normal;
let mut qualifier = LabelQualifier::None; let mut qualifier = LabelQualifier::None;
let mut region = String::new(); let mut variant = String::new();
let mut is_subtitle = false; let mut is_subtitle = false;
let mut is_audio = false; let mut is_audio = false;
for &part in &parts[1..] { for &part in &parts[1..] {
if part.is_empty() { continue; } 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 == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; }
else if part == "ACOM" { purpose = LabelPurpose::Commentary; 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 == "ADLG" { is_audio = true; }
else if part == "ATRI" { purpose = LabelPurpose::Normal; is_audio = true; } else if part == "ATRI" { is_audio = true; }
else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; } else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; }
else if part == "SDLG" { is_subtitle = true; } else if part == "SDLG" { is_subtitle = true; }
else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; } else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; }
else if part == "STRI" { is_subtitle = true; } else if part == "STRI" { is_subtitle = true; }
else if part == "TXT" { is_subtitle = true; } else if part == "TXT" { is_subtitle = true; }
else if part == "FOR" { qualifier = LabelQualifier::Forced; } 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 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; } 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 }; let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio };
Some(StreamLabel { Some(StreamLabel {
stream_number: 0, // caller sets this stream_number: 0,
stream_type, stream_type,
language: lang.to_string(), language: lang.to_string(),
name: s.to_string(), name: String::new(),
purpose, purpose,
qualifier, qualifier,
codec_hint: codec, codec_hint: codec,
region, variant,
}) })
} }
/// Extract all printable strings > 3 chars from binary data.
fn extract_strings(data: &[u8]) -> Vec<String> { fn extract_strings(data: &[u8]) -> Vec<String> {
let mut strings = Vec::new(); let mut strings = Vec::new();
let mut current = String::new(); let mut current = String::new();
@@ -147,15 +132,3 @@ fn extract_strings(data: &[u8]) -> Vec<String> {
} }
strings 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
}
+27
View File
@@ -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,
}
}
-1
View File
@@ -77,7 +77,6 @@ pub mod udf;
pub mod mpls; pub mod mpls;
pub mod clpi; pub mod clpi;
pub mod disc; pub mod disc;
pub mod jar;
pub mod aacs; pub mod aacs;
pub mod labels; pub mod labels;
+1
View File
@@ -116,4 +116,5 @@ impl ScsiTransport for SgIoTransport {
sense, sense,
}) })
} }
} }
+1
View File
@@ -368,6 +368,7 @@ impl ScsiTransport for MacScsiTransport {
sense, sense,
}) })
} }
} }
// ── IOKit service discovery ───────────────────────────────────────────────── // ── IOKit service discovery ─────────────────────────────────────────────────
+1
View File
@@ -56,6 +56,7 @@ pub trait ScsiTransport {
data: &mut [u8], data: &mut [u8],
timeout_ms: u32, timeout_ms: u32,
) -> Result<ScsiResult>; ) -> Result<ScsiResult>;
} }
// ── Platform-agnostic open ────────────────────────────────────────────────── // ── Platform-agnostic open ──────────────────────────────────────────────────
+6 -11
View File
@@ -141,30 +141,25 @@ impl UdfFs {
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> { fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
for child in &entry.entries { for child in &entry.entries {
if child.is_dir { if child.is_dir {
let name_upper = child.name.to_uppercase(); // Only skip STREAM — those are the multi-GB video files
if matches!(name_upper.as_str(), "STREAM" | "BACKUP" | "DUPLICATE") { if child.name.eq_ignore_ascii_case("STREAM") {
continue; continue;
} }
self.collect_file_ranges(session, child, ranges)?; self.collect_file_ranges(session, child, ranges)?;
} else { } 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: // Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
// MKB_RO.inf (134MB), ContentHash*.tbl (1MB), ContentRevocation (1MB) if child.size > 50_000_000 {
// Everything disc-info reads is under 3MB.
if child.size > 10_000_000 {
continue; 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) { if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
let abs_start = self.partition_start + data_lba; let abs_start = self.partition_start + data_lba;
let sector_count = (data_len + 2047) / 2048; let sector_count = (data_len + 2047) / 2048;
ranges.push((abs_start, sector_count)); 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(()) Ok(())