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 c6464107ff
commit 7f7a66e039
7 changed files with 714 additions and 2 deletions
+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
}