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:
+32
-154
@@ -34,8 +34,8 @@ pub struct Disc {
|
||||
pub layers: u8,
|
||||
/// Titles sorted by duration (longest first), then playlist name
|
||||
pub titles: Vec<Title>,
|
||||
/// JAR track labels (audio/subtitle names from BD-J menus)
|
||||
pub jar_labels: crate::jar::JarLabels,
|
||||
/// Disc region
|
||||
pub region: DiscRegion,
|
||||
/// AACS state — None if disc is unencrypted or keys unavailable
|
||||
pub aacs: Option<AacsState>,
|
||||
/// Whether this disc requires AACS decryption
|
||||
@@ -55,6 +55,28 @@ pub enum DiscFormat {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Disc playback region.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DiscRegion {
|
||||
/// Region-free (all UHD discs, some BD/DVD)
|
||||
Free,
|
||||
/// Blu-ray regions (A/B/C or combination)
|
||||
BluRay(Vec<BdRegion>),
|
||||
/// DVD regions (1-8 or combination)
|
||||
Dvd(Vec<u8>),
|
||||
}
|
||||
|
||||
/// Blu-ray region codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BdRegion {
|
||||
/// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia)
|
||||
A,
|
||||
/// Region B/2 — Europe, Africa, Australia, Middle East
|
||||
B,
|
||||
/// Region C/3 — Central/South Asia, China, Russia
|
||||
C,
|
||||
}
|
||||
|
||||
/// A title (one MPLS playlist).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Title {
|
||||
@@ -424,9 +446,6 @@ impl Disc {
|
||||
// Step 5: Enhance streams with disc config file labels (if available)
|
||||
crate::labels::apply(session, &udf_fs, &mut titles);
|
||||
|
||||
// JAR labels (for playlist purpose markers only, not stream labels)
|
||||
let jar_labels = Self::read_jar_labels(session, &udf_fs);
|
||||
|
||||
// Step 6: Detect AACS encryption
|
||||
let encrypted = udf_fs.find_dir("/AACS").is_some()
|
||||
|| udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
@@ -454,6 +473,13 @@ impl Disc {
|
||||
// BD-66/100 UHD: 25M+ sectors
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
|
||||
// UHD is always region-free. BD/DVD region parsing TODO.
|
||||
let region = if format == DiscFormat::Uhd {
|
||||
DiscRegion::Free
|
||||
} else {
|
||||
DiscRegion::Free // TODO: parse from index.bdmv
|
||||
};
|
||||
|
||||
Ok(Disc {
|
||||
volume_id: udf_fs.volume_id.clone(),
|
||||
meta_title: meta_title,
|
||||
@@ -462,7 +488,7 @@ impl Disc {
|
||||
capacity_bytes: capacity as u64 * 2048,
|
||||
layers,
|
||||
titles,
|
||||
jar_labels,
|
||||
region,
|
||||
aacs,
|
||||
encrypted,
|
||||
})
|
||||
@@ -581,125 +607,6 @@ impl Disc {
|
||||
DiscFormat::Unknown
|
||||
}
|
||||
|
||||
/// Merge JAR labels into title streams.
|
||||
///
|
||||
/// Two matching strategies:
|
||||
/// 1. By language+codec (label format like eng_MLP_) — matches stream by content
|
||||
/// 2. By index (TextField format) — Nth label → Nth stream
|
||||
/// Apply disc config file labels to streams by stream number.
|
||||
/// Matches by STN index — label stream_number N → Nth audio/subtitle stream.
|
||||
fn apply_disc_labels(titles: &mut [Title], labels: &[crate::labels::StreamLabel]) {
|
||||
use crate::labels::{StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
|
||||
for title in titles.iter_mut() {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for stream in &mut title.streams {
|
||||
match stream {
|
||||
Stream::Audio(a) => {
|
||||
audio_idx += 1;
|
||||
// Find label with matching stream number
|
||||
if let Some(label) = labels.iter().find(|l|
|
||||
l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
|
||||
) {
|
||||
// Build label string from purpose + region
|
||||
let mut parts = Vec::new();
|
||||
match label.purpose {
|
||||
LabelPurpose::Commentary => parts.push("Commentary".to_string()),
|
||||
LabelPurpose::Descriptive => parts.push("Descriptive Audio".to_string()),
|
||||
LabelPurpose::Score => parts.push("Score".to_string()),
|
||||
LabelPurpose::Ime => parts.push("IME".to_string()),
|
||||
LabelPurpose::Normal => {}
|
||||
}
|
||||
if !label.region.is_empty() { parts.push(format!("({})", label.region)); }
|
||||
if !label.codec_hint.is_empty() && label.codec_hint != "MLP" && label.codec_hint != "AC3" {
|
||||
parts.push(label.codec_hint.clone());
|
||||
}
|
||||
if !label.name.is_empty() && parts.is_empty() {
|
||||
a.label = label.name.clone();
|
||||
} else if !parts.is_empty() {
|
||||
a.label = parts.join(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
Stream::Subtitle(s) => {
|
||||
sub_idx += 1;
|
||||
if let Some(label) = labels.iter().find(|l|
|
||||
l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
|
||||
) {
|
||||
if label.qualifier == LabelQualifier::Sdh {
|
||||
// Set a label for SDH
|
||||
s.forced = false;
|
||||
// TODO: add sdh field to SubtitleStream
|
||||
}
|
||||
if label.qualifier == LabelQualifier::Forced {
|
||||
s.forced = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_jar_labels(titles: &mut [Title], jar: &crate::jar::JarLabels) {
|
||||
if jar.audio.is_empty() && jar.subtitle.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if labels have language+codec info (label format)
|
||||
let has_content_match = jar.audio.iter().any(|l| !l.language.is_empty() && !l.codec_hint.is_empty());
|
||||
|
||||
for title in titles.iter_mut() {
|
||||
if has_content_match {
|
||||
// Match by language + codec
|
||||
Self::apply_labels_by_content(title, jar);
|
||||
} else {
|
||||
// Match by index
|
||||
Self::apply_labels_by_index(title, jar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Match labels to streams by language + codec hint.
|
||||
fn apply_labels_by_content(title: &mut Title, jar: &crate::jar::JarLabels) {
|
||||
// For each JAR audio label, find the matching stream
|
||||
let mut used = vec![false; jar.audio.len()];
|
||||
|
||||
for stream in &mut title.streams {
|
||||
if let Stream::Audio(a) = stream {
|
||||
// Find a JAR label matching this stream's language + codec
|
||||
for (i, label) in jar.audio.iter().enumerate() {
|
||||
if used[i] { continue; }
|
||||
if label.language == a.language && codec_matches(&label.codec_hint, a.codec) {
|
||||
if !label.description.is_empty() {
|
||||
a.label = label.description.clone();
|
||||
}
|
||||
used[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Match labels to streams by STN index position.
|
||||
fn apply_labels_by_index(title: &mut Title, jar: &crate::jar::JarLabels) {
|
||||
let mut audio_idx = 0;
|
||||
for stream in &mut title.streams {
|
||||
if let Stream::Audio(a) = stream {
|
||||
if let Some(label) = jar.audio.get(audio_idx) {
|
||||
if !label.description.is_empty() {
|
||||
a.label = label.description.clone();
|
||||
}
|
||||
}
|
||||
audio_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
|
||||
/// Prefers English, falls back to first available language.
|
||||
/// Returns None if META directory is empty or XML has no usable title.
|
||||
@@ -736,23 +643,6 @@ impl Disc {
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract track labels from BD-J JAR files.
|
||||
fn read_jar_labels(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> crate::jar::JarLabels {
|
||||
if let Some(jar_dir) = udf_fs.find_dir("/BDMV/JAR") {
|
||||
for entry in &jar_dir.entries {
|
||||
if !entry.is_dir && entry.name.to_lowercase().ends_with(".jar") {
|
||||
let path = format!("/BDMV/JAR/{}", entry.name);
|
||||
if let Ok(jar_data) = udf_fs.read_file(session, &path) {
|
||||
if let Some(labels) = crate::jar::extract_labels(&jar_data) {
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::jar::JarLabels::default()
|
||||
}
|
||||
|
||||
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
||||
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut buf = [0u8; 8];
|
||||
@@ -980,18 +870,6 @@ fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048
|
||||
|
||||
// ─── Format helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a JAR codec hint matches a stream codec.
|
||||
fn codec_matches(hint: &str, codec: Codec) -> bool {
|
||||
match hint {
|
||||
"MLP" => codec == Codec::TrueHd,
|
||||
"AC3" => codec == Codec::Ac3 || codec == Codec::Ac3Plus,
|
||||
"DTS" => codec == Codec::Dts || codec == Codec::DtsHdMa || codec == Codec::DtsHdHr,
|
||||
"LPCM" => codec == Codec::Lpcm,
|
||||
"ADES" => codec == Codec::Ac3, // descriptive audio is usually DD
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_resolution(video_format: u8, _video_rate: u8) -> String {
|
||||
match video_format {
|
||||
1 => "480i".into(),
|
||||
|
||||
@@ -163,6 +163,22 @@ impl DriveSession {
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// Eject the disc tray.
|
||||
///
|
||||
/// Sends PREVENT ALLOW MEDIUM REMOVAL (allow) first to release any
|
||||
/// locks, then START STOP UNIT with LoEj=1 to open the tray.
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
// PREVENT ALLOW MEDIUM REMOVAL: allow removal
|
||||
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(&allow_cdb, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
|
||||
// START STOP UNIT: LoEj=1, Start=0
|
||||
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
|
||||
self.scsi.as_mut().execute(&eject_cdb, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
|
||||
pub fn scsi_execute(
|
||||
&mut self,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@ pub mod udf;
|
||||
pub mod mpls;
|
||||
pub mod clpi;
|
||||
pub mod disc;
|
||||
pub mod jar;
|
||||
pub mod aacs;
|
||||
pub mod labels;
|
||||
|
||||
|
||||
@@ -116,4 +116,5 @@ impl ScsiTransport for SgIoTransport {
|
||||
sense,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -368,6 +368,7 @@ impl ScsiTransport for MacScsiTransport {
|
||||
sense,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── IOKit service discovery ─────────────────────────────────────────────────
|
||||
|
||||
@@ -56,6 +56,7 @@ pub trait ScsiTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
}
|
||||
|
||||
// ── Platform-agnostic open ──────────────────────────────────────────────────
|
||||
|
||||
+6
-11
@@ -141,30 +141,25 @@ impl UdfFs {
|
||||
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
|
||||
for child in &entry.entries {
|
||||
if child.is_dir {
|
||||
let name_upper = child.name.to_uppercase();
|
||||
if matches!(name_upper.as_str(), "STREAM" | "BACKUP" | "DUPLICATE") {
|
||||
// Only skip STREAM — those are the multi-GB video files
|
||||
if child.name.eq_ignore_ascii_case("STREAM") {
|
||||
continue;
|
||||
}
|
||||
self.collect_file_ranges(session, child, ranges)?;
|
||||
} else {
|
||||
let name_upper = child.name.to_uppercase();
|
||||
// Include the ICB sector itself (in metadata partition)
|
||||
ranges.push((self.meta_to_abs(child.meta_lba), 1));
|
||||
|
||||
// Skip large files we don't need for disc-info:
|
||||
// MKB_RO.inf (134MB), ContentHash*.tbl (1MB), ContentRevocation (1MB)
|
||||
// Everything disc-info reads is under 3MB.
|
||||
if child.size > 10_000_000 {
|
||||
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
|
||||
if child.size > 50_000_000 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Include everything else (mpls, clpi, jar, bdmv, cer, cci, xml, png, inf)
|
||||
if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
|
||||
let abs_start = self.partition_start + data_lba;
|
||||
let sector_count = (data_len + 2047) / 2048;
|
||||
ranges.push((abs_start, sector_count));
|
||||
}
|
||||
|
||||
// Also include the ICB sector itself (in metadata partition)
|
||||
ranges.push((self.meta_to_abs(child.meta_lba), 1));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user