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:
MattJackson
2026-04-07 20:29:44 -07:00
parent aa188751e4
commit 0bb815ca22
15 changed files with 547 additions and 499 deletions
+32 -154
View File
@@ -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(),