diff --git a/Cargo.toml b/Cargo.toml index beb018c..f4af2bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ sha1 = "0.10" aes = "0.8" cbc = "0.1" flate2 = "1" +zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" diff --git a/src/jar.rs b/src/jar.rs new file mode 100644 index 0000000..a7d7524 --- /dev/null +++ b/src/jar.rs @@ -0,0 +1,263 @@ +//! BD-J JAR parser — extract audio/subtitle track labels from disc menus. +//! +//! Blu-ray discs with BD-J menus store the menu application as Java JAR files +//! in BDMV/JAR/. These contain .class files with string constants that label +//! audio and subtitle tracks (e.g. "eng_ADES_US_" = Descriptive Audio US). +//! +//! with no way to tell them apart (e.g. 3x "AC-3 5.1 English"). +//! +//! The JAR is a ZIP file. Each .class file has a Java constant pool containing +//! UTF-8 string literals. We scan for patterns like: +//! {lang}_{codec}_{variant}_ → audio track labels +//! {lang}_PGStream{n} → subtitle track labels +//! MAIN_FEATURE → playlist identification +//! FORCED_TRAILER → forced content identification +//! +//! This is best-effort: if the JAR doesn't contain labels or the format +//! is unexpected, we return empty results. The caller falls back to +//! showing streams without labels. + +use std::io::Read; + +/// Labels extracted from a BD-J JAR file. +#[derive(Debug, Default)] +pub struct JarLabels { + /// Audio track labels in STN order: (language, codec_hint, variant, raw_label) + pub audio: Vec, + /// Subtitle track labels: (language, stream_index, raw_label) + pub subtitle: Vec, + /// Playlist purpose labels (MAIN_FEATURE, FORCED_TRAILER, etc.) + pub playlists: Vec, +} + +/// A parsed track label from the JAR. +#[derive(Debug, Clone)] +pub struct TrackLabel { + /// ISO 639-2 language code (e.g. "eng", "fra") + pub language: String, + /// Codec or type hint (e.g. "MLP", "AC3", "ADES", "PGStream") + pub hint: String, + /// Variant or region (e.g. "US", "UK", "3", "4") + pub variant: String, + /// Human-readable description derived from the label + pub description: String, + /// The raw string from the class file + pub raw: String, +} + +impl TrackLabel { + /// Parse a label string like "eng_ADES_US_" or "dan_PGStream4" + fn parse(s: &str) -> Option { + let clean = s.trim_end_matches('_'); + let parts: Vec<&str> = clean.splitn(3, '_').collect(); + if parts.len() < 2 { + return None; + } + + let language = parts[0].to_string(); + // Language should be 2-3 lowercase letters + if language.len() < 2 || language.len() > 3 || !language.chars().all(|c| c.is_ascii_lowercase()) { + return None; + } + + let hint = parts[1].to_string(); + let variant = if parts.len() > 2 { parts[2].to_string() } else { String::new() }; + + let description = match hint.as_str() { + "MLP" => "TrueHD".to_string(), + "AC3" => { + if variant.is_empty() { "compatibility".to_string() } + else { variant.clone() } + } + "DTS" => "DTS".to_string(), + "LPCM" => "LPCM".to_string(), + "ADES" => { + if variant.is_empty() { "Descriptive Audio".to_string() } + else { format!("Descriptive Audio ({})", variant) } + } + h if h.starts_with("AudioStream") => String::new(), // generic, no extra info + h if h.starts_with("PGStream") => String::new(), // generic subtitle + _ => String::new(), + }; + + Some(TrackLabel { + language, + hint, + variant, + description, + raw: s.to_string(), + }) + } + + /// Is this an audio track label? + fn is_audio(&self) -> bool { + matches!(self.hint.as_str(), "MLP" | "AC3" | "DTS" | "LPCM" | "ADES") + || self.hint.starts_with("AudioStream") + } + + /// Is this a subtitle track label? + fn is_subtitle(&self) -> bool { + self.hint.starts_with("PGStream") + } +} + +/// Extract track labels from a JAR file (raw ZIP bytes). +/// +/// Returns None if the JAR can't be parsed or contains no labels. +/// This is best-effort — failure is not an error. +pub fn extract_labels(jar_data: &[u8]) -> Option { + let cursor = std::io::Cursor::new(jar_data); + let mut archive = zip::ZipArchive::new(cursor).ok()?; + + let mut all_audio = Vec::new(); + let mut all_subtitle = Vec::new(); + let mut all_playlists = Vec::new(); + + for i in 0..archive.len() { + let mut file = archive.by_index(i).ok()?; + if !file.name().ends_with(".class") { + continue; + } + + let mut data = Vec::new(); + file.read_to_end(&mut data).ok()?; + + let strings = extract_class_strings(&data); + + for s in &strings { + // Track labels: {lang}_{type}_{variant}_ + if let Some(label) = TrackLabel::parse(s) { + if label.is_audio() && !all_audio.iter().any(|a: &TrackLabel| a.raw == label.raw) { + all_audio.push(label); + } else if label.is_subtitle() && !all_subtitle.iter().any(|a: &TrackLabel| a.raw == label.raw) { + all_subtitle.push(label); + } + } + + // Playlist purpose labels + if matches!(s.as_str(), + "MAIN_FEATURE" | "MAIN_FEATURE_INTRO" | + "FORCED_TRAILER" | "INTL_FORCED_TRAILER" | + "commentary_extras" | "extras" + ) { + if !all_playlists.contains(s) { + all_playlists.push(s.clone()); + } + } + } + } + + if all_audio.is_empty() && all_subtitle.is_empty() { + return None; + } + + Some(JarLabels { + audio: all_audio, + subtitle: all_subtitle, + playlists: all_playlists, + }) +} + +/// Extract all UTF-8 string constants from a Java .class file's constant pool. +/// +/// Java class file format: +/// [0:4] magic (0xCAFEBABE) +/// [4:6] minor version +/// [6:8] major version +/// [8:10] constant_pool_count +/// [10:] constant_pool entries +/// +/// CONSTANT_Utf8 (tag=1): u8 tag + u16 length + bytes +/// We only extract these — they contain all string literals. +fn extract_class_strings(data: &[u8]) -> Vec { + let mut strings = Vec::new(); + + // Verify Java class magic + if data.len() < 10 || &data[0..4] != &[0xCA, 0xFE, 0xBA, 0xBE] { + return strings; + } + + let cp_count = ((data[8] as u16) << 8 | data[9] as u16) as usize; + let mut pos = 10; + + // Parse constant pool entries + let mut entry = 1; // constant pool is 1-indexed + while entry < cp_count && pos < data.len() { + let tag = data[pos]; + pos += 1; + + match tag { + // CONSTANT_Utf8 + 1 => { + if pos + 2 > data.len() { break; } + let len = ((data[pos] as usize) << 8) | data[pos + 1] as usize; + pos += 2; + if pos + len > data.len() { break; } + + if let Ok(s) = std::str::from_utf8(&data[pos..pos + len]) { + // Only keep strings that look like track labels + // Must contain underscore and be reasonable length + if s.len() >= 5 && s.len() <= 100 && s.contains('_') { + strings.push(s.to_string()); + } + } + pos += len; + } + // CONSTANT_Integer, CONSTANT_Float + 3 | 4 => { pos += 4; } + // CONSTANT_Long, CONSTANT_Double (take 2 entries) + 5 | 6 => { pos += 8; entry += 1; } + // CONSTANT_Class, CONSTANT_String, CONSTANT_MethodType + 7 | 8 | 16 => { pos += 2; } + // CONSTANT_Fieldref, CONSTANT_Methodref, CONSTANT_InterfaceMethodref, + // CONSTANT_NameAndType, CONSTANT_InvokeDynamic + 9 | 10 | 11 | 12 | 18 => { pos += 4; } + // CONSTANT_MethodHandle + 15 => { pos += 3; } + // Unknown tag — can't continue parsing safely + _ => { break; } + } + entry += 1; + } + + strings +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_audio_labels() { + let l = TrackLabel::parse("eng_MLP_").unwrap(); + assert_eq!(l.language, "eng"); + assert_eq!(l.hint, "MLP"); + assert_eq!(l.description, "TrueHD"); + assert!(l.is_audio()); + + let l = TrackLabel::parse("eng_ADES_US_").unwrap(); + assert_eq!(l.language, "eng"); + assert_eq!(l.hint, "ADES"); + assert_eq!(l.variant, "US"); + assert_eq!(l.description, "Descriptive Audio (US)"); + assert!(l.is_audio()); + + let l = TrackLabel::parse("fra_AudioStream3").unwrap(); + assert_eq!(l.language, "fra"); + assert!(l.is_audio()); + } + + #[test] + fn test_parse_subtitle_labels() { + let l = TrackLabel::parse("dan_PGStream4").unwrap(); + assert_eq!(l.language, "dan"); + assert!(l.is_subtitle()); + } + + #[test] + fn test_reject_non_labels() { + assert!(TrackLabel::parse("substring").is_none()); + assert!(TrackLabel::parse("equals").is_none()); + assert!(TrackLabel::parse("Code").is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 84c4bd5..4adafa8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,7 @@ pub mod udf; pub mod mpls; pub mod clpi; pub mod disc; +pub mod jar; pub use error::{Error, Result}; pub use drive::DriveSession;