Add labels module: 4 disc file parsers for stream labels

src/labels/ with 4 parsers tried in order:
1. language_streams.txt (Warner CTRM CSV)
2. menu_base.prop (Warner CTRM properties)
3. streamproperties.xml + playbackconfig.xml (Criterion XML)
4. bluray_project.bin (Pixelogic binary tokens)

Disc::scan() calls labels::extract() → apply_disc_labels().
If no disc files found, streams keep MPLS data as-is.
No JAR fallback — disc files or nothing.

Covers 4/8 discs with JARs (Dunkirk UHD, V for Vendetta BD,
Being There, Barbie). Remaining 3 (Civil War, Dune, V for
Vendetta UHD) have no disc config files.
This commit is contained in:
MattJackson
2026-04-07 18:42:33 -07:00
parent 8a95787426
commit ba44f7d928
7 changed files with 714 additions and 2 deletions
+161
View File
@@ -0,0 +1,161 @@
//! Parser for `bluray_project.bin` — Pixelogic binary format.
//!
//! Found at: `BDMV/JAR/*/bluray_project.bin`
//!
//! Binary file with embedded UTF-8 strings. Stream tokens appear in STN order
//! under playlist sections. Tokens follow the format:
//! {lang}_{purpose?}_{codec?}_{region?}_
//!
//! Examples: eng_MLP_, eng_US_ADES_, eng_SDH_, fra_TXT_FOR_
use crate::drive::DriveSession;
use crate::udf::{UdfFs, DirEntry};
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
/// Known audio codec tokens
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV"];
/// Known audio purpose tokens
const AUDIO_PURPOSES: &[&str] = &["ADLG", "ACOM", "ADES", "ATRI"];
/// Known subtitle types
const SUB_TYPES: &[&str] = &["SDH", "SDLG", "SCOM", "STRI"];
/// Known region tokens
const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"];
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let jar_dir = udf.find_dir("/BDMV/JAR")?;
let data = find_and_read(session, udf, jar_dir, "bluray_project.bin")?;
// Extract all strings from the binary
let strings = extract_strings(&data);
// Find the main feature section — look for "FPL_MainFeature" or "SEG_MainFeature"
// then collect audio and subtitle tokens that follow
let mut labels = Vec::new();
let mut in_feature = false;
let mut audio_num: u16 = 0;
let mut sub_num: u16 = 0;
for s in &strings {
// Detect feature section start
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
if in_feature {
// Already found one — this is a second copy, skip
break;
}
in_feature = true;
audio_num = 0;
sub_num = 0;
continue;
}
// Detect section end (next playlist starts)
if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
break;
}
if !in_feature { continue; }
// Try to parse as a stream token
if let Some(label) = parse_token(s) {
match label.stream_type {
StreamLabelType::Audio => {
audio_num += 1;
labels.push(StreamLabel { stream_number: audio_num, ..label });
}
StreamLabelType::Subtitle => {
sub_num += 1;
labels.push(StreamLabel { stream_number: sub_num, ..label });
}
}
}
}
if labels.is_empty() { return None; }
Some(labels)
}
/// Parse a token string like "eng_MLP_" or "eng_US_ADES_" into a StreamLabel.
fn parse_token(s: &str) -> Option<StreamLabel> {
let clean = s.trim_end_matches('_');
let parts: Vec<&str> = clean.split('_').collect();
if parts.len() < 2 { return None; }
// First part must be 3-letter lowercase language code
let lang = parts[0];
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
let mut codec = String::new();
let mut purpose = LabelPurpose::Normal;
let mut qualifier = LabelQualifier::None;
let mut region = String::new();
let mut is_subtitle = false;
let mut is_audio = false;
for &part in &parts[1..] {
if part.is_empty() { continue; }
if AUDIO_CODECS.contains(&part) { codec = part.to_string(); is_audio = true; }
else if part == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; }
else if part == "ACOM" { purpose = LabelPurpose::Commentary; is_audio = true; }
else if part == "ADLG" { purpose = LabelPurpose::Normal; is_audio = true; }
else if part == "ATRI" { purpose = LabelPurpose::Normal; is_audio = true; }
else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; }
else if part == "SDLG" { is_subtitle = true; }
else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; }
else if part == "STRI" { is_subtitle = true; }
else if part == "TXT" { is_subtitle = true; }
else if part == "FOR" { qualifier = LabelQualifier::Forced; }
else if REGIONS.contains(&part) { region = part.to_string(); }
else if part.starts_with("PGStream") { is_subtitle = true; }
else { return None; } // Unknown token — not a stream ID
}
if !is_audio && !is_subtitle { return None; }
let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio };
Some(StreamLabel {
stream_number: 0, // caller sets this
stream_type,
language: lang.to_string(),
name: s.to_string(),
purpose,
qualifier,
codec_hint: codec,
region,
})
}
/// Extract all printable strings > 3 chars from binary data.
fn extract_strings(data: &[u8]) -> Vec<String> {
let mut strings = Vec::new();
let mut current = String::new();
for &b in data {
if b >= 0x20 && b < 0x7f {
current.push(b as char);
} else {
if current.len() > 3 {
strings.push(current.clone());
}
current.clear();
}
}
if current.len() > 3 {
strings.push(current);
}
strings
}
fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option<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
}
+88
View File
@@ -0,0 +1,88 @@
//! Parser for `language_streams.txt` — Warner CTRM CSV format.
//!
//! Found at: `BDMV/JAR/*/language_streams.txt`
//!
//! Format:
//! ```text
//! playlist_id, type, stream_num, language, variant
//! 100, audio_production, 1, eng, atmos
//! 100, subtitle_narrative, 7, fra,
//! ```
use crate::drive::DriveSession;
use crate::udf::{UdfFs, DirEntry};
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<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
@@ -0,0 +1,129 @@
//! Parser for `menu_base.prop` — Warner CTRM properties format.
//!
//! Found at: `BDMV/JAR/*/menu_base.prop`
//!
//! Format:
//! ```text
//! audio_en.name=AudioEnglish
//! audio_en.audioStream=1
//! audio_en.audioLanguage=eng
//! subtitle_en_sdh.name=SubtitleEnglishSDH
//! subtitle_en_sdh.subtitleStream=1
//! subtitle_en_sdh.subtitleLanguage=eng
//! ```
use crate::drive::DriveSession;
use crate::udf::{UdfFs, DirEntry};
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
use std::collections::HashMap;
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<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
}
+88
View File
@@ -0,0 +1,88 @@
//! Stream label extraction from BD-J disc files.
//!
//! Searches the disc UDF filesystem for known config files that contain
//! stream labels (language, purpose, codec, forced flags). Four formats
//! supported, tried in order:
//!
//! 1. `language_streams.txt` — Warner CTRM CSV format
//! 2. `menu_base.prop` — Warner CTRM properties format
//! 3. `streamproperties.xml` + `playbackconfig.xml` — Criterion XML format
//! 4. `bluray_project.bin` — Pixelogic binary format
mod language_streams;
mod menu_base;
mod stream_properties;
mod bluray_project;
use crate::drive::DriveSession;
use crate::udf::UdfFs;
/// A stream label extracted from disc config files.
#[derive(Debug, Clone)]
pub struct StreamLabel {
/// STN index (1-based)
pub stream_number: u16,
/// Audio or Subtitle
pub stream_type: StreamLabelType,
/// ISO 639-2 language code
pub language: String,
/// Display name (e.g. "AudioEnglishDolby", "English Dolby Atmos")
pub name: String,
/// Stream purpose
pub purpose: LabelPurpose,
/// Additional qualifier
pub qualifier: LabelQualifier,
/// Codec hint from config (e.g. "MLP", "AC3", "atmos")
pub codec_hint: String,
/// Regional variant (e.g. "US", "UK", "CF", "CS")
pub region: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StreamLabelType {
Audio,
Subtitle,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LabelPurpose {
/// Normal dialogue track
Normal,
/// Audio commentary
Commentary,
/// Descriptive audio (visually impaired)
Descriptive,
/// Music score only
Score,
/// In-movie experience
Ime,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LabelQualifier {
None,
/// Subtitles for deaf and hard of hearing
Sdh,
/// Descriptive service
DescriptiveService,
/// Forced/narrative subtitle
Forced,
}
/// Try all parsers in order, return first successful result.
pub fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
// Try each format in order
if let Some(labels) = language_streams::parse(session, udf) {
return labels;
}
if let Some(labels) = menu_base::parse(session, udf) {
return labels;
}
if let Some(labels) = stream_properties::parse(session, udf) {
return labels;
}
if let Some(labels) = bluray_project::parse(session, udf) {
return labels;
}
Vec::new()
}
+184
View File
@@ -0,0 +1,184 @@
//! Parser for `streamproperties.xml` + `playbackconfig.xml` — Criterion XML format.
//!
//! Found at: `BDMV/JAR/*/streamproperties.xml` and `BDMV/JAR/*/playbackconfig.xml`
//!
//! streamproperties.xml defines stream info IDs with Content/Qualifier.
//! playbackconfig.xml maps StreamID (number) → StreamInfo_ID.
use crate::drive::DriveSession;
use crate::udf::{UdfFs, DirEntry};
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
use std::collections::HashMap;
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let jar_dir = udf.find_dir("/BDMV/JAR")?;
let sp_data = find_and_read(session, udf, jar_dir, "streamproperties.xml")?;
let sp_text = std::str::from_utf8(&sp_data).ok()?;
// Parse stream infos from streamproperties.xml
// Simple tag-based parsing (no full XML parser needed)
let stream_infos = parse_stream_infos(sp_text);
if stream_infos.is_empty() { return None; }
// Try to get playbackconfig.xml for stream number mapping
let mut stream_map: HashMap<String, u16> = HashMap::new();
if let Some(pc_data) = find_and_read(session, udf, jar_dir, "playbackconfig.xml") {
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
parse_playback_config(pc_text, &mut stream_map);
}
}
let mut labels = Vec::new();
let mut audio_idx: u16 = 1;
let mut sub_idx: u16 = 1;
for info in &stream_infos {
let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| {
// Fallback: assign by order
match info.stream_type {
StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n }
StreamLabelType::Subtitle => { let n = sub_idx; sub_idx += 1; n }
}
});
labels.push(StreamLabel {
stream_number: stream_num,
stream_type: info.stream_type,
language: info.language.clone(),
name: info.id.clone(),
purpose: info.purpose,
qualifier: info.qualifier,
codec_hint: String::new(),
region: info.region.clone(),
});
}
if labels.is_empty() { return None; }
Some(labels)
}
struct StreamInfo {
id: String,
stream_type: StreamLabelType,
language: String,
region: String,
purpose: LabelPurpose,
qualifier: LabelQualifier,
}
fn parse_stream_infos(xml: &str) -> Vec<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)
} else if let Some(p) = xml[pos..].find("<SubtitleStreamInfos>") {
(p + pos, StreamLabelType::Subtitle)
} else {
break;
};
let end_tag = match stream_type {
StreamLabelType::Audio => "</AudioStreamInfos>",
StreamLabelType::Subtitle => "</SubtitleStreamInfos>",
};
let block_end = match xml[tag..].find(end_tag) {
Some(p) => tag + p + end_tag.len(),
None => break,
};
let block = &xml[tag..block_end];
let id = extract_tag(block, "ID").unwrap_or_default();
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
let content = extract_tag(block, "Content").unwrap_or_default();
let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default();
// Parse language and region from LangInfoID (e.g. "ENG_US")
let (language, region) = if lang_id.contains('_') {
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
(parts[0].to_lowercase(), parts[1].to_string())
} else {
(lang_id.to_lowercase(), String::new())
};
let purpose = match content.as_str() {
"COMMENTARY" => LabelPurpose::Commentary,
"DIALOGUE" | _ => LabelPurpose::Normal,
};
let qualifier = match qualifier_str.as_str() {
"SDH" => LabelQualifier::Sdh,
"DS" => LabelQualifier::DescriptiveService,
_ => LabelQualifier::None,
};
infos.push(StreamInfo {
id, stream_type, language, region, purpose, qualifier,
});
pos = block_end;
}
infos
}
fn parse_playback_config(xml: &str, map: &mut HashMap<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>") {
Some(p + pos)
} else if let Some(p) = xml[pos..].find("<SubtitlesStreams>") {
Some(p + pos)
} else {
None
};
let tag_start = match tag_start {
Some(p) => p,
None => break,
};
// Find end of this block
let block_end = xml[tag_start..].find("</AudioStreams>")
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
.map(|p| tag_start + p + 20)
.unwrap_or(xml.len());
let block = &xml[tag_start..block_end];
if let (Some(stream_id_str), Some(info_id)) = (extract_tag(block, "StreamID"), extract_tag(block, "StreamInfo_ID")) {
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
map.insert(info_id, stream_num);
}
}
pos = block_end;
}
}
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].trim().to_string())
}
fn find_and_read(session: &mut DriveSession, udf: &UdfFs, parent: &DirEntry, filename: &str) -> Option<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
}