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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user