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 aa188751e4
commit 0bb815ca22
15 changed files with 547 additions and 499 deletions
@@ -1,29 +1,27 @@
//! Parser for `streamproperties.xml` + `playbackconfig.xml` — Criterion XML format.
//! Criterion Collection — `streamproperties.xml` + `playbackconfig.xml`
//!
//! Found at: `BDMV/JAR/*/streamproperties.xml` and `BDMV/JAR/*/playbackconfig.xml`
//!
//! streamproperties.xml defines stream info IDs with Content/Qualifier.
//! playbackconfig.xml maps StreamID (number) → StreamInfo_ID.
//! Clean structured XML with Content/Qualifier per stream and
//! stream number mapping via playbackconfig.
use crate::drive::DriveSession;
use crate::udf::{UdfFs, DirEntry};
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
use std::collections::HashMap;
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let jar_dir = udf.find_dir("/BDMV/JAR")?;
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "streamproperties.xml")
}
let sp_data = find_and_read(session, udf, jar_dir, "streamproperties.xml")?;
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
let sp_text = std::str::from_utf8(&sp_data).ok()?;
// Parse stream infos from streamproperties.xml
// Simple tag-based parsing (no full XML parser needed)
let stream_infos = parse_stream_infos(sp_text);
if stream_infos.is_empty() { return None; }
// Try to get playbackconfig.xml for stream number mapping
// Stream number mapping from playbackconfig.xml
let mut stream_map: HashMap<String, u16> = HashMap::new();
if let Some(pc_data) = find_and_read(session, udf, jar_dir, "playbackconfig.xml") {
if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") {
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
parse_playback_config(pc_text, &mut stream_map);
}
@@ -35,7 +33,6 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>
for info in &stream_infos {
let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| {
// Fallback: assign by order
match info.stream_type {
StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n }
StreamLabelType::Subtitle => { let n = sub_idx; sub_idx += 1; n }
@@ -46,11 +43,11 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>
stream_number: stream_num,
stream_type: info.stream_type,
language: info.language.clone(),
name: info.id.clone(),
name: String::new(),
purpose: info.purpose,
qualifier: info.qualifier,
codec_hint: String::new(),
region: info.region.clone(),
variant: info.variant.clone(),
});
}
@@ -62,16 +59,15 @@ struct StreamInfo {
id: String,
stream_type: StreamLabelType,
language: String,
region: String,
variant: String,
purpose: LabelPurpose,
qualifier: LabelQualifier,
}
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
let mut infos = Vec::new();
// Find <AudioStreamInfos>...</AudioStreamInfos> and <SubtitleStreamInfos>...</SubtitleStreamInfos>
let mut pos = 0;
while pos < xml.len() {
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
(p + pos, StreamLabelType::Audio)
@@ -92,14 +88,12 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
};
let block = &xml[tag..block_end];
let id = extract_tag(block, "ID").unwrap_or_default();
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
let content = extract_tag(block, "Content").unwrap_or_default();
let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default();
// Parse language and region from LangInfoID (e.g. "ENG_US")
let (language, region) = if lang_id.contains('_') {
let (language, variant) = if lang_id.contains('_') {
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
(parts[0].to_lowercase(), parts[1].to_string())
} else {
@@ -108,7 +102,7 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
let purpose = match content.as_str() {
"COMMENTARY" => LabelPurpose::Commentary,
"DIALOGUE" | _ => LabelPurpose::Normal,
_ => LabelPurpose::Normal,
};
let qualifier = match qualifier_str.as_str() {
@@ -117,19 +111,13 @@ fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
_ => LabelQualifier::None,
};
infos.push(StreamInfo {
id, stream_type, language, region, purpose, qualifier,
});
infos.push(StreamInfo { id, stream_type, language, variant, purpose, qualifier });
pos = block_end;
}
infos
}
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
// Find <AudioStreams> and <SubtitlesStreams> blocks
// Each has <StreamID>N</StreamID> and <StreamInfo_ID>STRA_xxx</StreamInfo_ID>
let mut pos = 0;
while pos < xml.len() {
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") {
@@ -145,7 +133,6 @@ fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
None => break,
};
// Find end of this block
let block_end = xml[tag_start..].find("</AudioStreams>")
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
.map(|p| tag_start + p + 20)
@@ -170,15 +157,3 @@ fn extract_tag(xml: &str, tag: &str) -> Option<String> {
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].trim().to_string())
}
fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option<Vec<u8>> {
for entry in &parent.entries {
if entry.is_dir {
let sub_path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
if let Ok(data) = udf.read_file(session, &sub_path) {
if !data.is_empty() { return Some(data); }
}
}
}
None
}
+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.
//!
//! Searches the disc UDF filesystem for known config files that contain
//! stream labels (language, purpose, codec, forced flags). Four formats
//! supported, tried in order. If found, labels are applied directly
//! to the title streams. If not found, streams keep MPLS data as-is.
//! Each parser module represents one BD-J authoring framework.
//! To add a new format:
//! 1. Create `src/labels/myformat.rs`
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
mod language_streams;
mod menu_base;
mod stream_properties;
mod bluray_project;
mod paramount;
mod criterion;
mod pixelogic;
mod ctrm;
pub mod vocab;
use crate::drive::DriveSession;
use crate::udf::UdfFs;
@@ -23,16 +26,16 @@ pub struct StreamLabel {
pub stream_type: StreamLabelType,
/// ISO 639-2 language code
pub language: String,
/// Display name (e.g. "AudioEnglishDolby", "English Dolby Atmos")
/// Display name (e.g. "Commentary", "Descriptive Audio")
pub name: String,
/// Stream purpose
pub purpose: LabelPurpose,
/// Additional qualifier
pub qualifier: LabelQualifier,
/// Codec hint from config (e.g. "MLP", "AC3", "atmos")
/// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos")
pub codec_hint: String,
/// Regional variant (e.g. "US", "UK", "CF", "CS")
pub region: String,
/// Regional variant (e.g. "US", "UK", "Castilian", "Canadian")
pub variant: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -58,8 +61,23 @@ pub enum LabelQualifier {
Forced,
}
// ── Parser registry ────────────────────────────────────────────────────────
//
// Each entry: (name, detect_fn, parse_fn)
// Order = priority. First match wins. Highest quality output first.
type DetectFn = fn(&UdfFs) -> bool;
type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>;
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
("paramount", paramount::detect, paramount::parse),
("criterion", criterion::detect, criterion::parse),
("pixelogic", pixelogic::detect, pixelogic::parse),
("ctrm", ctrm::detect, ctrm::parse),
// ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser
];
/// Search disc for config files, extract labels, apply to streams.
/// If no config files found, streams are left unchanged.
pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
let labels = extract(session, udf);
if labels.is_empty() { return; }
@@ -83,12 +101,10 @@ pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
LabelPurpose::Ime => parts.push("IME".to_string()),
LabelPurpose::Normal => {}
}
if !label.region.is_empty() {
parts.push(format!("({})", label.region));
if !label.variant.is_empty() {
parts.push(format!("({})", label.variant));
}
if !label.codec_hint.is_empty()
&& !matches!(label.codec_hint.as_str(), "MLP" | "AC3" | "DTS")
{
if !label.codec_hint.is_empty() {
parts.push(label.codec_hint.clone());
}
if !parts.is_empty() {
@@ -115,9 +131,42 @@ pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
}
fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
if let Some(labels) = language_streams::parse(session, udf) { return labels; }
if let Some(labels) = menu_base::parse(session, udf) { return labels; }
if let Some(labels) = stream_properties::parse(session, udf) { return labels; }
if let Some(labels) = bluray_project::parse(session, udf) { return labels; }
for (_name, detect, parse) in PARSERS {
if detect(udf) {
if let Some(labels) = parse(session, udf) {
return labels;
}
}
}
Vec::new()
}
// ── Shared helpers ─────────────────────────────────────────────────────────
/// Check if a file exists in any BDMV/JAR subdirectory.
pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool {
find_jar_file(udf, filename).is_some()
}
/// Find a file in any BDMV/JAR subdirectory, return its path.
pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
let jar_dir = udf.find_dir("/BDMV/JAR")?;
for entry in &jar_dir.entries {
if entry.is_dir {
let path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
// Check if file exists in this subdirectory
for child in &entry.entries {
if !child.is_dir && child.name.eq_ignore_ascii_case(filename) {
return Some(path);
}
}
}
}
None
}
/// Read a file from any BDMV/JAR subdirectory by filename.
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
let path = find_jar_file(udf, filename)?;
udf.read_file(session, &path).ok().filter(|d| !d.is_empty())
}
+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
//! under playlist sections. Tokens follow the format:
//! {lang}_{purpose?}_{codec?}_{region?}_
//!
//! Examples: eng_MLP_, eng_US_ADES_, eng_SDH_, fra_TXT_FOR_
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
use crate::drive::DriveSession;
use crate::udf::{UdfFs, DirEntry};
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
/// Known audio codec tokens
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV"];
/// Known audio purpose tokens
const AUDIO_PURPOSES: &[&str] = &["ADLG", "ACOM", "ADES", "ATRI"];
/// Known subtitle types
const SUB_TYPES: &[&str] = &["SDH", "SDLG", "SCOM", "STRI"];
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
/// Known region tokens
const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"];
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let jar_dir = udf.find_dir("/BDMV/JAR")?;
let data = find_and_read(session, udf, jar_dir, "bluray_project.bin")?;
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "bluray_project.bin")
}
// Extract all strings from the binary
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
let strings = extract_strings(&data);
// Find the main feature section — look for "FPL_MainFeature" or "SEG_MainFeature"
// then collect audio and subtitle tokens that follow
let mut labels = Vec::new();
let mut in_feature = false;
let mut audio_num: u16 = 0;
@@ -38,24 +30,20 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>
for s in &strings {
// Detect feature section start
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
if in_feature {
// Already found one — this is a second copy, skip
break;
}
if in_feature { break; }
in_feature = true;
audio_num = 0;
sub_num = 0;
continue;
}
// Detect section end (next playlist starts)
// Detect section end
if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
break;
}
if !in_feature { continue; }
// Try to parse as a stream token
if let Some(label) = parse_token(s) {
match label.stream_type {
StreamLabelType::Audio => {
@@ -74,13 +62,11 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>
Some(labels)
}
/// Parse a token string like "eng_MLP_" or "eng_US_ADES_" into a StreamLabel.
fn parse_token(s: &str) -> Option<StreamLabel> {
let clean = s.trim_end_matches('_');
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
let parts: Vec<&str> = clean.split('_').collect();
if parts.len() < 2 { return None; }
// First part must be 3-letter lowercase language code
let lang = parts[0];
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
return None;
@@ -89,26 +75,26 @@ fn parse_token(s: &str) -> Option<StreamLabel> {
let mut codec = String::new();
let mut purpose = LabelPurpose::Normal;
let mut qualifier = LabelQualifier::None;
let mut region = String::new();
let mut variant = String::new();
let mut is_subtitle = false;
let mut is_audio = false;
for &part in &parts[1..] {
if part.is_empty() { continue; }
if AUDIO_CODECS.contains(&part) { codec = part.to_string(); is_audio = true; }
if AUDIO_CODECS.contains(&part) { codec = vocab::codec(part).to_string(); is_audio = true; }
else if part == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; }
else if part == "ACOM" { purpose = LabelPurpose::Commentary; is_audio = true; }
else if part == "ADLG" { purpose = LabelPurpose::Normal; is_audio = true; }
else if part == "ATRI" { purpose = LabelPurpose::Normal; is_audio = true; }
else if part == "ADLG" { is_audio = true; }
else if part == "ATRI" { is_audio = true; }
else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; }
else if part == "SDLG" { is_subtitle = true; }
else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; }
else if part == "STRI" { is_subtitle = true; }
else if part == "TXT" { is_subtitle = true; }
else if part == "FOR" { qualifier = LabelQualifier::Forced; }
else if REGIONS.contains(&part) { region = part.to_string(); }
else if REGIONS.contains(&part) { variant = part.to_string(); }
else if part.starts_with("PGStream") { is_subtitle = true; }
else { return None; } // Unknown token — not a stream ID
else { return None; }
}
if !is_audio && !is_subtitle { return None; }
@@ -116,18 +102,17 @@ fn parse_token(s: &str) -> Option<StreamLabel> {
let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio };
Some(StreamLabel {
stream_number: 0, // caller sets this
stream_number: 0,
stream_type,
language: lang.to_string(),
name: s.to_string(),
name: String::new(),
purpose,
qualifier,
codec_hint: codec,
region,
variant,
})
}
/// Extract all printable strings > 3 chars from binary data.
fn extract_strings(data: &[u8]) -> Vec<String> {
let mut strings = Vec::new();
let mut current = String::new();
@@ -147,15 +132,3 @@ fn extract_strings(data: &[u8]) -> Vec<String> {
}
strings
}
fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option<Vec<u8>> {
for entry in &parent.entries {
if entry.is_dir {
let sub_path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
if let Ok(data) = udf.read_file(session, &sub_path) {
if !data.is_empty() { return Some(data); }
}
}
}
None
}
+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,
}
}