mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7243a6044 | ||
|
|
f1926c38dc | ||
|
|
4709a73c80 | ||
|
|
55bd1ee868 | ||
|
|
5285e8b6ec | ||
|
|
c5af61e46b | ||
|
|
ed73c1ab68 | ||
|
|
5ce2df0888 | ||
|
|
ea591a36a5 | ||
|
|
368e10486e | ||
|
|
a99c4f8487 | ||
|
|
4df470e572 | ||
|
|
a0b13941fe | ||
|
|
275c9da009 | ||
|
|
da183722fa | ||
|
|
4a5424eabe | ||
|
|
293d89c586 | ||
|
|
4840f5134d | ||
|
|
11c8211605 | ||
|
|
1038beaf06 | ||
|
|
f1df57196c | ||
|
|
8ba9dc1c0b | ||
|
|
49b131f39a | ||
|
|
8238ec4ce7 | ||
|
|
4fd5d27df8 | ||
|
|
1fbe272832 | ||
|
|
6bd635725e | ||
|
|
09b77b4dea | ||
|
|
688058b3e8 | ||
|
|
c96bac7977 | ||
|
|
c32acff3e4 | ||
|
|
b58e2d9873 | ||
|
|
a9e802c1c2 | ||
|
|
a876ce846b | ||
|
|
058fd8396f | ||
|
|
5ee28c08b9 | ||
|
|
764230b1eb | ||
|
|
7fb2e07aed | ||
|
|
376aadb335 | ||
|
|
586495b2ee | ||
|
|
72f2224efe | ||
|
|
0bca7a11bd | ||
|
|
72ab714c1e | ||
|
|
d7fb1b35ed | ||
|
|
06be4defd2 | ||
|
|
a832bad697 | ||
|
|
26e2d847d5 | ||
|
|
4442fa2df6 | ||
|
|
23dc55661c | ||
|
|
fbdb50c79f | ||
|
|
7cc74f0087 | ||
|
|
3aa1e528c5 | ||
|
|
7c6b0f82ab | ||
|
|
4226a53e73 | ||
|
|
92f34a289e | ||
|
|
fdbe469d50 | ||
|
|
4ec75a03f2 | ||
|
|
99236ffd55 | ||
|
|
9e3327a569 | ||
|
|
0081955686 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.18.7"
|
||||
version = "0.20.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -247,6 +247,7 @@ fn validate_processing_key(
|
||||
/// Find Verify Media Key Record (type 0x10) in MKB.
|
||||
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
let mut pos = 0;
|
||||
let mut type10_seen: Vec<(usize, usize)> = Vec::new();
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
@@ -254,14 +255,32 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x10 {
|
||||
type10_seen.push((pos, rec_len));
|
||||
}
|
||||
|
||||
if rec_type == 0x10 && rec_len >= 20 {
|
||||
// mk_dv is at offset 4 (after record header)
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
pos,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
return Some(dv);
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
type10_seen = ?type10_seen,
|
||||
scanned_bytes = pos,
|
||||
"no 0x10 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
@@ -628,32 +647,70 @@ pub fn resolve_keys(
|
||||
}
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_start",
|
||||
aacs2,
|
||||
bus_encryption,
|
||||
disc_hash = %hash_hex,
|
||||
mkb_present = mkb_data.is_some(),
|
||||
"resolve_keys: starting"
|
||||
);
|
||||
|
||||
// Path 1: Look up VUK by disc hash in KEYDB
|
||||
if let Some(entry) = keydb.find_disc(&hash_hex) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
|
||||
if let Some(vuk) = entry.vuk {
|
||||
return Some(build(vuk, 1));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_no_vuk", "disc hash entry has no VUK");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
|
||||
}
|
||||
|
||||
// Path 2: Find entry with matching VID → derive VUK from MK + VID
|
||||
let mut path2_mk_did_count = 0usize;
|
||||
for entry in keydb.disc_entries.values() {
|
||||
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||
path2_mk_did_count += 1;
|
||||
if did == *volume_id {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
|
||||
|
||||
// Path 3: MKB + processing keys → media key → VUK
|
||||
if let Some(mkb) = mkb_data {
|
||||
let mk_dv = mkb_find_mk_dv(mkb);
|
||||
let subdiff = mkb_find_subdiff_records(mkb);
|
||||
let cvalues = mkb_find_cvalues(mkb);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_mkb_records",
|
||||
mk_dv_found = mk_dv.is_some(),
|
||||
subdiff_found = subdiff.is_some(),
|
||||
subdiff_len = subdiff.as_ref().map(|s| s.len()).unwrap_or(0),
|
||||
cvalues_found = cvalues.is_some(),
|
||||
cvalues_len = cvalues.as_ref().map(|c| c.len()).unwrap_or(0),
|
||||
"MKB record scan results"
|
||||
);
|
||||
|
||||
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 3));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = keydb.processing_keys.len(), "PK derivation failed");
|
||||
|
||||
// Path 4: MKB + device keys → processing key → media key → VUK
|
||||
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 4));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = keydb.device_keys.len(), "DK derivation failed");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
|
||||
}
|
||||
|
||||
None
|
||||
|
||||
+158
-1
@@ -20,6 +20,35 @@ pub struct ClipInfo {
|
||||
pub ep_coarse: Vec<EpCoarse>,
|
||||
/// Fine EP entries for the primary video stream
|
||||
pub ep_fine: Vec<EpFine>,
|
||||
/// Per-stream metadata from the ProgramInfo section (BD spec).
|
||||
/// Cross-validates the MPLS STN view — see `labels/clpi.rs`.
|
||||
/// Empty when program_info is missing or malformed.
|
||||
pub streams: Vec<ClpiStream>,
|
||||
}
|
||||
|
||||
/// One stream descriptor from the CLPI ProgramInfo / stream_coding_info
|
||||
/// table. Mirrors the same fields the MPLS STN table carries — see
|
||||
/// `mpls::StreamEntry` for the playlist-side equivalent.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ClpiStream {
|
||||
/// PID of the stream in the MPEG-TS (matches MPLS).
|
||||
pub pid: u16,
|
||||
/// SCSI/BD coding type byte (0x80 LPCM, 0x83 TrueHD, 0x86 DTS-HD MA,
|
||||
/// 0x90 PG, etc.). See `labels::mpls_universal::coding_type_to_codec_hint`.
|
||||
pub coding_type: u8,
|
||||
/// ISO 639-2 3-char language code. Empty for video streams.
|
||||
pub language: String,
|
||||
/// Audio format byte (1=mono, 3=stereo, 6=5.1, 12=7.1).
|
||||
/// Zero for non-audio streams.
|
||||
pub audio_format: u8,
|
||||
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz). Zero for non-audio.
|
||||
pub audio_rate: u8,
|
||||
/// Video format byte (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p).
|
||||
/// Zero for non-video.
|
||||
pub video_format: u8,
|
||||
/// Video rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94).
|
||||
pub video_rate: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -128,7 +157,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
|
||||
// Header offsets
|
||||
let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
let _prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
let prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
let cpi_start = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
|
||||
|
||||
// ClipInfo section at offset 40
|
||||
@@ -139,6 +168,16 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
0
|
||||
};
|
||||
|
||||
// Parse ProgramInfo (per-stream language + codec). Best-effort:
|
||||
// malformed program_info doesn't fail the parse, just gives an
|
||||
// empty streams list. EP map is unaffected — sector-range lookups
|
||||
// continue to work.
|
||||
let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() {
|
||||
parse_program_info(&data[prog_info_start..])
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Parse CPI / EP Map
|
||||
let (ep_coarse, ep_fine) = if cpi_start > 0 && cpi_start + 8 < data.len() {
|
||||
parse_cpi(&data[cpi_start..])?
|
||||
@@ -151,9 +190,127 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
source_packet_count,
|
||||
ep_coarse,
|
||||
ep_fine,
|
||||
streams,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
||||
/// language, codec sub-fields). Layout per BD spec / libbluray
|
||||
/// clpi_parse.c:
|
||||
///
|
||||
/// ```text
|
||||
/// ProgramInfo:
|
||||
/// length: 4 bytes
|
||||
/// reserved: 1 byte
|
||||
/// num_programs: 1 byte
|
||||
/// for each program:
|
||||
/// spn_program_sequence_start: 4 bytes
|
||||
/// program_map_pid: 2 bytes
|
||||
/// num_streams: 1 byte
|
||||
/// num_groups: 1 byte
|
||||
/// for each stream:
|
||||
/// pid: 2 bytes
|
||||
/// stream_coding_info_length: 1 byte
|
||||
/// stream_coding_info: (varies by coding_type)
|
||||
/// coding_type: 1 byte
|
||||
/// per-type bytes (see match arms below)
|
||||
/// ```
|
||||
///
|
||||
/// Returns `Vec::new()` on any structural mismatch — we don't propagate
|
||||
/// errors because the EP map is the primary CLPI output, and a corrupt
|
||||
/// program_info shouldn't break sector-range lookups.
|
||||
fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
|
||||
let mut out = Vec::new();
|
||||
if data.len() < 6 {
|
||||
return out;
|
||||
}
|
||||
// length: 4 bytes (skipped — we trust the section bounds in the
|
||||
// caller's slice and read the bytes that follow). Reserved 1 byte
|
||||
// at offset 4. num_programs at offset 5.
|
||||
let num_programs = data[5] as usize;
|
||||
let mut pos = 6usize;
|
||||
for _ in 0..num_programs {
|
||||
// Program header: 4 (spn) + 2 (pmt_pid) + 1 (num_streams) + 1 (num_groups) = 8 bytes
|
||||
if pos + 8 > data.len() {
|
||||
return out;
|
||||
}
|
||||
let num_streams = data[pos + 6] as usize;
|
||||
pos += 8;
|
||||
|
||||
for _ in 0..num_streams {
|
||||
// Stream header: 2 (pid) + 1 (sci_length) + sci bytes
|
||||
if pos + 3 > data.len() {
|
||||
return out;
|
||||
}
|
||||
let pid = u16::from_be_bytes([data[pos], data[pos + 1]]);
|
||||
let sci_len = data[pos + 2] as usize;
|
||||
let sci_end = pos + 3 + sci_len;
|
||||
if sci_end > data.len() || sci_len < 1 {
|
||||
return out;
|
||||
}
|
||||
let sci = &data[pos + 3..sci_end];
|
||||
let coding_type = sci[0];
|
||||
|
||||
let mut audio_format = 0u8;
|
||||
let mut audio_rate = 0u8;
|
||||
let mut video_format = 0u8;
|
||||
let mut video_rate = 0u8;
|
||||
let mut language = String::new();
|
||||
|
||||
match coding_type {
|
||||
// Video — MPEG-2 (0x02), H.264 (0x1B), HEVC (0x24)
|
||||
0x02 | 0x1B | 0x24 => {
|
||||
if sci.len() >= 2 {
|
||||
video_format = (sci[1] >> 4) & 0x0F;
|
||||
video_rate = sci[1] & 0x0F;
|
||||
}
|
||||
}
|
||||
// Primary audio — LPCM(0x80), AC-3(0x81), DTS(0x82),
|
||||
// TrueHD(0x83), AC-3+(0x84), DTS-HD(0x85), DTS-HD MA(0x86)
|
||||
0x80..=0x86 => {
|
||||
if sci.len() >= 2 {
|
||||
audio_format = (sci[1] >> 4) & 0x0F;
|
||||
audio_rate = sci[1] & 0x0F;
|
||||
}
|
||||
if sci.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sci[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
// Secondary audio (0xA1 AC-3+, 0xA2 DTS-HD)
|
||||
0xA1 | 0xA2 => {
|
||||
if sci.len() >= 2 {
|
||||
audio_format = (sci[1] >> 4) & 0x0F;
|
||||
audio_rate = sci[1] & 0x0F;
|
||||
}
|
||||
if sci.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sci[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
// PG (0x90), IG (0x91): coding_type + 3-byte language [+ char_code for PG]
|
||||
0x90 | 0x91 => {
|
||||
if sci.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sci[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
out.push(ClpiStream {
|
||||
pid,
|
||||
coding_type,
|
||||
language,
|
||||
audio_format,
|
||||
audio_rate,
|
||||
video_format,
|
||||
video_rate,
|
||||
});
|
||||
|
||||
pos = sci_end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the CPI section containing the EP map.
|
||||
fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
if data.len() < 8 {
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ pub mod lfsr;
|
||||
pub(crate) mod tables;
|
||||
|
||||
use crate::disc::Extent;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// CSS decryption state for a DVD title.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -34,7 +34,7 @@ pub struct CssState {
|
||||
/// 0x80 (start of the encrypted region). This only happens when a new PES
|
||||
/// packet begins at exactly sector offset 128. We scan up to 50000
|
||||
/// scrambled sectors sequentially across all extents.
|
||||
pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option<CssState> {
|
||||
pub fn crack_key(reader: &mut dyn SectorSource, extents: &[Extent]) -> Option<CssState> {
|
||||
let mut tried = 0u32;
|
||||
let max_tries = 50_000;
|
||||
|
||||
|
||||
+4
-4
@@ -3,13 +3,13 @@
|
||||
use super::*;
|
||||
use crate::clpi;
|
||||
use crate::mpls;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
impl Disc {
|
||||
/// Scan Blu-ray titles from MPLS playlists.
|
||||
pub(super) fn scan_bluray_titles(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
let mut titles = Vec::new();
|
||||
@@ -31,7 +31,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
pub(super) fn parse_playlist(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
filename: &str,
|
||||
data: &[u8],
|
||||
@@ -199,7 +199,7 @@ impl Disc {
|
||||
/// Prefers English, falls back to first available language.
|
||||
/// Returns None if META directory is empty or XML has no usable title.
|
||||
pub(super) fn read_meta_title(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Option<String> {
|
||||
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
|
||||
|
||||
+2
-2
@@ -2,13 +2,13 @@
|
||||
|
||||
use super::*;
|
||||
use crate::ifo;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
impl Disc {
|
||||
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
|
||||
pub(super) fn scan_dvd_titles(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
||||
|
||||
+104
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||
@@ -22,42 +22,103 @@ impl Disc {
|
||||
) -> Option<HandshakeResult> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
let keydb_path = opts.resolve_keydb()?;
|
||||
let keydb = KeyDb::load(&keydb_path).ok()?;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_entry",
|
||||
"do_handshake entered"
|
||||
);
|
||||
let keydb_path = match opts.resolve_keydb() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_no_keydb",
|
||||
"no KEYDB found in search paths; handshake skipped"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let keydb = match KeyDb::load(&keydb_path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_keydb_load_failed",
|
||||
io_error_kind = ?e.kind(),
|
||||
keydb = %keydb_path.display(),
|
||||
"KEYDB load failed; handshake skipped"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let host_cert_count = keydb.host_certs.len();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_start",
|
||||
host_cert_count,
|
||||
keydb = %keydb_path.display(),
|
||||
"handshake starting"
|
||||
);
|
||||
|
||||
const MAX_CERT_ATTEMPTS: usize = 16;
|
||||
for hc in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS) {
|
||||
let mut last_err_code: Option<u16> = None;
|
||||
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
||||
Ok(mut auth) => {
|
||||
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
||||
Ok(vid) => vid,
|
||||
Err(_) => return None, // handshake succeeded but can't read VID
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_vid_read_failed",
|
||||
cert_index = idx,
|
||||
error_code = e.code(),
|
||||
"auth ok but volume ID read failed"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
.ok()
|
||||
.map(|(rdk, _)| rdk);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_ok",
|
||||
cert_index = idx,
|
||||
has_read_data_key = read_data_key.is_some(),
|
||||
);
|
||||
return Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
last_err_code = Some(e.code());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_all_certs_failed",
|
||||
host_cert_count,
|
||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
||||
last_error_code = last_err_code,
|
||||
"all host certs in KEYDB rejected by drive"
|
||||
);
|
||||
// All host certs failed — return None, not a fake success
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
///
|
||||
/// Reads AACS files from UDF (via SectorReader), resolves keys through
|
||||
/// Reads AACS files from UDF (via SectorSource), resolves keys through
|
||||
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
|
||||
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
|
||||
pub(super) fn resolve_encryption(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
keydb_path: &std::path::Path,
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
@@ -73,6 +134,19 @@ impl Disc {
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
|
||||
// Log the disc hash so we can confirm whether it's present in KEYDB
|
||||
// when key resolution fails. The disc hash is SHA-1 of the full
|
||||
// Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by.
|
||||
let dh = crate::aacs::disc_hash(&uk_ro_data);
|
||||
let dh_hex = crate::aacs::disc_hash_hex(&dh);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_disc_hash",
|
||||
disc_hash = %dh_hex,
|
||||
uk_ro_len = uk_ro_data.len(),
|
||||
"disc hash computed (compare with keydb.cfg entries)"
|
||||
);
|
||||
|
||||
let cc_data = udf_fs
|
||||
.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
@@ -84,6 +158,28 @@ impl Disc {
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
let mkb_first_64_hex = mkb_data
|
||||
.as_deref()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.take(64)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_mkb_info",
|
||||
mkb_present = mkb_data.is_some(),
|
||||
mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0),
|
||||
mkb_version = ?mkb_ver,
|
||||
mkb_first_64 = %mkb_first_64_hex,
|
||||
keydb_disc_count = keydb.disc_entries.len(),
|
||||
keydb_dk_count = keydb.device_keys.len(),
|
||||
keydb_pk_count = keydb.processing_keys.len(),
|
||||
"AACS resolution inputs"
|
||||
);
|
||||
|
||||
// Use handshake volume ID if available, otherwise zeros
|
||||
// (KEYDB VUK lookup by disc hash works without volume ID)
|
||||
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
||||
|
||||
+167
-1188
File diff suppressed because it is too large
Load Diff
+1226
-9
File diff suppressed because it is too large
Load Diff
+546
-52
@@ -65,6 +65,80 @@ pub struct ReadCtx {
|
||||
/// is its core job, and it has the right tools (single-sector
|
||||
/// reads, 60s recovery timeout, retry budget, escalating skip).
|
||||
pub bisect_on_marginal: bool,
|
||||
/// Count of consecutive firmware-wedge responses (HARDWARE_ERROR
|
||||
/// or ILLEGAL_REQUEST sense keys) since the last successful read.
|
||||
/// Pass 1 uses this to drive the wedge-skip path: each wedge
|
||||
/// triggers a 1 GB jump + cooldown pause. Reaching
|
||||
/// `WEDGE_ABORT_THRESHOLD` consecutive wedges with no good read
|
||||
/// in between → real AbortPass.
|
||||
pub wedge_count: u64,
|
||||
// ── Diagnostic counters (added 2026-05-10) ──
|
||||
//
|
||||
// Aggregate state for post-mortem analysis of wedge incidents.
|
||||
// Every Pass 1 / Pass N sweep now produces a structured summary
|
||||
// at the WARN log on each error AND an end-of-pass INFO summary.
|
||||
// Goal: when a wedge happens, the operator should be able to tell
|
||||
// from the logs whether it was triggered by ONE read at a
|
||||
// physically-damaged sector (immediate failure) or by accumulated
|
||||
// exposure across MANY reads (firmware-state buildup), and what
|
||||
// the timing pattern looked like.
|
||||
/// `Instant` of the most recent successful read. Used to compute
|
||||
/// "time since last good" for the WARN log on each error. None
|
||||
/// before the first successful read.
|
||||
pub last_success_at: Option<std::time::Instant>,
|
||||
/// `Instant` of the most recent failed read. Used to compute
|
||||
/// "time since last error" for the WARN log. None before the
|
||||
/// first error.
|
||||
pub last_error_at: Option<std::time::Instant>,
|
||||
/// Last error's sense-key "family" (Medium / Hardware / IllegalRequest
|
||||
/// / NotReady / Other). Used to detect WEDGE TRANSITIONS — when
|
||||
/// the family changes from Medium → Hardware/IllegalRequest, the
|
||||
/// drive almost certainly just entered fast-fail mode. That
|
||||
/// transition gets its own WARN log so the trace is unambiguous.
|
||||
pub last_error_family: Option<SenseFamily>,
|
||||
/// Sum of all errors observed during this sweep. Reported in the
|
||||
/// end-of-pass summary.
|
||||
pub total_errors: u64,
|
||||
/// Sum of all successful reads during this sweep.
|
||||
pub total_reads_ok: u64,
|
||||
/// Count of damage zones entered (transitions from clean → in-damage).
|
||||
pub zones_entered: u64,
|
||||
/// Count of damage-jumps executed during this sweep.
|
||||
pub jumps_taken: u64,
|
||||
/// True between "first error after a clean period" and "16 consecutive
|
||||
/// good reads after the last error in the cluster." Used to count
|
||||
/// zone entries and to bound zone_reads accurately.
|
||||
pub in_damage_zone: bool,
|
||||
}
|
||||
|
||||
/// Coarse classification of a SCSI sense key for diagnostic logging.
|
||||
/// Wedge-family events (Hardware + IllegalRequest) get their own
|
||||
/// transition log when the sense family changes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SenseFamily {
|
||||
NotReady,
|
||||
Medium,
|
||||
Hardware,
|
||||
IllegalRequest,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl SenseFamily {
|
||||
pub fn from_sense_key(sense_key: u8) -> Self {
|
||||
match sense_key {
|
||||
scsi::SENSE_KEY_NOT_READY => SenseFamily::NotReady,
|
||||
scsi::SENSE_KEY_MEDIUM_ERROR => SenseFamily::Medium,
|
||||
scsi::SENSE_KEY_HARDWARE_ERROR => SenseFamily::Hardware,
|
||||
scsi::SENSE_KEY_ILLEGAL_REQUEST => SenseFamily::IllegalRequest,
|
||||
_ => SenseFamily::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the "wedge family" — Hardware + IllegalRequest are
|
||||
/// the senses the BU40N firmware returns in its fast-fail state.
|
||||
pub fn is_wedge_family(self) -> bool {
|
||||
matches!(self, SenseFamily::Hardware | SenseFamily::IllegalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadCtx {
|
||||
@@ -73,8 +147,13 @@ impl ReadCtx {
|
||||
/// is the one that grinds on the bad ranges. So bisect-on-
|
||||
/// marginal is OFF (failed batches become SkipBlock; whole 32-
|
||||
/// sector blocks marked NonTrimmed for Pass N to revisit), and
|
||||
/// the damage-jump fast-path triggers after just 4 consecutive
|
||||
/// outer-batch failures.
|
||||
/// the damage-jump fast-path triggers after just 1 consecutive
|
||||
/// outer-batch failure — the user's wedge-prevention principle
|
||||
/// (2026-05-11): once the drive returns ANY recoverable error,
|
||||
/// retrying the same LBA quickly is what triggers the firmware
|
||||
/// fast-fail transition. Jump immediately, never retry in Pass 1.
|
||||
/// Pass N owns retries — it gets per-sector timeouts that don't
|
||||
/// hammer the firmware the same way.
|
||||
pub fn for_sweep(batch: u16) -> Self {
|
||||
Self {
|
||||
batch,
|
||||
@@ -84,12 +163,21 @@ impl ReadCtx {
|
||||
damage_window: Vec::with_capacity(16),
|
||||
damage_window_max: 16,
|
||||
damage_threshold_pct: 12,
|
||||
fast_jump_threshold: 4,
|
||||
fast_jump_threshold: 1,
|
||||
jump_multiplier: 1,
|
||||
not_ready_retries: 0,
|
||||
bridge_degradation_count: 0,
|
||||
bisecting: false,
|
||||
bisect_on_marginal: false,
|
||||
wedge_count: 0,
|
||||
last_success_at: None,
|
||||
last_error_at: None,
|
||||
last_error_family: None,
|
||||
total_errors: 0,
|
||||
total_reads_ok: 0,
|
||||
zones_entered: 0,
|
||||
jumps_taken: 0,
|
||||
in_damage_zone: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +203,15 @@ impl ReadCtx {
|
||||
bridge_degradation_count: 0,
|
||||
bisecting: false,
|
||||
bisect_on_marginal: true,
|
||||
wedge_count: 0,
|
||||
last_success_at: None,
|
||||
last_error_at: None,
|
||||
last_error_family: None,
|
||||
total_errors: 0,
|
||||
total_reads_ok: 0,
|
||||
zones_entered: 0,
|
||||
jumps_taken: 0,
|
||||
in_damage_zone: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +220,10 @@ impl ReadCtx {
|
||||
self.consecutive_good += 1;
|
||||
self.consecutive_failures = 0;
|
||||
self.not_ready_retries = 0;
|
||||
// Any successful read clears the wedge-skip counter — the
|
||||
// drive recovered, so further wedges should reset the skip
|
||||
// budget instead of accumulating toward a real abort.
|
||||
self.wedge_count = 0;
|
||||
// Outer-success only: a good single-sector read inside a
|
||||
// bisect doesn't mean we've left the damaged batch. Only an
|
||||
// outer-batch success resets the outer-failure counter.
|
||||
@@ -133,7 +234,40 @@ impl ReadCtx {
|
||||
if self.damage_window.len() > self.damage_window_max {
|
||||
self.damage_window.remove(0);
|
||||
}
|
||||
// Diagnostic state.
|
||||
self.total_reads_ok += 1;
|
||||
self.last_success_at = Some(std::time::Instant::now());
|
||||
// If we were in a damage zone and accumulated enough good
|
||||
// reads to exit (damage_window now all-good), the zone is
|
||||
// over. Don't reset zones_entered — that's a sweep total.
|
||||
if self.in_damage_zone && self.consecutive_good >= self.damage_window_max as u64 {
|
||||
self.in_damage_zone = false;
|
||||
self.last_error_family = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Final per-pass summary suitable for an INFO log at the end of
|
||||
/// `sweep` / `patch`. Caller renders this to a single structured
|
||||
/// log line.
|
||||
pub fn pass_summary(&self) -> PassSummary {
|
||||
PassSummary {
|
||||
total_reads_ok: self.total_reads_ok,
|
||||
total_errors: self.total_errors,
|
||||
zones_entered: self.zones_entered,
|
||||
jumps_taken: self.jumps_taken,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-of-pass stats logged at INFO for post-mortem analysis. Lets
|
||||
/// an operator answer "how damaged is this disc?" from a single log
|
||||
/// line per pass.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PassSummary {
|
||||
pub total_reads_ok: u64,
|
||||
pub total_errors: u64,
|
||||
pub zones_entered: u64,
|
||||
pub jumps_taken: u64,
|
||||
}
|
||||
|
||||
/// What the caller should do after a read failure. The caller owns the
|
||||
@@ -165,7 +299,42 @@ pub enum ReadAction {
|
||||
// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing
|
||||
// bridge wedges 524 ms after a 5.4-second internal ECC retry. The
|
||||
// post-failure pauses give the drive — and the bridge — time to settle.
|
||||
const POST_FAILURE_PAUSE_SECS: u64 = 1;
|
||||
/// Pause between a failed read and the next read attempt — applied
|
||||
/// uniformly to Pass 1 sweep and Pass N patch.
|
||||
///
|
||||
/// 2026-05-11 reframe: a failed read is a failed read, regardless of
|
||||
/// which pass is running. The prior split (1s for Pass N, 5s for Pass
|
||||
/// 1 via `PASS_1_FAIL_PAUSE_SECS`) was solving an imaginary cost
|
||||
/// problem — real damaged-disc cases mark <50 MB NonTrimmed, and the
|
||||
/// extra 5s/error is single-digit minutes per pass, not hours. The
|
||||
/// cost of NOT pausing — a drive wedge that aborts the entire
|
||||
/// multi-pass recovery — is much worse.
|
||||
///
|
||||
/// The wedge avoidance principle: error → drive ECC retry (5-10s
|
||||
/// internal) → return → cooldown pause → next read. Same shape
|
||||
/// everywhere reads can fail.
|
||||
const FAIL_PAUSE_SECS: u64 = 5;
|
||||
/// Long cooldown applied when a damage zone is first entered (the
|
||||
/// FIRST read failure after a clean run, before the drive has had a
|
||||
/// chance to cycle in retries that push it toward fast-fail).
|
||||
///
|
||||
/// Empirical: 2026-05-11 Dune Pt 2 wedge incident showed 7 medium
|
||||
/// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the
|
||||
/// BU40N's firmware into IllegalRequest fast-fail mode permanently.
|
||||
/// Once there, only physical eject + reload clears it. Giving the
|
||||
/// drive 30s of breathing room after the FIRST error in a zone —
|
||||
/// before we start adding more error counts in the firmware's
|
||||
/// internal window — prevents the transition.
|
||||
///
|
||||
/// Cost on clean discs: zero (first-error path doesn't trigger).
|
||||
/// Cost on damaged discs: ~30s × N damage zones; on a 5-zone disc
|
||||
/// that's 2.5 min extra. Trade for never wedging the drive.
|
||||
const ZONE_ENTRY_COOLDOWN_SECS: u64 = 30;
|
||||
/// Cooldown when a long streak of failures suggests the drive is
|
||||
/// stuck in a damage zone and needs MORE breathing room than the
|
||||
/// standard inter-error pause. Same value as `FAIL_PAUSE_SECS`
|
||||
/// because empirically 5s is enough; kept as a separate name so the
|
||||
/// escalation policy is explicit at the call site.
|
||||
const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5;
|
||||
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
|
||||
const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2;
|
||||
@@ -174,6 +343,64 @@ const NOT_READY_MAX_RETRIES: u32 = 3;
|
||||
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15;
|
||||
const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;
|
||||
|
||||
/// Base of the damage-jump distance formula: `jump_sectors =
|
||||
/// JUMP_BASE_SECTORS × batch × jump_multiplier`. Bumped 2026-05-10
|
||||
/// from 256 → 1024 (4×) so the first damage-jump at batch=32 covers
|
||||
/// 64 MB instead of 16 MB. Empirically the BU40N's damage clusters
|
||||
/// are 100+ MB wide; 16 MB jumps landed inside the cluster and the
|
||||
/// re-read added to the firmware wedge counter. 64 MB → 128 MB
|
||||
/// (after one doubling) clears almost any single-cluster damage in
|
||||
/// 2 jumps.
|
||||
const JUMP_BASE_SECTORS: u64 = 1024;
|
||||
|
||||
// Firmware-wedge skip policy for Pass 1 sweep
|
||||
// ===========================================
|
||||
//
|
||||
// When the BU40N (or similar drives) hits a physical-damage cluster,
|
||||
// its firmware can transition into a "wedge" state where it returns
|
||||
// HARDWARE_ERROR or ILLEGAL_REQUEST for every subsequent read —
|
||||
// often for many LBAs after the actual bad sector. Per project docs
|
||||
// "Bad-sector handling" rule #2: "Recovery requires eject+reload OR
|
||||
// significant cool-down."
|
||||
//
|
||||
// Pass 1's pre-fix behavior was to immediately AbortPass on the
|
||||
// first HARDWARE_ERROR / ILLEGAL_REQUEST, killing the rip at
|
||||
// whatever percentage it had reached. That's the wrong call when:
|
||||
// - the damage zone may be small (jumping past it could resume
|
||||
// normal reads), AND
|
||||
// - even if the drive stays wedged, finishing the sweep gives us
|
||||
// an honest mapfile for Pass N to attack later.
|
||||
//
|
||||
// New policy: treat wedge sense codes the same way the damage-window
|
||||
// treats persistent failure — JumpAhead by a large distance with a
|
||||
// cooldown pause. Allow up to WEDGE_ABORT_THRESHOLD consecutive
|
||||
// wedges (no successful read in between) before declaring the drive
|
||||
// truly stuck and surfacing AbortPass to autorip.
|
||||
|
||||
/// One-gigabyte jump (1024 MiB) on each wedge. Big enough to clear
|
||||
/// almost any single-cluster damage zone we've seen.
|
||||
const WEDGE_JUMP_SECTORS: u64 = 524_288;
|
||||
/// Cooldown pause after each wedge. Per project docs the drive needs
|
||||
/// "significant cool-down"; 30 s strikes a balance between giving
|
||||
/// the drive a chance to recover and not stalling the rip if the
|
||||
/// drive is permanently stuck.
|
||||
const WEDGE_PAUSE_SECS: u64 = 30;
|
||||
/// Bail after this many consecutive wedges with no good read in
|
||||
/// between. At 1 GB jumps this lets us scan ~16 GB worth of fully
|
||||
/// wedged area before giving up — generous enough to clear most
|
||||
/// physical-damage clusters, bounded enough to not loop forever on
|
||||
/// a permanently bricked drive.
|
||||
const WEDGE_ABORT_THRESHOLD: u64 = 16;
|
||||
|
||||
/// Pass-N wedge-skip distance. Pass N's batch=1 reads target
|
||||
/// specific NonTrimmed sectors from Pass 1, so a big 1 GB skip
|
||||
/// would blow past the current NonTrimmed range and abandon many
|
||||
/// sectors that might still recover. Use a smaller skip just to
|
||||
/// move past the bricked LBA + a small buffer — the outer patch
|
||||
/// loop's next iteration picks up the next sector in the same or
|
||||
/// next range.
|
||||
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;
|
||||
|
||||
/// THE single error-handling entry point. Updates `ctx`, returns the
|
||||
/// action the caller must apply.
|
||||
///
|
||||
@@ -191,12 +418,53 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
ctx.consecutive_outer_failures += 1;
|
||||
}
|
||||
|
||||
// Diagnostic instrumentation — compute timing context BEFORE
|
||||
// mutating the timestamps so the log reflects the gap to the
|
||||
// PREVIOUS error / success, not zero.
|
||||
let now = std::time::Instant::now();
|
||||
let ms_since_last_error = ctx
|
||||
.last_error_at
|
||||
.map(|t| now.duration_since(t).as_millis() as u64);
|
||||
let ms_since_last_success = ctx
|
||||
.last_success_at
|
||||
.map(|t| now.duration_since(t).as_millis() as u64);
|
||||
|
||||
let current_family = err
|
||||
.scsi_sense()
|
||||
.map(|s| SenseFamily::from_sense_key(s.sense_key))
|
||||
.unwrap_or(SenseFamily::Other);
|
||||
|
||||
// Zone-entry tracking: this is the first error after a clean run
|
||||
// (or the first error of the sweep).
|
||||
if !ctx.in_damage_zone && !ctx.bisecting {
|
||||
ctx.in_damage_zone = true;
|
||||
ctx.zones_entered += 1;
|
||||
}
|
||||
|
||||
ctx.total_errors += 1;
|
||||
ctx.last_error_at = Some(now);
|
||||
|
||||
// Wedge transition: previous error was MEDIUM, this one is
|
||||
// HARDWARE or ILLEGAL_REQUEST. That's the moment the drive's
|
||||
// firmware flipped into fast-fail mode. Distinct WARN so logs
|
||||
// make it unambiguous when the wedge "started."
|
||||
let is_wedge_transition = matches!(ctx.last_error_family, Some(prev) if !prev.is_wedge_family())
|
||||
&& current_family.is_wedge_family();
|
||||
ctx.last_error_family = Some(current_family);
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "read_error",
|
||||
consecutive_failures = ctx.consecutive_failures,
|
||||
consecutive_outer_failures = ctx.consecutive_outer_failures,
|
||||
ms_since_last_error,
|
||||
ms_since_last_success,
|
||||
total_errors = ctx.total_errors,
|
||||
total_reads_ok = ctx.total_reads_ok,
|
||||
batch = ctx.batch,
|
||||
bisecting = ctx.bisecting,
|
||||
wedge_count = ctx.wedge_count,
|
||||
sense_family = ?current_family,
|
||||
sense_key = err.scsi_sense().map(|s| s.sense_key),
|
||||
asc = err.scsi_sense().map(|s| s.asc),
|
||||
ascq = err.scsi_sense().map(|s| s.ascq),
|
||||
@@ -204,6 +472,17 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
"read failed; classifying"
|
||||
);
|
||||
|
||||
if is_wedge_transition {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "wedge_transition",
|
||||
errors_in_zone = ctx.total_errors,
|
||||
ms_since_last_success,
|
||||
new_family = ?current_family,
|
||||
"drive entered wedge / fast-fail family (was returning recoverable medium errors before this)"
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Transport failure: bridge crash / USB disconnect. The outer
|
||||
// pass loop knows how to handle this (rediscover sg path,
|
||||
// re-open drive). Inline single-sector retry here was tried in
|
||||
@@ -239,11 +518,62 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
ctx.not_ready_retries = 0;
|
||||
}
|
||||
|
||||
// 4. Hardware / illegal request: the drive said "no, won't do it".
|
||||
// Retrying won't change the answer. Surface to the outer layer
|
||||
// so it can eject + prompt user.
|
||||
// 4. Hardware error / illegal request — the firmware-wedge family.
|
||||
// The drive transitioned into a fast-fail state where it
|
||||
// rejects reads near the LBA. Same response shape for both
|
||||
// passes (2026-05-11 reframe — error handling is centralized,
|
||||
// and the wedge is a code-induced state we can avoid via
|
||||
// pacing + skip):
|
||||
//
|
||||
// - Pass 1 sweep (bisect_on_marginal=false): jump
|
||||
// WEDGE_JUMP_SECTORS (1 GB) ahead, pause WEDGE_PAUSE_SECS,
|
||||
// mark skipped region NonTrimmed.
|
||||
// - Pass N patch (bisect_on_marginal=true): give up on the
|
||||
// current sector (the granular target), pause for cooldown,
|
||||
// let the outer patch loop move to the next NonTrimmed
|
||||
// range. Implemented as a small JumpAhead so the same code
|
||||
// path serves both — Pass N's batch=1 means JumpAhead by
|
||||
// WEDGE_PASS_N_SKIP_SECTORS effectively skips just this
|
||||
// sector and a small buffer (gives the drive room to
|
||||
// recover before the next per-sector attempt).
|
||||
//
|
||||
// Both paths share the WEDGE_ABORT_THRESHOLD budget — only
|
||||
// AbortPass after N consecutive wedges with no successful
|
||||
// read in between.
|
||||
if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
|
||||
return ReadAction::AbortPass;
|
||||
if !ctx.bisecting {
|
||||
ctx.wedge_count += 1;
|
||||
}
|
||||
if ctx.wedge_count >= WEDGE_ABORT_THRESHOLD {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "wedge_abort",
|
||||
wedge_count = ctx.wedge_count,
|
||||
threshold = WEDGE_ABORT_THRESHOLD,
|
||||
pass = if ctx.bisect_on_marginal { "N" } else { "1" },
|
||||
"wedge-skip exhausted — drive appears permanently stuck"
|
||||
);
|
||||
return ReadAction::AbortPass;
|
||||
}
|
||||
let jump_sectors = if ctx.bisect_on_marginal {
|
||||
WEDGE_PASS_N_SKIP_SECTORS
|
||||
} else {
|
||||
WEDGE_JUMP_SECTORS
|
||||
};
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "wedge_skip",
|
||||
pass = if ctx.bisect_on_marginal { "N" } else { "1" },
|
||||
wedge_count = ctx.wedge_count,
|
||||
jump_sectors,
|
||||
pause_secs = WEDGE_PAUSE_SECS,
|
||||
"wedge detected — skipping ahead and pausing for drive cooldown"
|
||||
);
|
||||
ctx.jumps_taken += 1;
|
||||
return ReadAction::JumpAhead {
|
||||
sectors: jump_sectors,
|
||||
pause_secs: WEDGE_PAUSE_SECS,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Marginal media (MEDIUM_ERROR / ABORTED_COMMAND) on a multi-
|
||||
@@ -290,17 +620,31 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
bad_count * 100 / ctx.damage_window.len()
|
||||
};
|
||||
|
||||
// Pass 1's job is to get to the end of the disc fast. Inter-
|
||||
// block pauses help the drive cool down on Pass N (gentle
|
||||
// recovery on bad ranges) but on Pass 1 they just turn a 30 s
|
||||
// damage zone into 30 minutes of dead-air sleep. Pass N
|
||||
// (`bisect_on_marginal=true`) keeps the original cooldown logic.
|
||||
let pause_secs = if !ctx.bisect_on_marginal {
|
||||
0
|
||||
// Inter-error pause — wedge prevention via pacing.
|
||||
//
|
||||
// Zone-entry case (first error after a clean run): apply the
|
||||
// long ZONE_ENTRY_COOLDOWN_SECS pause. The empirical wedge
|
||||
// observed 2026-05-11 happened ~7 errors into a damage zone,
|
||||
// each retry adding to the firmware's internal counter. A 30s
|
||||
// pause at zone entry lets the drive's bridge / firmware
|
||||
// counters reset before we issue the next read.
|
||||
//
|
||||
// Subsequent errors in the same zone: the standard 5s pause.
|
||||
// (We've already jumped past the initial damage; further errors
|
||||
// mean we landed in another bad cluster — same pacing applies.)
|
||||
//
|
||||
// Long-streak escalation: same 5s currently; kept as a separate
|
||||
// branch for future tuning. Pass N (bisect_on_marginal=true)
|
||||
// uses the standard pauses — it's running single-sector retries
|
||||
// on already-known-bad LBAs by design.
|
||||
let is_zone_entry =
|
||||
ctx.consecutive_outer_failures == 1 && !ctx.bisecting && !ctx.bisect_on_marginal;
|
||||
let pause_secs = if is_zone_entry {
|
||||
ZONE_ENTRY_COOLDOWN_SECS
|
||||
} else if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
|
||||
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
|
||||
} else {
|
||||
POST_FAILURE_PAUSE_SECS
|
||||
FAIL_PAUSE_SECS
|
||||
};
|
||||
|
||||
// 7. Damage-jump: too many failures → skip ahead by an escalating
|
||||
@@ -309,6 +653,14 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
// multiplier produced a 56 GB jump). Saturating arithmetic on
|
||||
// the sector calc as defence in depth.
|
||||
//
|
||||
// Jump base bumped 2026-05-10 from 256 to 1024 sectors per
|
||||
// multiplier unit (= 64 MB first jump at batch=32, up from
|
||||
// 16 MB). The smaller base routinely landed jumps back inside
|
||||
// damage clusters of 100+ MB, each landing adding to the
|
||||
// firmware's wedge counter. 64 MB initial + 128 MB second +
|
||||
// 256 MB third clears almost any single-cluster damage
|
||||
// pattern we've seen in 2 jumps.
|
||||
//
|
||||
// Two triggers, evaluated in order:
|
||||
//
|
||||
// a. **Fast-entry** — `consecutive_outer_failures >= fast_jump_threshold`.
|
||||
@@ -321,18 +673,21 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
// sliding window of 16 outer reads. Pass N's only path,
|
||||
// and Pass 1's fallback if the failures are scattered
|
||||
// enough that we don't hit the consecutive threshold.
|
||||
const MAX_JUMP_MULTIPLIER: u64 = 64; // 64 × 256 × batch sectors
|
||||
const MAX_JUMP_MULTIPLIER: u64 = 64;
|
||||
let fast_trigger = !ctx.bisecting && ctx.consecutive_outer_failures >= ctx.fast_jump_threshold;
|
||||
let window_trigger =
|
||||
ctx.damage_window.len() >= ctx.damage_window_max && bad_pct >= ctx.damage_threshold_pct;
|
||||
if fast_trigger || window_trigger {
|
||||
let mult = ctx.jump_multiplier.min(MAX_JUMP_MULTIPLIER);
|
||||
let sectors = 256u64.saturating_mul(ctx.batch as u64).saturating_mul(mult);
|
||||
let sectors = JUMP_BASE_SECTORS
|
||||
.saturating_mul(ctx.batch as u64)
|
||||
.saturating_mul(mult);
|
||||
ctx.jump_multiplier = (ctx.jump_multiplier.saturating_mul(2)).min(MAX_JUMP_MULTIPLIER);
|
||||
// Reset the outer-failure counter so a long damaged region
|
||||
// doesn't keep firing fast-jump every read after the initial
|
||||
// jump fired. The window-based trigger handles further jumps.
|
||||
ctx.consecutive_outer_failures = 0;
|
||||
ctx.jumps_taken += 1;
|
||||
return ReadAction::JumpAhead {
|
||||
sectors,
|
||||
pause_secs: pause_secs + POST_JUMP_EXTRA_PAUSE_SECS,
|
||||
@@ -374,6 +729,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn illegal_request_err() -> Error {
|
||||
Error::DiscRead {
|
||||
sector: 100,
|
||||
status: Some(2),
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: scsi::SENSE_KEY_ILLEGAL_REQUEST,
|
||||
asc: 0x24,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_marginal_with_batch_gt_1_bisects() {
|
||||
let mut ctx = ReadCtx::for_patch(32);
|
||||
@@ -382,15 +749,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_marginal_skips_instead_of_bisecting() {
|
||||
// Pass 1's job is "fast and accurate" — leave bisection to
|
||||
// Pass N. A failed batch becomes SkipBlock (whole 32-sector
|
||||
// block marked NonTrimmed for Pass N to revisit).
|
||||
fn pass_1_marginal_jumps_immediately_not_bisecting() {
|
||||
// 2026-05-11 wedge-prevention rewrite: Pass 1 jumps on the
|
||||
// FIRST marginal error (fast_jump_threshold=1) rather than
|
||||
// SkipBlock. Retrying the same LBA quickly is what triggers
|
||||
// the BU40N's firmware fast-fail transition; immediate jump
|
||||
// prevents the cascade. Pass N still bisects (its job is
|
||||
// per-sector recovery on already-known-bad LBAs).
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::SkipBlock { .. } => {}
|
||||
other => panic!("expected SkipBlock for Pass 1, got {other:?}"),
|
||||
ReadAction::JumpAhead { .. } => {}
|
||||
other => panic!("expected JumpAhead on first Pass 1 marginal error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,26 +786,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_jumps_after_4_consecutive_outer_failures() {
|
||||
// The fast-entry trigger: Pass 1 should JumpAhead after 4
|
||||
// consecutive outer-batch failures, BEFORE the 16-block
|
||||
// damage window has filled. Otherwise we spend ~40 minutes
|
||||
// of bisecting/grinding to fill the window before the first
|
||||
// jump on a damage zone we entered cleanly.
|
||||
fn pass_1_jumps_immediately_on_first_outer_failure() {
|
||||
// 2026-05-11 rewrite: fast_jump_threshold is 1 on Pass 1, not
|
||||
// 4. Even ONE error triggers a jump because BU40N's firmware
|
||||
// fast-fail mode is sensitive to retry cadence. The wedge
|
||||
// observed 2026-05-11 happened at 7 errors / 6.5s — by then
|
||||
// we were already wedged. Jumping on error #1 means we
|
||||
// physically can't reach the cascade.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
// First three should NOT jump (still under threshold of 4).
|
||||
for _ in 0..3 {
|
||||
let a = handle_read_error(&medium_err(), &mut ctx);
|
||||
assert!(
|
||||
!matches!(a, ReadAction::JumpAhead { .. }),
|
||||
"should not jump until 4 consecutive outer failures"
|
||||
);
|
||||
}
|
||||
// Fourth should jump.
|
||||
let a = handle_read_error(&medium_err(), &mut ctx);
|
||||
assert!(
|
||||
matches!(a, ReadAction::JumpAhead { .. }),
|
||||
"expected JumpAhead at 4th consecutive outer failure, got {a:?}"
|
||||
"expected JumpAhead on first outer failure (fast_jump_threshold=1), got {a:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -456,12 +818,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn outer_success_resets_consecutive_outer_failures() {
|
||||
// With fast_jump_threshold=1 each Pass 1 error fires a jump
|
||||
// and resets `consecutive_outer_failures` to 0 inside the
|
||||
// handler. So we can't accumulate "3" the old way — instead,
|
||||
// verify the counter goes back to 0 after on_success too.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
for _ in 0..3 {
|
||||
handle_read_error(&medium_err(), &mut ctx);
|
||||
}
|
||||
assert_eq!(ctx.consecutive_outer_failures, 3);
|
||||
// An outer-success (bisecting=false) should reset the counter.
|
||||
handle_read_error(&medium_err(), &mut ctx);
|
||||
// After fast-jump, consecutive_outer_failures already 0.
|
||||
assert_eq!(ctx.consecutive_outer_failures, 0);
|
||||
// on_success keeps it at 0 (defensive).
|
||||
ctx.bisecting = false;
|
||||
ctx.on_success();
|
||||
assert_eq!(ctx.consecutive_outer_failures, 0);
|
||||
@@ -485,12 +850,113 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_error_aborts() {
|
||||
fn pass_1_hardware_error_jumps_ahead_not_aborts() {
|
||||
// New wedge-skip policy: Pass 1 (bisect_on_marginal=false)
|
||||
// should JumpAhead with a 1 GB skip + cooldown pause instead
|
||||
// of immediately aborting. Aborting on first wedge was the
|
||||
// pre-fix behavior that killed rips at 48% on damaged discs.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::JumpAhead {
|
||||
sectors,
|
||||
pause_secs,
|
||||
} => {
|
||||
assert_eq!(sectors, WEDGE_JUMP_SECTORS);
|
||||
assert_eq!(pause_secs, WEDGE_PAUSE_SECS);
|
||||
}
|
||||
other => panic!("expected JumpAhead, got {other:?}"),
|
||||
}
|
||||
assert_eq!(ctx.wedge_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_hardware_error_aborts_after_threshold() {
|
||||
// After WEDGE_ABORT_THRESHOLD consecutive wedges with no good
|
||||
// read in between, autorip should see a real AbortPass so it
|
||||
// can surface "drive is stuck, power-cycle required" to the
|
||||
// user — rather than looping forever on a permanently bricked
|
||||
// drive.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
for i in 0..WEDGE_ABORT_THRESHOLD - 1 {
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
assert!(
|
||||
matches!(action, ReadAction::JumpAhead { .. }),
|
||||
"iter {i}: expected JumpAhead, got {action:?}"
|
||||
);
|
||||
}
|
||||
// The Nth wedge crosses the threshold.
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
assert_eq!(action, ReadAction::AbortPass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_good_read_resets_wedge_count() {
|
||||
// A single successful read between wedges must clear the
|
||||
// skip counter — otherwise a disc with a few scattered bad
|
||||
// zones would eventually run out of skip budget even though
|
||||
// the drive was recovering between zones.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
for _ in 0..(WEDGE_ABORT_THRESHOLD - 1) {
|
||||
handle_read_error(&hardware_err(), &mut ctx);
|
||||
}
|
||||
assert_eq!(ctx.wedge_count, WEDGE_ABORT_THRESHOLD - 1);
|
||||
ctx.on_success();
|
||||
assert_eq!(ctx.wedge_count, 0);
|
||||
// After the success, we should still get JumpAhead (not
|
||||
// AbortPass) on the next wedge.
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
assert!(matches!(action, ReadAction::JumpAhead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_hardware_error_also_skips_not_aborts() {
|
||||
// 2026-05-11 reframe: error handling is centralized, the
|
||||
// wedge is a code-induced state, and the avoidance principle
|
||||
// (skip + pause + continue) applies to Pass N too. Previously
|
||||
// Pass N AbortPass'd on first wedge — same fatal-at-48% bug
|
||||
// Pass 1 had pre-fix. Now Pass N gets a smaller skip
|
||||
// (WEDGE_PASS_N_SKIP_SECTORS, not the 1 GB Pass 1 jump)
|
||||
// because Pass N's job IS to revisit specific NonTrimmed
|
||||
// ranges; over-skipping abandons recoverable sectors.
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::JumpAhead {
|
||||
sectors,
|
||||
pause_secs,
|
||||
} => {
|
||||
assert_eq!(sectors, WEDGE_PASS_N_SKIP_SECTORS);
|
||||
assert_eq!(pause_secs, WEDGE_PAUSE_SECS);
|
||||
}
|
||||
other => panic!("expected JumpAhead, got {other:?}"),
|
||||
}
|
||||
assert_eq!(ctx.wedge_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_hardware_error_aborts_after_threshold() {
|
||||
// Same threshold as Pass 1 — after WEDGE_ABORT_THRESHOLD
|
||||
// consecutive wedges with no good read in between, give up.
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
for _ in 0..WEDGE_ABORT_THRESHOLD - 1 {
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
assert!(matches!(action, ReadAction::JumpAhead { .. }));
|
||||
}
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
assert_eq!(action, ReadAction::AbortPass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_illegal_request_also_routes_to_wedge_skip() {
|
||||
// ILLEGAL_REQUEST is the other half of the wedge family:
|
||||
// drive saying "I won't parse your CDB" after entering the
|
||||
// fast-fail state. Same treatment as HARDWARE_ERROR.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&illegal_request_err(), &mut ctx);
|
||||
assert!(matches!(action, ReadAction::JumpAhead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_failure_streak_extends_pause_on_pass_n() {
|
||||
// Pass N keeps the cooldown behaviour: after many consecutive
|
||||
@@ -514,15 +980,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_does_not_pause_on_skip() {
|
||||
// Pass 1 must zoom — a damage zone is Pass N's problem to
|
||||
// recover from. Sleeping between failed batches turned a 30s
|
||||
// damaged region into a 30-minute Pass-1 grind on real discs.
|
||||
fn pass_1_zone_entry_uses_long_cooldown() {
|
||||
// 2026-05-11 wedge-prevention rewrite: Pass 1's FIRST error
|
||||
// (zone entry) gets a 30 s ZONE_ENTRY_COOLDOWN_SECS pause +
|
||||
// a 2 s POST_JUMP_EXTRA on top (since we're also jumping).
|
||||
// The long pause prevents the retry cadence that triggers
|
||||
// firmware fast-fail. Subsequent errors in the same zone fall
|
||||
// back to the standard 5 s FAIL_PAUSE_SECS.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::SkipBlock { pause_secs } => assert_eq!(pause_secs, 0),
|
||||
other => panic!("expected SkipBlock, got {other:?}"),
|
||||
ReadAction::JumpAhead { pause_secs, .. } => {
|
||||
assert_eq!(
|
||||
pause_secs,
|
||||
ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS,
|
||||
"first-error pause should be 30 + 2 = 32 s"
|
||||
);
|
||||
}
|
||||
other => panic!("expected JumpAhead on first Pass 1 error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_pauses_uniformly_on_failed_read() {
|
||||
// Pass N (bisect_on_marginal=true) is exempt from the
|
||||
// zone-entry long pause — its whole job is to retry single
|
||||
// sectors on already-known-bad LBAs, and the 30 s pause every
|
||||
// single-sector failure would multiply slow recovery
|
||||
// pointlessly. Pass N keeps the standard 5 s FAIL_PAUSE_SECS.
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::SkipBlock { pause_secs } => assert_eq!(pause_secs, FAIL_PAUSE_SECS),
|
||||
ReadAction::JumpAhead { pause_secs, .. } => {
|
||||
assert_eq!(pause_secs, FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS)
|
||||
}
|
||||
ReadAction::Bisect => {}
|
||||
other => panic!("expected pausing action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
//! - Mapfile is single-writer (consumer-only). No locking.
|
||||
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
||||
//! - `set_speed` calls happen on the producer thread (same thread that
|
||||
//! owns the `SectorReader`). No new SCSI concurrency.
|
||||
//! owns the `SectorSource`). No new SCSI concurrency.
|
||||
//! - Per-iteration ordering of file-write → mapfile-record is kept
|
||||
//! intact in the consumer (write before record), so the on-disk
|
||||
//! invariant "mapfile only marks Finished what the file has
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ use crate::platform::PlatformDriver;
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
use crate::profile::{self, DriveProfile};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -707,7 +707,7 @@ fn open_block_device_for_sg(sg_path: &Path) -> Option<std::os::unix::io::RawFd>
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for Drive {
|
||||
impl SectorSource for Drive {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@
|
||||
|
||||
use crate::disc::{Codec, Resolution};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────────────────
|
||||
@@ -184,7 +184,7 @@ fn bcd_byte(b: u8) -> u32 {
|
||||
///
|
||||
/// Reads the VMG (Video Manager) to discover title sets, then reads each
|
||||
/// VTS IFO to extract PGC chains, cell addresses, and stream attributes.
|
||||
pub fn parse_vmg(reader: &mut dyn SectorReader, udf: &UdfFs) -> Result<DvdInfo> {
|
||||
pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo> {
|
||||
let vmg_data = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO")?;
|
||||
|
||||
// Validate VMG magic
|
||||
@@ -263,7 +263,7 @@ pub fn parse_vmg(reader: &mut dyn SectorReader, udf: &UdfFs) -> Result<DvdInfo>
|
||||
///
|
||||
/// `titles_info` is a list of (chapter_count, vts_title_number) from TT_SRPT.
|
||||
fn parse_vts(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
vts_number: u8,
|
||||
titles_info: &[(u16, u8)],
|
||||
|
||||
+4
-2
@@ -27,5 +27,7 @@ pub(crate) use writeback_file::WritebackFile;
|
||||
// caller — the targeted `#[allow]` keeps the re-export visible without
|
||||
// dragging the rest of the module under `dead_code`.
|
||||
#[allow(unused_imports)]
|
||||
pub use pipeline::WRITE_THROUGH_DEPTH;
|
||||
pub use pipeline::{DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink};
|
||||
pub use pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
+80
-20
@@ -27,15 +27,11 @@
|
||||
//! - Consumer panic is converted into
|
||||
//! `Error::IoError { source: io::Error::other(...) }`.
|
||||
//!
|
||||
//! ## Dead-code suppression
|
||||
//! ## Debug logging
|
||||
//!
|
||||
//! Both `Disc::sweep` (`DEFAULT_PIPELINE_DEPTH`, `spawn_named`,
|
||||
//! `try_send`) and `Disc::patch` (`WRITE_THROUGH_DEPTH`, `Flow::Stop`)
|
||||
//! are now in-tree callers (0.18 round 2). The targeted `#[allow]`
|
||||
//! markers below cover the corners of the API not exercised by either
|
||||
//! call site (e.g. `Pipeline::spawn` without a name; `Flow::Stop`
|
||||
//! return path from sweep's sink — sweep always consumes the producer's
|
||||
//! full work-list). Drop the allows once mux migrates.
|
||||
//! Set `FREEMKV_DEBUG=1` environment variable to enable verbose debug
|
||||
//! logging throughout the pipeline (channel sends/receives, backpressure,
|
||||
//! consumer lag detection). This is critical for diagnosing stalls.
|
||||
|
||||
use std::io;
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
@@ -43,20 +39,29 @@ use std::thread::{self, JoinHandle};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// Check if verbose debug logging is enabled via FREEMKV_DEBUG env var.
|
||||
pub fn debug_enabled() -> bool {
|
||||
std::env::var("FREEMKV_DEBUG")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v == "true" || v == "yes")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Default channel depth for callers without a specific reason to
|
||||
/// pick another value.
|
||||
///
|
||||
/// Empirically tuned for sweep and mux — both want enough slack that
|
||||
/// short consumer stalls don't immediately back up onto the producer,
|
||||
/// but not so much that a producer outpacing the consumer accumulates
|
||||
/// arbitrary buffered work. `4` matches the depth sweep has used
|
||||
/// since 0.17.11 (originally in `disc/sweep_pipeline.rs`, now in
|
||||
/// `disc/sweep.rs::SweepSink`). Patch should usually use
|
||||
/// [`WRITE_THROUGH_DEPTH`] (`1`) instead — write-through gives clean
|
||||
/// back-pressure between every read attempt and the matching write,
|
||||
/// which matters when the consumer is updating the mapfile in lockstep.
|
||||
/// pick another value. Kept conservative (4) — most callers should
|
||||
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
|
||||
|
||||
/// Read pipeline depth. Larger buffer compensates for drive variability
|
||||
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
|
||||
/// consumer blocks on write.
|
||||
pub const READ_PIPELINE_DEPTH: usize = 32;
|
||||
|
||||
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
||||
/// sync_file_range blocks; prevents producer from accumulating too much
|
||||
/// work while consumer waits for NFS to drain.
|
||||
pub const WRITE_PIPELINE_DEPTH: usize = 16;
|
||||
|
||||
/// Channel depth for write-through pipelines. Each `send` fully
|
||||
/// drains before the next can enqueue. Use this when the producer
|
||||
/// must observe consumer side-effects (e.g. mapfile state) before
|
||||
@@ -149,6 +154,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
let mut stopped = false;
|
||||
|
||||
while let Ok(item) = rx.recv() {
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline receive: item={}", std::any::type_name::<I>());
|
||||
}
|
||||
|
||||
let apply_start = std::time::Instant::now();
|
||||
|
||||
if first_err.is_some() || stopped {
|
||||
// Drain remaining items so the producer never
|
||||
// blocks on a dead receiver. `apply` is not
|
||||
@@ -159,11 +170,32 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
Ok(Flow::Continue) => {}
|
||||
Ok(Flow::Stop) => {
|
||||
stopped = true;
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline: consumer returned Flow::Stop");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
|
||||
}
|
||||
first_err = Some(e);
|
||||
}
|
||||
}
|
||||
|
||||
let apply_elapsed = apply_start.elapsed();
|
||||
if debug_enabled() && apply_elapsed > std::time::Duration::from_millis(100) {
|
||||
tracing::debug!(
|
||||
"Pipeline apply: took {:.2}s, item={}",
|
||||
apply_elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!(
|
||||
"Pipeline apply: OK in {:.3}ms, item={}",
|
||||
apply_elapsed.as_micros(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match first_err {
|
||||
@@ -188,7 +220,35 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// independent signal (e.g. `Halt`) — `send` alone is not the
|
||||
/// notification edge.
|
||||
pub fn send(&self, item: I) -> Result<(), I> {
|
||||
self.tx.send(item).map_err(|e| e.0)
|
||||
let start = std::time::Instant::now();
|
||||
match self.tx.send(item) {
|
||||
Ok(()) => {
|
||||
let elapsed = start.elapsed();
|
||||
if debug_enabled() && elapsed > std::time::Duration::from_millis(10) {
|
||||
tracing::debug!(
|
||||
"Pipeline send: blocked {:.2}s, item={}",
|
||||
elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let elapsed = start.elapsed();
|
||||
if debug_enabled() && elapsed > std::time::Duration::from_millis(10) {
|
||||
tracing::debug!(
|
||||
"Pipeline send: blocked {:.2}s before channel closed, item={}",
|
||||
elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
|
||||
}
|
||||
Err(e.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking variant of [`Pipeline::send`]. If the channel is
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::path::Path;
|
||||
|
||||
use super::writeback::WritebackPipeline;
|
||||
|
||||
const CHUNK_BYTES: u64 = 32 * 1024 * 1024;
|
||||
const WRITEBACK_CHUNK_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
pub(crate) struct WritebackFile {
|
||||
file: File,
|
||||
@@ -42,7 +42,7 @@ impl WritebackFile {
|
||||
/// or appended files).
|
||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
||||
let pos = file.stream_position()?;
|
||||
let pipeline = WritebackPipeline::new(&file, pos, CHUNK_BYTES);
|
||||
let pipeline = WritebackPipeline::new(&file, pos, WRITEBACK_CHUNK_BYTES);
|
||||
Ok(Self {
|
||||
file,
|
||||
pipeline,
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
//! BDMV disc-library metadata (`/BDMV/META/DL/bdmt_<lang>.xml`).
|
||||
//!
|
||||
//! Every commercial Blu-ray carries a disc-library metadata directory
|
||||
//! with one XML file per shipped language. The schema is the Blu-ray
|
||||
//! "disc library metadata" namespace (`urn:BDA:bdmv;disclibmeta`),
|
||||
//! conventionally prefixed `di:`. Fields commonly present:
|
||||
//!
|
||||
//! - `<di:title>` or `<di:name>` — the title string. Vendor practice
|
||||
//! varies (Paramount discs tend to use `<di:name>`).
|
||||
//! - `<di:description>` — optional synopsis (often absent on retail
|
||||
//! discs; common on box sets and special editions).
|
||||
//! - `<di:discNumber>` / `<di:numSets>` (or `<di:numberOfSets>`) —
|
||||
//! set position for multi-disc releases.
|
||||
//!
|
||||
//! This module is intentionally separate from the BD-J `StreamLabel`
|
||||
//! parsers under `labels/*.rs`. The XML here is disc-level (title,
|
||||
//! description, set position), not per-stream — wiring into the main
|
||||
//! parser registry happens elsewhere.
|
||||
//!
|
||||
//! Real-world XML is irregular: missing description elements, multiple
|
||||
//! title elements (first one wins), and occasional malformed content.
|
||||
//! Extraction is best-effort — a malformed file is treated as "no
|
||||
//! metadata" (returns `None` from the helper), and the caller can
|
||||
//! still get metadata from sibling-language XML files.
|
||||
|
||||
// The module wiring (registry hook + public re-export) is added
|
||||
// separately. Until then the parse/detect entry points have no
|
||||
use super::xml;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`.
|
||||
///
|
||||
/// All maps are keyed by 3-char ISO 639-2 language code (e.g.
|
||||
/// `"eng"`, `"fra"`, `"jpn"`) — the same key segment used in the
|
||||
/// `bdmt_<lang>.xml` filename.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct DiscMetadata {
|
||||
/// Localized titles, keyed by 3-char ISO 639-2 lang code
|
||||
/// (e.g. "eng" → "Dune Part Two")
|
||||
pub titles: BTreeMap<String, String>,
|
||||
/// First-line / short description, per lang
|
||||
pub descriptions: BTreeMap<String, String>,
|
||||
/// Disc N of M for box sets (None if not a box set)
|
||||
pub disc_number: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// True if `/BDMV/META/DL/` exists and contains at least one
|
||||
/// `bdmt_*.xml` file.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
let Some(dir) = udf.find_dir("/BDMV/META/DL") else {
|
||||
return false;
|
||||
};
|
||||
dir.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && is_bdmt_filename(&e.name))
|
||||
}
|
||||
|
||||
/// Read every `bdmt_<lang>.xml` under `/BDMV/META/DL/` and return the
|
||||
/// aggregated [`DiscMetadata`]. Returns `None` if no titles could be
|
||||
/// extracted from any file.
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata> {
|
||||
let dir = udf.find_dir("/BDMV/META/DL")?;
|
||||
let mut out = DiscMetadata::default();
|
||||
|
||||
for entry in &dir.entries {
|
||||
if entry.is_dir {
|
||||
continue;
|
||||
}
|
||||
let Some(lang) = lang_code_from_filename(&entry.name) else {
|
||||
continue;
|
||||
};
|
||||
let path = format!("/BDMV/META/DL/{}", entry.name);
|
||||
let Ok(bytes) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::str::from_utf8(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let Some((title, description, disc_set)) = parse_bdmt_xml(&lang, text) else {
|
||||
continue;
|
||||
};
|
||||
out.titles.insert(lang.clone(), title);
|
||||
if let Some(desc) = description {
|
||||
out.descriptions.insert(lang.clone(), desc);
|
||||
}
|
||||
// Disc-set position is disc-global; first one we successfully
|
||||
// read wins. (All bdmt_*.xml on a given disc carry the same
|
||||
// value in practice.)
|
||||
if out.disc_number.is_none() {
|
||||
if let Some(ds) = disc_set {
|
||||
out.disc_number = Some(ds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out.titles.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `name` matches the `bdmt_<lang>.xml` convention with a
|
||||
/// 3-character ISO 639-2 lang code segment. Case-insensitive.
|
||||
fn is_bdmt_filename(name: &str) -> bool {
|
||||
lang_code_from_filename(name).is_some()
|
||||
}
|
||||
|
||||
/// Extract the 3-char language code from a `bdmt_<lang>.xml` filename.
|
||||
/// Returns `None` if the filename doesn't match. Lang code is
|
||||
/// lowercased so callers always see e.g. `"eng"` not `"ENG"`.
|
||||
fn lang_code_from_filename(name: &str) -> Option<String> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let stem = lower.strip_suffix(".xml")?;
|
||||
let lang = stem.strip_prefix("bdmt_")?;
|
||||
// ISO 639-2 codes are exactly 3 ASCII letters. Be strict — keeps
|
||||
// us from picking up unrelated `bdmt_foo.xml` siblings.
|
||||
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
Some(lang.to_string())
|
||||
}
|
||||
|
||||
/// Tuple returned by [`parse_bdmt_xml`]: `(title, description?, disc_set?)`.
|
||||
/// Aliased so the function signature isn't a clippy::type-complexity offender.
|
||||
pub(crate) type BdmtFields = (String, Option<String>, Option<(u32, u32)>);
|
||||
|
||||
/// Parse one `bdmt_<lang>.xml` document and return
|
||||
/// `(title, description?, disc_set?)`. Returns `None` if no title
|
||||
/// could be located — the caller treats this as "skip this file".
|
||||
///
|
||||
/// Title-element preference: `<di:name>` → `<di:title>` →
|
||||
/// `<di:tableOfContents>/<di:titleName>` (first match wins, per the
|
||||
/// authoring-tool conventions documented at the module level).
|
||||
pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option<BdmtFields> {
|
||||
let title = extract_title(xml_text)?;
|
||||
let description = xml::text(xml_text, "description")
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|s| !looks_like_xml(s))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let disc_set = extract_disc_set(xml_text);
|
||||
Some((title, description, disc_set))
|
||||
}
|
||||
|
||||
/// Reject candidate description strings that are themselves XML
|
||||
/// fragments — observed on disc-04 (Top Gun: Maverick), where
|
||||
/// `<di:description>` contained `<di:thumbnail href="…"/>` child
|
||||
/// elements and no actual prose. Surfacing that raw to the JSON
|
||||
/// output is worse than dropping the field entirely.
|
||||
fn looks_like_xml(s: &str) -> bool {
|
||||
let t = s.trim_start();
|
||||
t.starts_with('<')
|
||||
}
|
||||
|
||||
/// Try title-bearing element variants in priority order. The `xml`
|
||||
/// helpers are case- and namespace-insensitive, so callers pass the
|
||||
/// bare local name (no `di:` prefix).
|
||||
fn extract_title(xml_text: &str) -> Option<String> {
|
||||
// Order matches the module-level convention: <di:name> first
|
||||
// (Paramount-style), then <di:title>, then the nested
|
||||
// tableOfContents/titleName form.
|
||||
for tag in ["name", "title"] {
|
||||
if let Some(s) = xml::text(xml_text, tag) {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// tableOfContents/titleName: search inside the toc block so we
|
||||
// don't accidentally pick a stray <titleName> from elsewhere.
|
||||
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
|
||||
let block = &xml_text[s..e];
|
||||
if let Some(t) = xml::text(block, "titleName") {
|
||||
let trimmed = t.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract `(discNumber, numSets)` if both are present and parse as
|
||||
/// `u32`. Accepts either `<di:numSets>` or `<di:numberOfSets>` for
|
||||
/// the denominator (both forms appear in the wild).
|
||||
fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> {
|
||||
let n = xml::text(xml_text, "discNumber")?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
let total = xml::text(xml_text, "numSets")
|
||||
.or_else(|| xml::text(xml_text, "numberOfSets"))?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
Some((n, total))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_simple_title() {
|
||||
// Minimal Paramount-style document: <di:name> as the title
|
||||
// carrier inside a <discInfo> root.
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Part Two</di:name>
|
||||
</discInfo>"#;
|
||||
let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse");
|
||||
assert_eq!(title, "Dune Part Two");
|
||||
assert_eq!(desc, None);
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_title_element_variant() {
|
||||
// <di:title> is the alternate carrier; should be picked up
|
||||
// when <di:name> is absent.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:title>The Matrix</di:title>
|
||||
<di:description>A film about computers.</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "The Matrix");
|
||||
assert_eq!(desc.as_deref(), Some("A film about computers."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_title_from_table_of_contents_fallback() {
|
||||
// Some authoring tools nest the title under tableOfContents.
|
||||
// No <di:name> or <di:title> at top level → fall back to
|
||||
// titleName inside tableOfContents.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:tableOfContents>
|
||||
<di:titleName>Inside Out 2</di:titleName>
|
||||
</di:tableOfContents>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "Inside Out 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_position() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>LOTR Disc 2</di:name>
|
||||
<di:discNumber>2</di:discNumber>
|
||||
<di:numSets>5</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, Some((2, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_position_alternate_total_tag() {
|
||||
// <di:numberOfSets> is an alternate spelling we've seen.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>3</di:discNumber>
|
||||
<di:numberOfSets>6</di:numberOfSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, Some((3, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_requires_both_fields() {
|
||||
// discNumber alone (no total) yields None — we don't fabricate
|
||||
// a denominator.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_languages_keyed_correctly() {
|
||||
// Simulate driving parse_bdmt_xml from two synthetic XML
|
||||
// blobs and aggregating into DiscMetadata the same way parse()
|
||||
// would. This exercises the BTreeMap key handling without
|
||||
// needing a UdfFs.
|
||||
let eng_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Part Two</di:name>
|
||||
</discInfo>"#;
|
||||
let fra_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Deuxième Partie</di:name>
|
||||
<di:description>Suite du film de 2021.</di:description>
|
||||
</discInfo>"#;
|
||||
|
||||
let mut meta = DiscMetadata::default();
|
||||
for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] {
|
||||
let (title, desc, ds) = parse_bdmt_xml(lang, blob).unwrap();
|
||||
meta.titles.insert(lang.to_string(), title);
|
||||
if let Some(d) = desc {
|
||||
meta.descriptions.insert(lang.to_string(), d);
|
||||
}
|
||||
if meta.disc_number.is_none() {
|
||||
if let Some(d) = ds {
|
||||
meta.disc_number = Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
meta.titles.get("eng").map(String::as_str),
|
||||
Some("Dune Part Two")
|
||||
);
|
||||
assert_eq!(
|
||||
meta.titles.get("fra").map(String::as_str),
|
||||
Some("Dune Deuxième Partie")
|
||||
);
|
||||
assert!(meta.descriptions.get("eng").is_none());
|
||||
assert_eq!(
|
||||
meta.descriptions.get("fra").map(String::as_str),
|
||||
Some("Suite du film de 2021.")
|
||||
);
|
||||
assert_eq!(meta.disc_number, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_xml_returns_none() {
|
||||
// Random gibberish has no recognizable title element. We
|
||||
// document the contract: parse_bdmt_xml returns None, and
|
||||
// parse() (the caller) skips the file. Aggregating across
|
||||
// zero files leaves DiscMetadata::default() — which parse()
|
||||
// surfaces as None to its caller. Either is documented as
|
||||
// acceptable per the module spec.
|
||||
let bad = "this is not xml &&& <<< nope";
|
||||
assert!(parse_bdmt_xml("eng", bad).is_none());
|
||||
|
||||
// Half-open tag, no body, no close: also yields no title.
|
||||
let truncated = "<discInfo><di:name>";
|
||||
assert!(parse_bdmt_xml("eng", truncated).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_with_only_child_xml_is_dropped() {
|
||||
// Real-world bug from disc-04 (Top Gun: Maverick, 2026-05-11
|
||||
// capture): <di:description> contained only <di:thumbnail/>
|
||||
// child elements with no actual prose. The previous parser
|
||||
// surfaced the raw XML fragment as the description string.
|
||||
// Now we reject candidates that begin with `<`.
|
||||
let xml = r#"<discInfo>
|
||||
<di:name>Top Gun: Maverick</di:name>
|
||||
<di:description>
|
||||
<di:thumbnail href="tgm_meta_sm.jpg" />
|
||||
<di:thumbnail href="tgm_meta_lg.jpg" />
|
||||
</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, description, _) =
|
||||
parse_bdmt_xml("eng", xml).expect("title is present so parse must succeed");
|
||||
assert_eq!(title, "Top Gun: Maverick");
|
||||
assert!(
|
||||
description.is_none(),
|
||||
"description containing only XML children must be dropped, got {description:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_with_plain_text_passes_through() {
|
||||
// The legitimate case still works: a description with actual
|
||||
// prose survives the looks_like_xml filter.
|
||||
let xml = r#"<discInfo>
|
||||
<di:name>Some Movie</di:name>
|
||||
<di:description>An epic tale of one man's quest for tea.</di:description>
|
||||
</discInfo>"#;
|
||||
let (_, description, _) = parse_bdmt_xml("eng", xml).expect("must parse");
|
||||
assert_eq!(
|
||||
description.as_deref(),
|
||||
Some("An epic tale of one man's quest for tea.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_in_title_is_trimmed() {
|
||||
let xml = r#"<discInfo><di:name>
|
||||
Dune Part Two
|
||||
</di:name></discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "Dune Part Two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_code_extraction() {
|
||||
assert_eq!(lang_code_from_filename("bdmt_eng.xml"), Some("eng".into()));
|
||||
assert_eq!(lang_code_from_filename("BDMT_FRA.XML"), Some("fra".into()));
|
||||
assert_eq!(lang_code_from_filename("bdmt_jpn.xml"), Some("jpn".into()));
|
||||
// Non-matching cases:
|
||||
assert_eq!(lang_code_from_filename("bdmt_.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_engl.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_e1g.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None);
|
||||
assert_eq!(lang_code_from_filename("foo.xml"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
//! CLPI vs MPLS cross-validation diagnostic.
|
||||
//!
|
||||
//! Empirical question (raised 2026-05-11): is CLPI's per-stream
|
||||
//! language and codec data truly redundant with MPLS's STN-table data
|
||||
//! on real-world Blu-rays?
|
||||
//!
|
||||
//! Build a quick audit that walks both sources, normalizes their stream
|
||||
//! lists by (PID, language, coding_type), and flags any disagreement.
|
||||
//!
|
||||
//! Three classes of mismatch we want to detect:
|
||||
//!
|
||||
//! 1. **CLPI has streams MPLS doesn't reference.** Orphan streams in
|
||||
//! the .m2ts that no playlist's STN table includes. Means the user
|
||||
//! can't reach them through the menu but they're physically on the
|
||||
//! disc.
|
||||
//! 2. **MPLS has streams CLPI doesn't list.** Should never happen if
|
||||
//! both parsers are correct — playlists reference clips which
|
||||
//! reference streams. If it happens, one of our parsers has a bug.
|
||||
//! 3. **Same PID, different language / coding_type.** The playlist re-
|
||||
//! tagged a stream's metadata. Rare but spec-permitted. Means CLPI
|
||||
//! and MPLS disagree about the same physical stream's properties.
|
||||
//!
|
||||
//! If audits across the corpus show zero mismatches of any class, CLPI
|
||||
//! program_info extraction is **empirically redundant** for labels and
|
||||
//! we can leave it out of the registry. If even one mismatch surfaces,
|
||||
//! we add a CLPI label parser to the registry as belt-and-suspenders.
|
||||
//!
|
||||
//! This module exposes `audit(reader, udf)` returning a structured
|
||||
//! report. Surfaced via the labels-analyze tool — not part of the
|
||||
//! `analyze()` pipeline (no impact on the label output).
|
||||
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One row in the audit: a stream PID that's known to one source or
|
||||
/// both, with the fields each source reported.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClpiVsMplsRow {
|
||||
pub pid: u16,
|
||||
pub clpi_coding_type: Option<u8>,
|
||||
pub clpi_language: Option<String>,
|
||||
pub mpls_coding_type: Option<u8>,
|
||||
pub mpls_language: Option<String>,
|
||||
}
|
||||
|
||||
impl ClpiVsMplsRow {
|
||||
/// Three rules for classification:
|
||||
/// - both sources missing (impossible — caller wouldn't insert)
|
||||
/// - one source missing → class A or B (orphan-on-disc / playlist-only)
|
||||
/// - both present but fields differ → class C (metadata divergence)
|
||||
/// - both present and identical → no mismatch
|
||||
pub fn class(&self) -> ClpiVsMplsClass {
|
||||
match (
|
||||
self.clpi_coding_type.is_some(),
|
||||
self.mpls_coding_type.is_some(),
|
||||
) {
|
||||
(true, false) => ClpiVsMplsClass::ClpiOnly,
|
||||
(false, true) => ClpiVsMplsClass::MplsOnly,
|
||||
(true, true) => {
|
||||
let coding_match = self.clpi_coding_type == self.mpls_coding_type;
|
||||
let lang_match = self.clpi_language == self.mpls_language;
|
||||
if coding_match && lang_match {
|
||||
ClpiVsMplsClass::Match
|
||||
} else {
|
||||
ClpiVsMplsClass::Divergent
|
||||
}
|
||||
}
|
||||
(false, false) => ClpiVsMplsClass::Match,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification of one (PID) row.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClpiVsMplsClass {
|
||||
/// PID seen in CLPI ProgramInfo but no MPLS STN table references
|
||||
/// it. Orphan on disc.
|
||||
ClpiOnly,
|
||||
/// PID seen in MPLS STN table but no CLPI ProgramInfo includes it.
|
||||
/// One of our parsers probably has a bug.
|
||||
MplsOnly,
|
||||
/// Both sources see this PID with the same coding_type + language.
|
||||
Match,
|
||||
/// Both sources see this PID but disagree on coding_type or language.
|
||||
/// MPLS wins for label rendering (playlist-authoritative view); CLPI
|
||||
/// is the per-clip ground truth.
|
||||
Divergent,
|
||||
}
|
||||
|
||||
/// Full audit report.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClpiVsMplsAudit {
|
||||
pub rows: Vec<ClpiVsMplsRow>,
|
||||
}
|
||||
|
||||
impl ClpiVsMplsAudit {
|
||||
pub fn class_counts(&self) -> (usize, usize, usize, usize) {
|
||||
let mut clpi_only = 0;
|
||||
let mut mpls_only = 0;
|
||||
let mut matches = 0;
|
||||
let mut divergent = 0;
|
||||
for r in &self.rows {
|
||||
match r.class() {
|
||||
ClpiVsMplsClass::ClpiOnly => clpi_only += 1,
|
||||
ClpiVsMplsClass::MplsOnly => mpls_only += 1,
|
||||
ClpiVsMplsClass::Match => matches += 1,
|
||||
ClpiVsMplsClass::Divergent => divergent += 1,
|
||||
}
|
||||
}
|
||||
(clpi_only, mpls_only, matches, divergent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `/BDMV/CLIPINF/*.clpi` and `/BDMV/PLAYLIST/*.mpls`, build a
|
||||
/// dedup-by-PID table of (CLPI fields, MPLS fields), return the
|
||||
/// merged view. Missing files (read errors, parse failures) are
|
||||
/// silently skipped — this is diagnostic, not correctness-critical.
|
||||
pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit {
|
||||
// Aggregate by PID across all CLPI files. If a PID appears in
|
||||
// multiple clips (typical — main movie clip + trailers reference
|
||||
// the same audio stream PIDs), first encountered wins (they should
|
||||
// all agree per BD spec).
|
||||
let mut clpi_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new();
|
||||
if let Some(dir) = udf.find_dir("/BDMV/CLIPINF") {
|
||||
let names: Vec<String> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".clpi"))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let path = format!("/BDMV/CLIPINF/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(clip) = crate::clpi::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
for s in clip.streams {
|
||||
clpi_by_pid
|
||||
.entry(s.pid)
|
||||
.or_insert((s.coding_type, s.language));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same for MPLS streams.
|
||||
let mut mpls_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new();
|
||||
if let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") {
|
||||
let names: Vec<String> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".mpls"))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pl) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
for s in pl.streams {
|
||||
if s.pid == 0 {
|
||||
// PID 0 means "no PID in stream entry" — skip rather
|
||||
// than collide with other entries.
|
||||
continue;
|
||||
}
|
||||
mpls_by_pid
|
||||
.entry(s.pid)
|
||||
.or_insert((s.coding_type, s.language));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge views: every PID seen anywhere gets a row.
|
||||
let mut all_pids: std::collections::BTreeSet<u16> = std::collections::BTreeSet::new();
|
||||
all_pids.extend(clpi_by_pid.keys().copied());
|
||||
all_pids.extend(mpls_by_pid.keys().copied());
|
||||
|
||||
let mut rows = Vec::with_capacity(all_pids.len());
|
||||
for pid in all_pids {
|
||||
let clpi = clpi_by_pid.get(&pid);
|
||||
let mpls = mpls_by_pid.get(&pid);
|
||||
rows.push(ClpiVsMplsRow {
|
||||
pid,
|
||||
clpi_coding_type: clpi.map(|(c, _)| *c),
|
||||
clpi_language: clpi.map(|(_, l)| l.clone()),
|
||||
mpls_coding_type: mpls.map(|(c, _)| *c),
|
||||
mpls_language: mpls.map(|(_, l)| l.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
ClpiVsMplsAudit { rows }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn class_match_when_identical() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("eng".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_clpi_only_when_mpls_missing() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: None,
|
||||
mpls_language: None,
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::ClpiOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_mpls_only_when_clpi_missing() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: None,
|
||||
clpi_language: None,
|
||||
mpls_coding_type: Some(0x90),
|
||||
mpls_language: Some("fra".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::MplsOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_divergent_on_lang_disagreement() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("und".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Divergent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_counts_sum_rows() {
|
||||
let audit = ClpiVsMplsAudit {
|
||||
rows: vec![
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("eng".into()),
|
||||
}, // Match
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1101,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("fra".into()),
|
||||
mpls_coding_type: None,
|
||||
mpls_language: None,
|
||||
}, // ClpiOnly
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1102,
|
||||
clpi_coding_type: None,
|
||||
clpi_language: None,
|
||||
mpls_coding_type: Some(0x90),
|
||||
mpls_language: Some("eng".into()),
|
||||
}, // MplsOnly
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1103,
|
||||
clpi_coding_type: Some(0x86),
|
||||
clpi_language: Some("spa".into()),
|
||||
mpls_coding_type: Some(0x86),
|
||||
mpls_language: Some("ita".into()),
|
||||
}, // Divergent
|
||||
],
|
||||
};
|
||||
let (co, mo, m, d) = audit.class_counts();
|
||||
assert_eq!(co, 1);
|
||||
assert_eq!(mo, 1);
|
||||
assert_eq!(m, 1);
|
||||
assert_eq!(d, 1);
|
||||
}
|
||||
}
|
||||
+56
-92
@@ -3,8 +3,8 @@
|
||||
//! Clean structured XML with Content/Qualifier per stream and
|
||||
//! stream number mapping via playbackconfig.
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
|
||||
use crate::sector::SectorReader;
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "streamproperties.xml")
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?;
|
||||
let sp_text = std::str::from_utf8(&sp_data).ok()?;
|
||||
|
||||
@@ -66,7 +66,8 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
// High confidence: streamproperties.xml is fully structured.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
|
||||
struct StreamInfo {
|
||||
@@ -78,105 +79,68 @@ struct StreamInfo {
|
||||
qualifier: LabelQualifier,
|
||||
}
|
||||
|
||||
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
|
||||
fn parse_stream_infos(text: &str) -> Vec<StreamInfo> {
|
||||
let mut infos = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < xml.len() {
|
||||
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Audio)
|
||||
} else if let Some(p) = xml[pos..].find("<SubtitleStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Subtitle)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
for (tag_name, stream_type) in [
|
||||
("AudioStreamInfos", StreamLabelType::Audio),
|
||||
("SubtitleStreamInfos", StreamLabelType::Subtitle),
|
||||
] {
|
||||
let mut from = 0;
|
||||
while let Some((start, end)) = xml::find_element(text, tag_name, from) {
|
||||
let block = &text[start..end];
|
||||
let id = xml::text(block, "ID").unwrap_or_default();
|
||||
let lang_id = xml::text(block, "LangInfoID").unwrap_or_default();
|
||||
let content = xml::text(block, "Content").unwrap_or_default();
|
||||
let qualifier_str = xml::text(block, "Qualifier").unwrap_or_default();
|
||||
|
||||
let end_tag = match stream_type {
|
||||
StreamLabelType::Audio => "</AudioStreamInfos>",
|
||||
StreamLabelType::Subtitle => "</SubtitleStreamInfos>",
|
||||
};
|
||||
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 {
|
||||
(lang_id.to_lowercase(), String::new())
|
||||
};
|
||||
|
||||
let block_end = match xml[tag..].find(end_tag) {
|
||||
Some(p) => tag + p + end_tag.len(),
|
||||
None => break,
|
||||
};
|
||||
let purpose = if content.eq_ignore_ascii_case("COMMENTARY") {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
|
||||
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();
|
||||
let qualifier = match qualifier_str.to_ascii_uppercase().as_str() {
|
||||
"SDH" => LabelQualifier::Sdh,
|
||||
"DS" => LabelQualifier::DescriptiveService,
|
||||
_ => LabelQualifier::None,
|
||||
};
|
||||
|
||||
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 {
|
||||
(lang_id.to_lowercase(), String::new())
|
||||
};
|
||||
|
||||
let purpose = match content.as_str() {
|
||||
"COMMENTARY" => LabelPurpose::Commentary,
|
||||
_ => LabelPurpose::Normal,
|
||||
};
|
||||
|
||||
let qualifier = match qualifier_str.as_str() {
|
||||
"SDH" => LabelQualifier::Sdh,
|
||||
"DS" => LabelQualifier::DescriptiveService,
|
||||
_ => LabelQualifier::None,
|
||||
};
|
||||
|
||||
infos.push(StreamInfo {
|
||||
id,
|
||||
stream_type,
|
||||
language,
|
||||
variant,
|
||||
purpose,
|
||||
qualifier,
|
||||
});
|
||||
pos = block_end;
|
||||
infos.push(StreamInfo {
|
||||
id,
|
||||
stream_type,
|
||||
language,
|
||||
variant,
|
||||
purpose,
|
||||
qualifier,
|
||||
});
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
infos
|
||||
}
|
||||
|
||||
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
|
||||
let mut pos = 0;
|
||||
while pos < xml.len() {
|
||||
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") {
|
||||
Some(p + pos)
|
||||
} else {
|
||||
xml[pos..].find("<SubtitlesStreams>").map(|p| p + pos)
|
||||
};
|
||||
|
||||
let tag_start = match tag_start {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
|
||||
let block_end = xml[tag_start..]
|
||||
.find("</AudioStreams>")
|
||||
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
|
||||
.map(|p| tag_start + p + 20)
|
||||
.unwrap_or(xml.len());
|
||||
|
||||
let block = &xml[tag_start..block_end];
|
||||
|
||||
if let (Some(stream_id_str), Some(info_id)) = (
|
||||
extract_tag(block, "StreamID"),
|
||||
extract_tag(block, "StreamInfo_ID"),
|
||||
) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
map.insert(info_id, stream_num);
|
||||
fn parse_playback_config(text: &str, map: &mut HashMap<String, u16>) {
|
||||
for tag_name in ["AudioStreams", "SubtitlesStreams"] {
|
||||
let mut from = 0;
|
||||
while let Some((start, end)) = xml::find_element(text, tag_name, from) {
|
||||
let block = &text[start..end];
|
||||
if let (Some(stream_id_str), Some(info_id)) = (
|
||||
xml::text(block, "StreamID"),
|
||||
xml::text(block, "StreamInfo_ID"),
|
||||
) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
map.insert(info_id, stream_num);
|
||||
}
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
|
||||
pos = block_end;
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = xml[start..].find(&close)? + start;
|
||||
Some(xml[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
+185
-17
@@ -4,8 +4,8 @@
|
||||
//! When both exist, language_streams.txt provides structured types while
|
||||
//! menu_base.prop provides stream number → button name mapping.
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType, vocab};
|
||||
use crate::sector::SectorReader;
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -14,7 +14,7 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
// Try language_streams.txt first (richer structured data)
|
||||
let ls_labels = parse_language_streams(reader, udf);
|
||||
|
||||
@@ -22,12 +22,18 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
let mb_labels = parse_menu_base(reader, 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,
|
||||
let labels = match (ls_labels, mb_labels) {
|
||||
(Some(ls), Some(mb)) => merge(ls, mb),
|
||||
(Some(ls), None) => ls,
|
||||
(None, Some(mb)) => mb,
|
||||
(None, None) => return None,
|
||||
};
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// High confidence: both language_streams.txt and menu_base.prop
|
||||
// are structured key-value formats with documented types.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
|
||||
fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
@@ -49,7 +55,7 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
|
||||
// ── language_streams.txt parser ────────────────────────────────────────────
|
||||
|
||||
fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "language_streams.txt")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
@@ -172,9 +178,167 @@ fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal menu_base.prop text and run `parse_menu_base`'s
|
||||
/// inner logic via a temporary closure. This isolates the prop
|
||||
/// parsing without needing a SectorSource.
|
||||
fn parse_props(text: &str) -> Vec<StreamLabel> {
|
||||
// Mirror the inner loop of parse_menu_base exactly. Kept
|
||||
// separate so the test doesn't need disc fixtures.
|
||||
use std::collections::HashMap;
|
||||
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 Some(eq_pos) = line.find('=') else {
|
||||
continue;
|
||||
};
|
||||
let full_key = &line[..eq_pos];
|
||||
let value = &line[eq_pos + 1..];
|
||||
if let Some(dot_pos) = full_key.rfind('.') {
|
||||
entries
|
||||
.entry(full_key[..dot_pos].to_string())
|
||||
.or_default()
|
||||
.insert(full_key[dot_pos + 1..].to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
let mut labels = Vec::new();
|
||||
for (prefix, props) in &entries {
|
||||
let is_audio = props
|
||||
.get("class")
|
||||
.is_some_and(|c| c.contains("AudioButton"))
|
||||
|| prefix.starts_with("audio_");
|
||||
let is_subtitle = props
|
||||
.get("class")
|
||||
.is_some_and(|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 purpose = match vocab::purpose(&name) {
|
||||
LabelPurpose::Normal if prefix.contains("comm") => LabelPurpose::Commentary,
|
||||
p => p,
|
||||
};
|
||||
let qualifier = if is_subtitle {
|
||||
vocab::qualifier(&name)
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
let stream_type = if is_audio {
|
||||
StreamLabelType::Audio
|
||||
} else {
|
||||
StreamLabelType::Subtitle
|
||||
};
|
||||
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(),
|
||||
});
|
||||
}
|
||||
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
|
||||
labels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commentary_via_name() {
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=2\n\
|
||||
audio_1.name=Director's Commentary\n\
|
||||
audio_1.audioLanguage=eng\n",
|
||||
);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commentary_via_prefix_when_name_silent() {
|
||||
let labels = parse_props(
|
||||
"audio_commentary_1.class=AudioButton\n\
|
||||
audio_commentary_1.streamNumber=2\n\
|
||||
audio_commentary_1.name=Track 2\n\
|
||||
audio_commentary_1.audioLanguage=eng\n",
|
||||
);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commenter_does_not_false_match_commentary() {
|
||||
// Regression for the pre-refactor `name.contains("comment")`
|
||||
// bug: this would wrongly classify a "Commenter Pro" track as
|
||||
// Commentary. vocab::purpose enforces a word boundary.
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=2\n\
|
||||
audio_1.name=Commenter Pro Track\n\
|
||||
audio_1.audioLanguage=eng\n",
|
||||
);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_via_name() {
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=3\n\
|
||||
audio_1.name=English Descriptive Audio\n",
|
||||
);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Descriptive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdh_only_on_subtitles() {
|
||||
// SDH applied to a subtitle stream.
|
||||
let labels = parse_props(
|
||||
"subtitle_1.class=SubtitleButton\n\
|
||||
subtitle_1.streamNumber=4\n\
|
||||
subtitle_1.name=English SDH\n",
|
||||
);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdh_not_applied_to_audio_stream_even_if_name_contains_sdh() {
|
||||
// Audio streams should not pick up SDH (it's a subtitle
|
||||
// concept). Edge case: badly-authored name happens to include
|
||||
// "SDH" — we don't propagate it to audio metadata.
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=5\n\
|
||||
audio_1.name=English SDH (track?)\n",
|
||||
);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
}
|
||||
|
||||
// ── menu_base.prop parser ──────────────────────────────────────────────────
|
||||
|
||||
fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "menu_base.prop")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
@@ -231,16 +395,20 @@ fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<Str
|
||||
}
|
||||
|
||||
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
|
||||
// Purpose: ask vocab first (word-boundary matched — avoids the
|
||||
// "Commenter" false positive the prior `name.contains("comment")`
|
||||
// had). Then fall back to the structural prefix check
|
||||
// (`audio_commentary.foo`-style keys group commentary streams
|
||||
// regardless of display name).
|
||||
let purpose = match vocab::purpose(&name) {
|
||||
LabelPurpose::Normal if prefix.contains("comm") => LabelPurpose::Commentary,
|
||||
p => p,
|
||||
};
|
||||
|
||||
let qualifier = if is_subtitle && name_lower.contains("sdh") {
|
||||
LabelQualifier::Sdh
|
||||
// Qualifier: only apply to subtitles (SDH is a subtitle concept).
|
||||
let qualifier = if is_subtitle {
|
||||
vocab::qualifier(&name)
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! "dbp" framework — Magnolia Pictures BD-J authoring shop (per
|
||||
//! `bd-live.magpictures.com` referenced in the disc's
|
||||
//! `com/dbp/bluray.MenuXlet.perm`). Detected on UHD discs whose
|
||||
//! `/BDMV/JAR/<x>.jar` (top-level, not in a subdir) contains
|
||||
//! `com/dbp/` package paths.
|
||||
//!
|
||||
//! Stream labels live as plain ASCII strings inside compiled `.class`
|
||||
//! files in the jar — a quirk of the menu-rendering layer encoding
|
||||
//! its TextField positions and content as constant strings the
|
||||
//! Java compiler retained in the class string pool. Format observed
|
||||
//! in the corpus (Civil War UHD, 2024):
|
||||
//!
|
||||
//! ```text
|
||||
//! LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,...
|
||||
//! RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,...
|
||||
//! HTextField,Subtitle1,English SDH,Fontstrip_Composite,...
|
||||
//! ATextField,Subtitle0,None,Fontstrip_Composite,...
|
||||
//! ```
|
||||
//!
|
||||
//! The single uppercase letter before `TextField` is string-pool
|
||||
//! prefix noise — the parser anchors on `TextField,` regardless of
|
||||
//! what precedes it. `Subtitle0` is the disable-subtitles menu
|
||||
//! button and is skipped (not a real subtitle stream).
|
||||
//!
|
||||
//! ## Implementation
|
||||
//!
|
||||
//! v2 (2026-05-10): rewritten on top of [`super::class_reader`] —
|
||||
//! iterates `CpInfo::Utf8` constant-pool entries instead of raw byte
|
||||
//! scanning each class file. Equivalent label coverage (the literal
|
||||
//! `TextField,...` strings live in the CP as Utf8 entries), but
|
||||
//! structurally cleaner: no false-positive risk from method bytecode
|
||||
//! or attribute names happening to contain `TextField,`. Language /
|
||||
//! purpose / qualifier classification moved to [`super::vocab`] so all
|
||||
//! Java-parser families share one source of truth.
|
||||
|
||||
use super::class_reader::CpInfo;
|
||||
use super::{ParseResult, StreamLabel, StreamLabelType, jar, vocab};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// dbp detect can't peek inside a jar without a SectorSource (the
|
||||
/// trait function only takes `&UdfFs`), so we trigger on the cheap
|
||||
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
|
||||
/// BD-J disc, but parse() does the real `com/dbp/` check and
|
||||
/// returns None on a mismatch — so this parser only ever consumes
|
||||
/// time on discs that fell through every earlier parser.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
jar::has_any_top_level_jar(udf)
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
jar::for_each_jar(reader, udf, |_entry_name, archive| {
|
||||
if !jar::has_path_prefix(archive, "com/dbp/") {
|
||||
return None;
|
||||
}
|
||||
let labels = scan_jar(archive);
|
||||
if labels.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// High confidence: TextField,Audio1,... is a stable anchor
|
||||
// pattern + vocab routes language/purpose/qualifier.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_jar(archive: &mut jar::Jar) -> Vec<StreamLabel> {
|
||||
// BTreeMap so we keep the highest-numbered (last-written) label
|
||||
// for each stream slot deterministic across runs. The same
|
||||
// TextField,Audio1,... string can appear in multiple classes
|
||||
// (button-state variants, localization fallbacks). Last write
|
||||
// wins — they should all agree, but the structure is defensive.
|
||||
let mut audios: BTreeMap<u16, String> = BTreeMap::new();
|
||||
let mut subs: BTreeMap<u16, String> = BTreeMap::new();
|
||||
|
||||
jar::for_each_class(archive, |_class_name, class| {
|
||||
for (_idx, cp) in class.constant_pool.iter() {
|
||||
if let CpInfo::Utf8(s) = cp {
|
||||
collect_textfield(s, &mut audios, &mut subs);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (num, label) in audios {
|
||||
out.push(make_label(num, label, StreamLabelType::Audio));
|
||||
}
|
||||
for (num, label) in subs {
|
||||
out.push(make_label(num, label, StreamLabelType::Subtitle));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_textfield(
|
||||
s: &str,
|
||||
audios: &mut BTreeMap<u16, String>,
|
||||
subs: &mut BTreeMap<u16, String>,
|
||||
) {
|
||||
// Anchor on "TextField," — the prefix character before it varies
|
||||
// (string-pool ordering inside compiled Java) and is irrelevant.
|
||||
let Some(idx) = s.find("TextField,") else {
|
||||
return;
|
||||
};
|
||||
let after = &s[idx + "TextField,".len()..];
|
||||
let mut parts = after.splitn(3, ',');
|
||||
let kind_n = parts.next().unwrap_or("").trim();
|
||||
let label = parts.next().unwrap_or("").trim();
|
||||
if label.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(rest) = kind_n.strip_prefix("Audio") {
|
||||
if let Ok(n) = rest.parse::<u16>() {
|
||||
audios.insert(n, label.to_string());
|
||||
}
|
||||
} else if let Some(rest) = kind_n.strip_prefix("Subtitle") {
|
||||
if let Ok(n) = rest.parse::<u16>() {
|
||||
// Subtitle0 is conventionally the "None / Off" disable
|
||||
// button, not an actual subtitle stream.
|
||||
if n > 0 {
|
||||
subs.insert(n, label.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLabel {
|
||||
let lang_info = vocab::lang(&label);
|
||||
let language = lang_info.map(|l| l.code).unwrap_or("").to_string();
|
||||
let variant = lang_info.map(|l| l.variant).unwrap_or("").to_string();
|
||||
let qualifier = vocab::qualifier(&label);
|
||||
let purpose = vocab::purpose(&label);
|
||||
StreamLabel {
|
||||
stream_number: num,
|
||||
stream_type,
|
||||
language,
|
||||
name: label,
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{LabelPurpose, LabelQualifier};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collect_extracts_audio_and_subtitle_indices() {
|
||||
let mut audios = BTreeMap::new();
|
||||
let mut subs = BTreeMap::new();
|
||||
let lines = [
|
||||
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763,275,25,left",
|
||||
"RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,296,803,275,25,left",
|
||||
"RTextField,Audio3,Spanish 5.1 Dolby Digital,Fontstrip_Composite,296,843,275,25,left",
|
||||
"ATextField,Subtitle0,None,Fontstrip_Composite,1312,843,275,25,left",
|
||||
"HTextField,Subtitle1,English SDH,Fontstrip_Composite,1312,763,275,25,left",
|
||||
"DTextField,Subtitle2,Spanish,Fontstrip_Composite,1312,803,275,25,left",
|
||||
];
|
||||
for s in &lines {
|
||||
collect_textfield(s, &mut audios, &mut subs);
|
||||
}
|
||||
assert_eq!(audios.len(), 3);
|
||||
assert_eq!(audios[&1], "English Dolby Atmos");
|
||||
assert_eq!(audios[&2], "English Descriptive Audio");
|
||||
assert_eq!(audios[&3], "Spanish 5.1 Dolby Digital");
|
||||
// Subtitle0 ("None") is skipped — disable button, not a stream.
|
||||
assert_eq!(subs.len(), 2);
|
||||
assert_eq!(subs[&1], "English SDH");
|
||||
assert_eq!(subs[&2], "Spanish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_ignores_non_textfield_strings() {
|
||||
let mut audios = BTreeMap::new();
|
||||
let mut subs = BTreeMap::new();
|
||||
for s in [
|
||||
"GraphicButton,SU_Audio",
|
||||
"AudioMenu",
|
||||
"CommentaryMenuAlternateScenes",
|
||||
"PrimaryAudioControl",
|
||||
] {
|
||||
collect_textfield(s, &mut audios, &mut subs);
|
||||
}
|
||||
assert!(audios.is_empty());
|
||||
assert!(subs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_routes_via_vocab() {
|
||||
let l = make_label(1, "English SDH".to_string(), StreamLabelType::Subtitle);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.qualifier, LabelQualifier::Sdh);
|
||||
assert_eq!(l.purpose, LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_descriptive_audio() {
|
||||
let l = make_label(
|
||||
2,
|
||||
"English Descriptive Audio".to_string(),
|
||||
StreamLabelType::Audio,
|
||||
);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.purpose, LabelPurpose::Descriptive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_commentary() {
|
||||
let l = make_label(
|
||||
3,
|
||||
"English Director's Commentary".to_string(),
|
||||
StreamLabelType::Audio,
|
||||
);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_compound_languages_populate_variant() {
|
||||
let brazilian = make_label(1, "Brazilian Portuguese 5.1".into(), StreamLabelType::Audio);
|
||||
assert_eq!(brazilian.language, "por");
|
||||
assert_eq!(brazilian.variant, "Brazilian");
|
||||
|
||||
let castilian = make_label(1, "Castilian Spanish".into(), StreamLabelType::Audio);
|
||||
assert_eq!(castilian.language, "spa");
|
||||
assert_eq!(castilian.variant, "Castilian");
|
||||
|
||||
let canadian = make_label(
|
||||
1,
|
||||
"Canadian French Dolby Digital".into(),
|
||||
StreamLabelType::Audio,
|
||||
);
|
||||
assert_eq!(canadian.language, "fra");
|
||||
assert_eq!(canadian.variant, "Canadian");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_bare_language_has_empty_variant() {
|
||||
let l = make_label(1, "English Dolby Atmos".into(), StreamLabelType::Audio);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.variant, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_unknown_language_is_empty() {
|
||||
// vocab::lang returns None — make_label converts both fields to "".
|
||||
let l = make_label(1, "Klingon Dolby Atmos".into(), StreamLabelType::Audio);
|
||||
assert_eq!(l.language, "");
|
||||
assert_eq!(l.variant, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_rnib_descriptive_service() {
|
||||
let l = make_label(1, "English RNIB".into(), StreamLabelType::Subtitle);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.qualifier, LabelQualifier::DescriptiveService);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
//! BD-J jar utilities — common scaffolding for parsers that read
|
||||
//! `/BDMV/JAR/*.jar`.
|
||||
//!
|
||||
//! Composes with [`class_reader`](super::class_reader) for structured
|
||||
//! `.class` access. Used by `dbp` (string-pool scan via constant pool)
|
||||
//! and `deluxe` (bytecode pattern matching) — those parsers express
|
||||
//! "open every top-level jar, look at every .class inside" without
|
||||
//! repeating the zip-archive boilerplate.
|
||||
|
||||
// `try_each_class` is staged for `labels::deluxe`, which needs the
|
||||
// early-return form to short-circuit class iteration on a match.
|
||||
// dead-code allow comes off when deluxe lands.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::class_reader::ClassFile;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// In-memory zip archive: backed by a `Vec<u8>` read from UDF. Owns
|
||||
/// the buffer; callers pass it to [`has_path_prefix`], [`for_each_class`],
|
||||
/// etc.
|
||||
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
|
||||
|
||||
/// True if `/BDMV/JAR/` contains at least one top-level `.jar` file
|
||||
/// (not under a subdir). Used by `detect()` in parsers whose real
|
||||
/// signal lives inside a jar — they can't open the jar without a
|
||||
/// `SectorSource`, so they use this cheap pre-check and do the real
|
||||
/// `com/<vendor>/` discriminator in `parse()`.
|
||||
pub fn has_any_top_level_jar(udf: &UdfFs) -> bool {
|
||||
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
||||
return false;
|
||||
};
|
||||
jar_dir
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && e.name.to_lowercase().ends_with(".jar"))
|
||||
}
|
||||
|
||||
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
|
||||
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
||||
/// produces, or `None` if every jar was visited without a hit.
|
||||
///
|
||||
/// "Top-level" means entries directly under `/BDMV/JAR/`, not nested
|
||||
/// under a subdir. (Pixelogic, Criterion, Paramount, etc. put their
|
||||
/// data files inside `/BDMV/JAR/<x>/`; dbp and Deluxe put their jar
|
||||
/// directly at `/BDMV/JAR/<name>.jar`.)
|
||||
///
|
||||
/// Entries that fail to read from UDF or that aren't valid zips are
|
||||
/// silently skipped — same defensive shape as the existing dbp parser.
|
||||
pub fn for_each_jar<R, F>(reader: &mut dyn SectorSource, udf: &UdfFs, mut f: F) -> Option<R>
|
||||
where
|
||||
F: FnMut(&str, &mut Jar) -> Option<R>,
|
||||
{
|
||||
let jar_dir = udf.find_dir("/BDMV/JAR")?;
|
||||
for entry in &jar_dir.entries {
|
||||
if entry.is_dir {
|
||||
continue;
|
||||
}
|
||||
if !entry.name.to_lowercase().ends_with(".jar") {
|
||||
continue;
|
||||
}
|
||||
let path = format!("/BDMV/JAR/{}", entry.name);
|
||||
let Ok(bytes) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut archive) = ZipArchive::new(Cursor::new(bytes)) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(r) = f(&entry.name, &mut archive) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True if any entry in this jar's central directory starts with
|
||||
/// `prefix`. Fast — only reads filenames, never extracts bytes.
|
||||
///
|
||||
/// Used by parsers as a cheap "is this MY framework's jar?" check
|
||||
/// (e.g. `has_path_prefix(archive, "com/dbp/")` for dbp,
|
||||
/// `has_path_prefix(archive, "com/bydeluxe/")` for Deluxe).
|
||||
pub fn has_path_prefix(archive: &mut Jar, prefix: &str) -> bool {
|
||||
for i in 0..archive.len() {
|
||||
if let Ok(f) = archive.by_index(i) {
|
||||
if f.name().starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Iterate every `.class` entry in the jar, parse it with
|
||||
/// [`class_reader`], and call `f` with `(entry_name, &ClassFile)`.
|
||||
///
|
||||
/// Entries that fail to read or parse are silently skipped — this is
|
||||
/// label-extraction code, robustness matters more than completeness.
|
||||
/// Callers that need to know which classes failed should use the
|
||||
/// lower-level [`class_reader`] API directly.
|
||||
pub fn for_each_class<F>(archive: &mut Jar, mut f: F)
|
||||
where
|
||||
F: FnMut(&str, &ClassFile),
|
||||
{
|
||||
for i in 0..archive.len() {
|
||||
let Ok(mut entry) = archive.by_index(i) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.name().ends_with(".class") {
|
||||
continue;
|
||||
}
|
||||
let name = entry.name().to_string();
|
||||
let mut bytes = Vec::with_capacity(entry.size() as usize);
|
||||
if std::io::Read::read_to_end(&mut entry, &mut bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(class) = ClassFile::parse(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
f(&name, &class);
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`for_each_class`] but allows the callback to short-circuit
|
||||
/// iteration. Returns the first `Some(R)` the callback produces.
|
||||
pub fn try_each_class<R, F>(archive: &mut Jar, mut f: F) -> Option<R>
|
||||
where
|
||||
F: FnMut(&str, &ClassFile) -> Option<R>,
|
||||
{
|
||||
for i in 0..archive.len() {
|
||||
let Ok(mut entry) = archive.by_index(i) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.name().ends_with(".class") {
|
||||
continue;
|
||||
}
|
||||
let name = entry.name().to_string();
|
||||
let mut bytes = Vec::with_capacity(entry.size() as usize);
|
||||
if std::io::Read::read_to_end(&mut entry, &mut bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(class) = ClassFile::parse(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(r) = f(&name, &class) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
+1068
-46
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
//! Universal MPLS-based stream labels.
|
||||
//!
|
||||
//! Unlike the framework-specific parsers in this directory (dbp,
|
||||
//! pixelogic, ctrm, criterion, ...), this module is the *floor*:
|
||||
//! every Blu-ray ships with MPLS playlists under `/BDMV/PLAYLIST/`,
|
||||
//! and every MPLS file has an STN table with per-stream ISO 639-2
|
||||
//! language codes plus coding-type / channel-layout / sample-rate
|
||||
//! bytes from the BD spec.
|
||||
//!
|
||||
//! The framework parsers extract richer editorial labels ("English
|
||||
//! Dolby Atmos", "Director's Commentary") when the disc was authored
|
||||
//! with a recognized tool. When none of them match (e.g. a "no BD-J"
|
||||
//! disc, or an authoring framework we haven't catalogued), MPLS still
|
||||
//! gives us language + codec on every stream — enough to render
|
||||
//! something more useful than the bare PID.
|
||||
//!
|
||||
//! Output confidence is Low: MPLS carries language + codec but
|
||||
//! never purpose/qualifier info (no way to tell "Commentary" from
|
||||
//! "Normal" from the STN table alone). Higher-confidence framework
|
||||
//! parsers, when present, always win on the registry's max-by-confidence
|
||||
//! tiebreaker — MPLS is only chosen when nothing else matched.
|
||||
|
||||
use super::{
|
||||
LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType,
|
||||
vocab::{self, LangInfo},
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
|
||||
/// `.mpls` file. Cheap directory walk only — no sector reads.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
|
||||
return false;
|
||||
};
|
||||
dir.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && has_mpls_extension(&e.name))
|
||||
}
|
||||
|
||||
/// Walk every `*.mpls` in `/BDMV/PLAYLIST/`, parse it, and convert
|
||||
/// each StreamEntry to a [`StreamLabel`]. Streams shared across
|
||||
/// playlists (same PID) are deduped.
|
||||
///
|
||||
/// Returns `None` if no labels could be produced (e.g. no .mpls files
|
||||
/// parsed successfully, or every parsed stream was a type we skip
|
||||
/// like IG / DV EL).
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let playlist_dir = udf.find_dir("/BDMV/PLAYLIST")?;
|
||||
|
||||
// Collect mpls filenames first so we don't hold a borrow on udf
|
||||
// while we call udf.read_file (which takes &self).
|
||||
let mpls_names: Vec<String> = playlist_dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && has_mpls_extension(&e.name))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
|
||||
if mpls_names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
// (stream_type_tag, language, codec_hint, pid) — PID is the
|
||||
// canonical "same physical stream" key; type+lang+codec round
|
||||
// out the rare case where two distinct logical streams happen
|
||||
// to share a PID across playlists with different metadata.
|
||||
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
|
||||
|
||||
// Global 1-based counters keyed by StreamLabelType. Incremented
|
||||
// only when an entry survives dedup, so stream_numbers are dense
|
||||
// (1, 2, 3, ...) per type across the whole disc — not reset per
|
||||
// playlist. A disc with 2 MPLS files that each list the same
|
||||
// 8 audio streams ends up with audio_1..audio_8, not audio_1..
|
||||
// audio_16 or audio_1..audio_8 with audio_1 duplicated.
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for name in &mpls_names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(playlist) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio, // primary + secondary audio
|
||||
3 => StreamLabelType::Subtitle, // PG subtitle
|
||||
// 1 = primary video, 6 = secondary video, 7 = DV EL
|
||||
// → no StreamLabelType variant for video, skip.
|
||||
// 4 = IG (interactive graphics) — not a user-facing
|
||||
// stream, skip.
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
|
||||
let type_tag = type_tag(label_type);
|
||||
let key = (type_tag, language.clone(), codec_hint.clone(), entry.pid);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
|
||||
let stream_number = match label_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_idx += 1;
|
||||
audio_idx
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_idx += 1;
|
||||
sub_idx
|
||||
}
|
||||
};
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: label_type,
|
||||
language,
|
||||
name,
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint,
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// MPLS gives language + codec but never editorial info (no
|
||||
// commentary/SDH/director's cut). Low confidence means framework
|
||||
// parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always
|
||||
// win when they match. MPLS only gets chosen as the parser when
|
||||
// nothing else fired — exactly the universal-fallback role we want.
|
||||
Some(ParseResult::low(labels))
|
||||
}
|
||||
|
||||
fn has_mpls_extension(name: &str) -> bool {
|
||||
// Case-insensitive ".mpls" suffix. Some discs use uppercase,
|
||||
// some lowercase; UDF filenames preserve case but we don't.
|
||||
let n = name.len();
|
||||
if n < 5 {
|
||||
return false;
|
||||
}
|
||||
name[n - 5..].eq_ignore_ascii_case(".mpls")
|
||||
}
|
||||
|
||||
/// Lowercase + trim the raw 3-char ISO 639-2 code. If the lowered
|
||||
/// string maps via [`vocab::lang`] (it won't for plain "eng" — that
|
||||
/// matcher is for English-name fragments, not codes) use its
|
||||
/// canonical code; otherwise return the trimmed lowercase string.
|
||||
fn normalize_language(raw: &str) -> String {
|
||||
let trimmed = raw.trim().to_ascii_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// vocab::lang() matches free-form English names, not ISO 639-2
|
||||
// codes — so for the typical MPLS payload ("eng", "fra", ...)
|
||||
// it returns None and we keep the trimmed code.
|
||||
if let Some(LangInfo { code, .. }) = vocab::lang(&trimmed) {
|
||||
return code.to_string();
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
/// Human-readable English name for an ISO 639-2 code, or empty if
|
||||
/// the code is unknown. Kept inline rather than in vocab because
|
||||
/// vocab is the *reverse* mapping (name → code).
|
||||
pub(crate) fn language_display_name(iso: &str) -> String {
|
||||
match iso {
|
||||
"eng" => "English",
|
||||
"fra" | "fre" => "French",
|
||||
"spa" => "Spanish",
|
||||
"deu" | "ger" => "German",
|
||||
"ita" => "Italian",
|
||||
"jpn" => "Japanese",
|
||||
"zho" | "chi" => "Chinese",
|
||||
"kor" => "Korean",
|
||||
"por" => "Portuguese",
|
||||
"pol" => "Polish",
|
||||
"ces" | "cze" => "Czech",
|
||||
"hun" => "Hungarian",
|
||||
"nld" | "dut" => "Dutch",
|
||||
"ara" => "Arabic",
|
||||
"hin" => "Hindi",
|
||||
"tur" => "Turkish",
|
||||
"tha" => "Thai",
|
||||
"swe" => "Swedish",
|
||||
"nor" => "Norwegian",
|
||||
"dan" => "Danish",
|
||||
"fin" => "Finnish",
|
||||
"heb" => "Hebrew",
|
||||
"rus" => "Russian",
|
||||
"ell" | "gre" => "Greek",
|
||||
"vie" => "Vietnamese",
|
||||
"ind" => "Indonesian",
|
||||
"msa" | "may" => "Malay",
|
||||
"ukr" => "Ukrainian",
|
||||
"ron" | "rum" => "Romanian",
|
||||
"bul" => "Bulgarian",
|
||||
"hrv" => "Croatian",
|
||||
"srp" => "Serbian",
|
||||
"slk" | "slo" => "Slovak",
|
||||
"slv" => "Slovenian",
|
||||
"est" => "Estonian",
|
||||
"lav" => "Latvian",
|
||||
"lit" => "Lithuanian",
|
||||
"isl" | "ice" => "Icelandic",
|
||||
"eus" | "baq" => "Basque",
|
||||
"cat" => "Catalan",
|
||||
"glg" => "Galician",
|
||||
_ => "",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Map BD coding_type byte → codec name. Returns empty for unknown
|
||||
/// bytes (the table covers everything the spec defines, but unknown
|
||||
/// values are still possible on malformed discs).
|
||||
pub(crate) fn codec_name(coding_type: u8) -> &'static str {
|
||||
match coding_type {
|
||||
0x02 => "MPEG-2",
|
||||
0x1B => "H.264",
|
||||
0x24 => "HEVC",
|
||||
0x80 => "LPCM",
|
||||
0x81 => "AC-3",
|
||||
0x82 => "DTS",
|
||||
0x83 => "TrueHD",
|
||||
0x84 => "AC-3+",
|
||||
0x85 => "DTS-HD",
|
||||
0x86 => "DTS-HD MA",
|
||||
0x90 => "PG",
|
||||
0x91 => "IG",
|
||||
0xA1 => "AC-3+ Secondary",
|
||||
0xA2 => "DTS-HD Secondary",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the final `codec_hint`. For audio streams, optionally
|
||||
/// append " <channels>" and/or " <rate>" suffixes. Sample rate is
|
||||
/// only spelled out for non-48k (the universal default).
|
||||
fn build_codec_hint(label_type: StreamLabelType, entry: &crate::mpls::StreamEntry) -> String {
|
||||
let base = codec_name(entry.coding_type);
|
||||
if base.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if label_type != StreamLabelType::Audio {
|
||||
return base.to_string();
|
||||
}
|
||||
|
||||
let mut out = base.to_string();
|
||||
|
||||
let channels = match entry.audio_format {
|
||||
1 => Some("mono"),
|
||||
3 => Some("2.0"),
|
||||
6 => Some("5.1"),
|
||||
12 => Some("7.1"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ch) = channels {
|
||||
out.push(' ');
|
||||
out.push_str(ch);
|
||||
}
|
||||
|
||||
// 1 = 48 kHz (universal default, omit). Only call out higher rates.
|
||||
let rate = match entry.audio_rate {
|
||||
4 => Some("96kHz"),
|
||||
5 => Some("192kHz"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = rate {
|
||||
out.push(' ');
|
||||
out.push_str(r);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Dedup-tag for the label type. `u8` instead of `StreamLabelType`
|
||||
/// itself because the enum does not derive `Hash` / `Eq`-by-discriminant
|
||||
/// in a way that we want to couple to (and `==` works fine for the
|
||||
/// linear `Vec::contains` lookup we do).
|
||||
fn type_tag(t: StreamLabelType) -> u8 {
|
||||
match t {
|
||||
StreamLabelType::Audio => 1,
|
||||
StreamLabelType::Subtitle => 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mpls::{Playlist, StreamEntry};
|
||||
|
||||
fn audio_entry(pid: u16, coding: u8, fmt: u8, rate: u8, lang: &str) -> StreamEntry {
|
||||
StreamEntry {
|
||||
stream_type: 2,
|
||||
pid,
|
||||
coding_type: coding,
|
||||
video_format: 0,
|
||||
video_rate: 0,
|
||||
audio_format: fmt,
|
||||
audio_rate: rate,
|
||||
language: lang.to_string(),
|
||||
dynamic_range: 0,
|
||||
color_space: 0,
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn pg_entry(pid: u16, lang: &str) -> StreamEntry {
|
||||
StreamEntry {
|
||||
stream_type: 3,
|
||||
pid,
|
||||
coding_type: 0x90,
|
||||
video_format: 0,
|
||||
video_rate: 0,
|
||||
audio_format: 0,
|
||||
audio_rate: 0,
|
||||
language: lang.to_string(),
|
||||
dynamic_range: 0,
|
||||
color_space: 0,
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn playlist_with(streams: Vec<StreamEntry>) -> Playlist {
|
||||
Playlist {
|
||||
version: "0200".to_string(),
|
||||
play_items: Vec::new(),
|
||||
streams,
|
||||
marks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the same conversion logic that `parse()` runs on real
|
||||
/// disc data, but starting from already-parsed Playlists so we
|
||||
/// don't have to synthesize valid MPLS bytes.
|
||||
fn labels_from_playlists(playlists: &[Playlist]) -> Vec<StreamLabel> {
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
|
||||
|
||||
for playlist in playlists {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio,
|
||||
3 => StreamLabelType::Subtitle,
|
||||
_ => continue,
|
||||
};
|
||||
let stream_number = match label_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_idx += 1;
|
||||
audio_idx
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_idx += 1;
|
||||
sub_idx
|
||||
}
|
||||
};
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
let key = (
|
||||
type_tag(label_type),
|
||||
language.clone(),
|
||||
codec_hint.clone(),
|
||||
entry.pid,
|
||||
);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: label_type,
|
||||
language,
|
||||
name,
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint,
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpls_audio_streams_become_labels() {
|
||||
// Two audio streams: English TrueHD 7.1 48k, French AC-3 5.1 48k.
|
||||
let pl = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"),
|
||||
audio_entry(0x1101, 0x81, 6, 1, "fra"),
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 2);
|
||||
|
||||
// English TrueHD 7.1
|
||||
let a = &labels[0];
|
||||
assert_eq!(a.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(a.stream_number, 1);
|
||||
assert_eq!(a.language, "eng");
|
||||
assert_eq!(a.name, "English");
|
||||
assert_eq!(a.codec_hint, "TrueHD 7.1");
|
||||
assert_eq!(a.purpose, LabelPurpose::Normal);
|
||||
assert_eq!(a.qualifier, LabelQualifier::None);
|
||||
assert_eq!(a.variant, "");
|
||||
|
||||
// French AC-3 5.1
|
||||
let b = &labels[1];
|
||||
assert_eq!(b.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(b.stream_number, 2);
|
||||
assert_eq!(b.language, "fra");
|
||||
assert_eq!(b.name, "French");
|
||||
assert_eq!(b.codec_hint, "AC-3 5.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpls_pg_streams_become_subtitle_labels() {
|
||||
let pl = playlist_with(vec![
|
||||
pg_entry(0x1200, "eng"),
|
||||
pg_entry(0x1201, "spa"),
|
||||
pg_entry(0x1202, "fra"),
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 3);
|
||||
for label in &labels {
|
||||
assert_eq!(label.stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(label.codec_hint, "PG");
|
||||
}
|
||||
assert_eq!(labels[0].stream_number, 1);
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
assert_eq!(labels[0].name, "English");
|
||||
assert_eq!(labels[1].stream_number, 2);
|
||||
assert_eq!(labels[1].language, "spa");
|
||||
assert_eq!(labels[1].name, "Spanish");
|
||||
assert_eq!(labels[2].stream_number, 3);
|
||||
assert_eq!(labels[2].language, "fra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_streams_across_playlists() {
|
||||
// Two playlists, same English TrueHD 7.1 PID 0x1100 in both.
|
||||
// Expect one Audio label, not two.
|
||||
let pl1 = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"),
|
||||
audio_entry(0x1101, 0x81, 6, 1, "fra"),
|
||||
]);
|
||||
let pl2 = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"), // duplicate
|
||||
audio_entry(0x1102, 0x82, 6, 1, "deu"), // new
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl1, pl2]);
|
||||
// Expected: eng@0x1100, fra@0x1101, deu@0x1102 — three uniques.
|
||||
assert_eq!(labels.len(), 3);
|
||||
// PID isn't stored on StreamLabel, so assert on the surviving
|
||||
// language set instead.
|
||||
let mut langs: Vec<String> = labels.iter().map(|l| l.language.clone()).collect();
|
||||
langs.sort();
|
||||
assert_eq!(langs, vec!["deu", "eng", "fra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coding_type_to_codec_hint_table() {
|
||||
// Spot-check every entry in the spec table. Audio entries
|
||||
// come back bare (no channels/rate set) so codec_hint is the
|
||||
// codec name alone.
|
||||
let cases: &[(u8, &str)] = &[
|
||||
(0x02, "MPEG-2"),
|
||||
(0x1B, "H.264"),
|
||||
(0x24, "HEVC"),
|
||||
(0x80, "LPCM"),
|
||||
(0x81, "AC-3"),
|
||||
(0x82, "DTS"),
|
||||
(0x83, "TrueHD"),
|
||||
(0x84, "AC-3+"),
|
||||
(0x85, "DTS-HD"),
|
||||
(0x86, "DTS-HD MA"),
|
||||
(0x90, "PG"),
|
||||
(0x91, "IG"),
|
||||
(0xA1, "AC-3+ Secondary"),
|
||||
(0xA2, "DTS-HD Secondary"),
|
||||
];
|
||||
for (ct, expected) in cases {
|
||||
assert_eq!(
|
||||
codec_name(*ct),
|
||||
*expected,
|
||||
"coding_type 0x{:02X} should map to {}",
|
||||
ct,
|
||||
expected
|
||||
);
|
||||
}
|
||||
// Unknown bytes return empty.
|
||||
assert_eq!(codec_name(0x00), "");
|
||||
assert_eq!(codec_name(0xFF), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_format_appends_channel_layout() {
|
||||
let mono = audio_entry(1, 0x83, 1, 1, "eng");
|
||||
let stereo = audio_entry(2, 0x83, 3, 1, "eng");
|
||||
let surround_51 = audio_entry(3, 0x83, 6, 1, "eng");
|
||||
let surround_71 = audio_entry(4, 0x83, 12, 1, "eng");
|
||||
let unknown = audio_entry(5, 0x83, 0, 1, "eng");
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &mono),
|
||||
"TrueHD mono"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &stereo),
|
||||
"TrueHD 2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &surround_51),
|
||||
"TrueHD 5.1"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &surround_71),
|
||||
"TrueHD 7.1"
|
||||
);
|
||||
assert_eq!(build_codec_hint(StreamLabelType::Audio, &unknown), "TrueHD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_rate_only_shows_above_48k() {
|
||||
// 48 kHz (rate=1) is the universal default → not surfaced.
|
||||
let r48 = audio_entry(1, 0x83, 6, 1, "eng");
|
||||
// 96 kHz (rate=4) → surfaced.
|
||||
let r96 = audio_entry(2, 0x83, 6, 4, "eng");
|
||||
// 192 kHz (rate=5) → surfaced.
|
||||
let r192 = audio_entry(3, 0x83, 6, 5, "eng");
|
||||
assert_eq!(build_codec_hint(StreamLabelType::Audio, &r48), "TrueHD 5.1");
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &r96),
|
||||
"TrueHD 5.1 96kHz"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &r192),
|
||||
"TrueHD 5.1 192kHz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_iso_code_passes_through_without_display_name() {
|
||||
// Made-up code: keep the raw lowercase code as `language`,
|
||||
// but `name` is empty because we don't know it.
|
||||
let pl = playlist_with(vec![audio_entry(0x1100, 0x83, 6, 1, "xyz")]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].language, "xyz");
|
||||
assert_eq!(labels[0].name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ig_and_dv_streams_are_skipped() {
|
||||
// stream_type 4 = IG, 7 = DV EL — both must not surface.
|
||||
let mut ig = pg_entry(0x1400, "eng");
|
||||
ig.stream_type = 4;
|
||||
let mut dv = audio_entry(0x1011, 0x24, 0, 0, "");
|
||||
dv.stream_type = 7;
|
||||
let pl = playlist_with(vec![ig, dv]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert!(labels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_audio_becomes_audio_label() {
|
||||
// stream_type 5 = secondary audio. The conversion should
|
||||
// still produce an Audio label (the registry's apply path
|
||||
// can ignore secondary if it wants — this module just
|
||||
// surfaces what's there).
|
||||
let mut sec = audio_entry(0x1A00, 0x83, 3, 1, "eng");
|
||||
sec.stream_type = 5;
|
||||
sec.secondary = true;
|
||||
let pl = playlist_with(vec![sec]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(labels[0].codec_hint, "TrueHD 2.0");
|
||||
}
|
||||
}
|
||||
+20
-31
@@ -12,15 +12,15 @@
|
||||
//! sub_com1_idx="23,24,25" />
|
||||
//! ```
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
|
||||
use crate::sector::SectorReader;
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "playlists.xml")
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let data = super::read_jar_file(reader, udf, "playlists.xml")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
@@ -30,8 +30,8 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
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());
|
||||
if let Some(aud) = xml::attr(&feature, "aud") {
|
||||
let com_idx = xml::attr(&feature, "aud_com1_idx").and_then(|s| s.parse::<usize>().ok());
|
||||
|
||||
for (i, lang) in aud.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
@@ -57,12 +57,12 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
}
|
||||
|
||||
// Parse subtitle streams
|
||||
if let Some(sub) = extract_attr(&feature, "sub") {
|
||||
let forced: Vec<bool> = extract_attr(&feature, "forced_sub")
|
||||
if let Some(sub) = xml::attr(&feature, "sub") {
|
||||
let forced: Vec<bool> = xml::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")
|
||||
let com_indices: Vec<usize> = xml::attr(&feature, "sub_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -100,32 +100,29 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
// High confidence: paramount's playlists.xml is fully structured
|
||||
// and we extract every documented field.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
|
||||
/// Find the feature playlist element (the one with the most audio tracks).
|
||||
fn find_feature_playlist(xml: &str) -> Option<String> {
|
||||
fn find_feature_playlist(text: &str) -> Option<String> {
|
||||
let mut best: Option<String> = None;
|
||||
let mut best_aud_count = 0;
|
||||
let mut from = 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];
|
||||
while let Some((start, end)) = xml::find_element(text, "playlist", from) {
|
||||
let element = &text[start..end];
|
||||
|
||||
// Prefer name="Feature" explicitly
|
||||
if let Some(name) = extract_attr(element, "name") {
|
||||
// Prefer name="Feature" explicitly.
|
||||
if let Some(name) = xml::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") {
|
||||
// Otherwise pick the one with the most audio streams.
|
||||
if let Some(aud) = xml::attr(element, "aud") {
|
||||
let count = aud.split(',').count();
|
||||
if count > best_aud_count {
|
||||
best_aud_count = count;
|
||||
@@ -133,15 +130,7 @@ fn find_feature_playlist(xml: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pos = end;
|
||||
from = end;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Extract an XML attribute value from an element string.
|
||||
fn extract_attr(element: &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())
|
||||
}
|
||||
|
||||
+102
-23
@@ -5,9 +5,13 @@
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType, vocab};
|
||||
use crate::sector::SectorReader;
|
||||
use super::{
|
||||
Confidence, LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, text,
|
||||
vocab,
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Known audio codec tokens
|
||||
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
|
||||
@@ -20,9 +24,18 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "bluray_project.bin")
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let data = super::read_jar_file(reader, udf, "bluray_project.bin")?;
|
||||
let strings = extract_strings(&data);
|
||||
// min_len=4 matches the prior local extract_strings impl. The token
|
||||
// grammar is `{lang3}_{codec?}_{purpose?}_{region?}_` so the
|
||||
// shortest meaningful run is 4 chars (lang + underscore).
|
||||
let strings = text::extract_ascii_strings(&data, 4);
|
||||
|
||||
// Tracked across all parse_token calls in this run: did any stream
|
||||
// hit an unrecognized token component (skip-unknown path)? If yes
|
||||
// we downgrade confidence to Medium — the labels are still valid
|
||||
// but the corpus surfaced something we don't catalogue.
|
||||
let saw_unknown = AtomicBool::new(false);
|
||||
|
||||
let mut labels = Vec::new();
|
||||
let mut in_feature = false;
|
||||
@@ -50,7 +63,7 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(label) = parse_token(s) {
|
||||
if let Some(label) = parse_token_inner(s, Some(&saw_unknown)) {
|
||||
match label.stream_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_num += 1;
|
||||
@@ -73,10 +86,15 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
let confidence = if saw_unknown.load(Ordering::Relaxed) {
|
||||
Confidence::Medium
|
||||
} else {
|
||||
Confidence::High
|
||||
};
|
||||
Some(ParseResult { labels, confidence })
|
||||
}
|
||||
|
||||
fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
fn parse_token_inner(s: &str, saw_unknown: Option<&AtomicBool>) -> Option<StreamLabel> {
|
||||
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.split('_').collect();
|
||||
if parts.len() < 2 {
|
||||
@@ -127,7 +145,18 @@ fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
} else if part.starts_with("PGStream") {
|
||||
is_subtitle = true;
|
||||
} else {
|
||||
return None;
|
||||
// Unknown token component — skip this single part rather
|
||||
// than discarding the entire stream record. Pre-refactor
|
||||
// behavior was `return None` here, which silently dropped
|
||||
// any stream containing a single uncatalogued token (e.g.
|
||||
// a new codec ID or framework variant). Better to surface
|
||||
// what we know than discard a whole stream over one part,
|
||||
// but flag the parse as Medium-confidence so callers know
|
||||
// some data was elided.
|
||||
tracing::debug!(part = %part, "pixelogic: unrecognized token component, skipping");
|
||||
if let Some(flag) = saw_unknown {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,22 +182,72 @@ fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_strings(data: &[u8]) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
let mut current = String::new();
|
||||
// extract_strings removed — replaced by super::text::extract_ascii_strings(data, 4).
|
||||
|
||||
for &b in data {
|
||||
if (0x20..0x7f).contains(&b) {
|
||||
current.push(b as char);
|
||||
} else {
|
||||
if current.len() > 3 {
|
||||
strings.push(current.clone());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_token_basic_audio() {
|
||||
let l = parse_token_inner("eng_MLP_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.codec_hint, "TrueHD");
|
||||
assert_eq!(l.purpose, LabelPurpose::Normal);
|
||||
}
|
||||
if current.len() > 3 {
|
||||
strings.push(current);
|
||||
|
||||
#[test]
|
||||
fn parse_token_basic_subtitle_sdh() {
|
||||
let l = parse_token_inner("eng_SDH_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.qualifier, LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_commentary() {
|
||||
let l = parse_token_inner("eng_MLP_ACOM_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_descriptive() {
|
||||
let l = parse_token_inner("eng_AC3_ADES_", None).unwrap();
|
||||
assert_eq!(l.purpose, LabelPurpose::Descriptive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_with_region() {
|
||||
let l = parse_token_inner("eng_MLP_US_", None).unwrap();
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.variant, "US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_unknown_component_does_not_kill_stream() {
|
||||
// Regression: pre-refactor, an unrecognized token part returned
|
||||
// None for the whole stream, silently dropping it. New
|
||||
// behavior: skip the unknown part, surface what we know.
|
||||
let l = parse_token_inner("eng_MLP_FUTUREFLAG_FOR_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.codec_hint, "TrueHD");
|
||||
assert_eq!(l.qualifier, LabelQualifier::Forced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_no_audio_or_subtitle_signal_returns_none() {
|
||||
// A token that has only a language and an unknown part with
|
||||
// no audio/subtitle classifier should still return None —
|
||||
// there's no way to file it as a stream.
|
||||
assert!(parse_token_inner("eng_UNKNOWN_", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_rejects_non_lang_prefix() {
|
||||
assert!(parse_token_inner("XX_MLP_", None).is_none());
|
||||
assert!(parse_token_inner("ENG_MLP_", None).is_none()); // uppercase not accepted as ISO 639-2
|
||||
}
|
||||
strings
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Text-extraction helpers used by parsers that scan binary blobs for
|
||||
//! embedded label strings.
|
||||
//!
|
||||
//! Promoted from two near-duplicate implementations:
|
||||
//! - `pixelogic::extract_strings` (`bluray_project.bin`, min_len=4)
|
||||
//! - `dbp::extract_printable` (`.class` files in jars, min_len=5)
|
||||
//!
|
||||
//! Single implementation, threshold passed in. Callers that have a
|
||||
//! more structured parse path (e.g. `class_reader` for .class) should
|
||||
//! prefer that — this helper is for genuinely unstructured input.
|
||||
|
||||
/// Walk `data`, emit every maximal run of printable-ASCII bytes
|
||||
/// (`0x20..=0x7E`) whose length is at least `min_len`.
|
||||
///
|
||||
/// Non-printable bytes (including `\t`, `\n`, NUL) terminate the
|
||||
/// current run. Output strings are guaranteed valid UTF-8 (they're
|
||||
/// pure 7-bit ASCII). Strings shorter than `min_len` are dropped.
|
||||
pub fn extract_ascii_strings(data: &[u8], min_len: usize) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut current = String::new();
|
||||
for &b in data {
|
||||
if (0x20..=0x7E).contains(&b) {
|
||||
current.push(b as char);
|
||||
} else if current.len() >= min_len {
|
||||
out.push(std::mem::take(&mut current));
|
||||
} else {
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if current.len() >= min_len {
|
||||
out.push(current);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_simple_runs() {
|
||||
let got = extract_ascii_strings(b"hello\0world\0", 3);
|
||||
assert_eq!(got, vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_minimum_length() {
|
||||
let got = extract_ascii_strings(b"hi\0ok\0longer\0", 5);
|
||||
assert_eq!(got, vec!["longer"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treats_tab_and_newline_as_separators() {
|
||||
// \t (0x09) and \n (0x0A) are below 0x20, so they break runs.
|
||||
let got = extract_ascii_strings(b"alpha\tbeta\ngamma", 3);
|
||||
assert_eq!(got, vec!["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_trailing_run_without_terminator() {
|
||||
// Run at the very end of the buffer should still be emitted.
|
||||
let got = extract_ascii_strings(b"prefix\0tail", 3);
|
||||
assert_eq!(got, vec!["prefix", "tail"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_high_bit_bytes() {
|
||||
// 0x80+ is non-printable per this helper's definition.
|
||||
let mut buf = b"good".to_vec();
|
||||
buf.push(0xC3);
|
||||
buf.push(0xA9);
|
||||
buf.extend_from_slice(b"more");
|
||||
let got = extract_ascii_strings(&buf, 3);
|
||||
assert_eq!(got, vec!["good", "more"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_empty() {
|
||||
let got = extract_ascii_strings(&[], 1);
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_len_zero_emits_singletons() {
|
||||
// Pathological but well-defined.
|
||||
let got = extract_ascii_strings(b"a\0b", 0);
|
||||
assert_eq!(got, vec!["a", "b"]);
|
||||
}
|
||||
}
|
||||
+432
-9
@@ -1,19 +1,40 @@
|
||||
//! Shared label vocabulary — values we are 100% confident about.
|
||||
//! Shared label vocabulary — canonical mappings used by ≥1 label parser.
|
||||
//!
|
||||
//! 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.
|
||||
//! playlists.xml, menu_base.prop, .class string pools, etc.) — NOT
|
||||
//! from BD spec fields. This module is the central, regression-tested
|
||||
//! source of truth for:
|
||||
//!
|
||||
//! 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.
|
||||
//! - Codec brand name aliases (`MLP` → `TrueHD`).
|
||||
//! - English / multi-word language name → ISO 639-2 code.
|
||||
//! - English text → [`LabelPurpose`] (Commentary / Descriptive / etc.).
|
||||
//! - English text → [`LabelQualifier`] (SDH / Forced / Descriptive Service).
|
||||
//!
|
||||
//! Rules of engagement (carried over from
|
||||
//! `(internal)/memory/feedback_label_data_rules.md`):
|
||||
//!
|
||||
//! 1. Only map values we are 100% certain about — published codec
|
||||
//! names, well-known ISO 639-2 mappings, vendor-documented purpose
|
||||
//! keywords.
|
||||
//! 2. Unknown codes / unrecognized phrases pass through raw or return
|
||||
//! `None`. We never guess.
|
||||
//! 3. Matching is case-insensitive and word-boundary-aware where
|
||||
//! relevant (so "Commenter" doesn't match "commentary"). Anchoring
|
||||
//! on whole tokens is the responsibility of this module — callers
|
||||
//! pass raw text, we handle it.
|
||||
//!
|
||||
//! This module is NOT for BD spec STN codec IDs; those decode in
|
||||
//! `mpls.rs` separately.
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier};
|
||||
|
||||
// ── Codec aliases ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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.
|
||||
/// authoring tools. Unknown codes pass through unchanged so callers
|
||||
/// can still surface vendor-specific tokens we haven't catalogued.
|
||||
pub fn codec(code: &str) -> &str {
|
||||
match code {
|
||||
"MLP" => "TrueHD",
|
||||
@@ -25,3 +46,405 @@ pub fn codec(code: &str) -> &str {
|
||||
_ => code,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Language: English / multi-word names → ISO 639-2 ─────────────────────────
|
||||
|
||||
/// Result of [`lang`] — ISO code + human-readable regional variant.
|
||||
///
|
||||
/// `code` is ISO 639-2 (always 3 lowercase letters).
|
||||
/// `variant` is the regional dialect as a human-readable English word
|
||||
/// (`"Brazilian"`, `"Castilian"`, `"Canadian"`, `"Simplified"`, ...)
|
||||
/// or `""` when the input names just a bare language without
|
||||
/// dialect ("Spanish" → variant=""). The variant matches the
|
||||
/// convention pixelogic / ctrm / criterion already use for their
|
||||
/// `StreamLabel::variant` field: a short display token the UI can
|
||||
/// surface verbatim.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LangInfo {
|
||||
pub code: &'static str,
|
||||
pub variant: &'static str,
|
||||
}
|
||||
|
||||
/// Map a free-form language label fragment to an ISO 639-2 code AND
|
||||
/// (where applicable) its regional variant.
|
||||
///
|
||||
/// Handles both bare English names ("English", "Spanish") and the
|
||||
/// multi-word vendor variants we've seen in the corpus ("Brazilian
|
||||
/// Portuguese", "Castilian Spanish", "Canadian French"). Match is
|
||||
/// case-insensitive; longer compound phrases win over their bare
|
||||
/// counterparts (so "Brazilian Portuguese" returns
|
||||
/// `LangInfo { code: "por", variant: "Brazilian" }`, not consumed by
|
||||
/// the bare "Portuguese" entry).
|
||||
///
|
||||
/// Bare-name matches return `variant: ""`.
|
||||
///
|
||||
/// Returns `None` for unrecognized input — callers decide whether to
|
||||
/// fall back to MPLS spec codes, pass through raw, or drop the stream.
|
||||
/// Never guesses.
|
||||
///
|
||||
/// Why the variant: the prior `lang() -> Option<&str>` shape silently
|
||||
/// dropped regional dialect info. "Brazilian Portuguese 5.1" became
|
||||
/// `language="por", variant=""` — UI displayed plain "Portuguese"
|
||||
/// even though the disc had explicitly labeled this stream Brazilian.
|
||||
/// Capturing the variant here parallels how pixelogic and ctrm
|
||||
/// populate `StreamLabel::variant` from their own region tables.
|
||||
pub fn lang(text: &str) -> Option<LangInfo> {
|
||||
let lower = text.to_lowercase();
|
||||
// Multi-word compounds first — longest-match wins.
|
||||
for (needle, code, variant) in COMPOUND_LANGS {
|
||||
if lower.contains(needle) {
|
||||
return Some(LangInfo { code, variant });
|
||||
}
|
||||
}
|
||||
// Bare names: word-boundary match (avoid "english" inside "englishman"
|
||||
// or any other accidental substring).
|
||||
for (needle, code) in BARE_LANGS {
|
||||
if has_word(&lower, needle) {
|
||||
return Some(LangInfo { code, variant: "" });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const COMPOUND_LANGS: &[(&str, &str, &str)] = &[
|
||||
("brazilian portuguese", "por", "Brazilian"),
|
||||
("euro portuguese", "por", "European"),
|
||||
("european portuguese", "por", "European"),
|
||||
("castilian spanish", "spa", "Castilian"),
|
||||
("latin american spanish", "spa", "Latin American"),
|
||||
("latin spanish", "spa", "Latin American"),
|
||||
("canadian french", "fra", "Canadian"),
|
||||
("parisian french", "fra", "Parisian"),
|
||||
("australian english", "eng", "Australian"),
|
||||
("austrailian english", "eng", "Australian"), // disc-corpus typo, keep matching
|
||||
("british english", "eng", "British"),
|
||||
("simplified chinese", "zho", "Simplified"),
|
||||
("traditional chinese", "zho", "Traditional"),
|
||||
("mandarin chinese", "zho", "Mandarin"),
|
||||
("cantonese chinese", "zho", "Cantonese"),
|
||||
];
|
||||
|
||||
const BARE_LANGS: &[(&str, &str)] = &[
|
||||
("english", "eng"),
|
||||
("spanish", "spa"),
|
||||
("french", "fra"),
|
||||
("german", "deu"),
|
||||
("italian", "ita"),
|
||||
("japanese", "jpn"),
|
||||
("chinese", "zho"),
|
||||
("mandarin", "zho"),
|
||||
("cantonese", "zho"),
|
||||
("portuguese", "por"),
|
||||
("polish", "pol"),
|
||||
("czech", "ces"),
|
||||
("hungarian", "hun"),
|
||||
("dutch", "nld"),
|
||||
("korean", "kor"),
|
||||
("arabic", "ara"),
|
||||
("hindi", "hin"),
|
||||
("turkish", "tur"),
|
||||
("thai", "tha"),
|
||||
("swedish", "swe"),
|
||||
("norwegian", "nor"),
|
||||
("danish", "dan"),
|
||||
("finnish", "fin"),
|
||||
("hebrew", "heb"),
|
||||
("russian", "rus"),
|
||||
("greek", "ell"),
|
||||
("vietnamese", "vie"),
|
||||
("indonesian", "ind"),
|
||||
("malay", "msa"),
|
||||
("ukrainian", "ukr"),
|
||||
("romanian", "ron"),
|
||||
("bulgarian", "bul"),
|
||||
("croatian", "hrv"),
|
||||
("serbian", "srp"),
|
||||
("slovak", "slk"),
|
||||
("slovenian", "slv"),
|
||||
("estonian", "est"),
|
||||
("latvian", "lav"),
|
||||
("lithuanian", "lit"),
|
||||
("icelandic", "isl"),
|
||||
("basque", "eus"),
|
||||
("catalan", "cat"),
|
||||
("galician", "glg"),
|
||||
];
|
||||
|
||||
// ── Purpose ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Classify a free-form English label string into a [`LabelPurpose`].
|
||||
///
|
||||
/// Recognized keywords (case-insensitive, word-boundary matched):
|
||||
/// - "commentary", "director's commentary" → `Commentary`
|
||||
/// - "descriptive", "description", "audio description", "described" → `Descriptive`
|
||||
/// - "score", "music only" → `Score`
|
||||
/// - "ime" (alternate music for closing themes etc.) → `Ime`
|
||||
/// - anything else → `Normal`
|
||||
///
|
||||
/// Word-boundary matching means "Commentary track" matches but
|
||||
/// "Commenter Pro audio" does not.
|
||||
pub fn purpose(text: &str) -> LabelPurpose {
|
||||
let lower = text.to_lowercase();
|
||||
// Multi-word compounds first — they're more specific.
|
||||
if lower.contains("audio description") || lower.contains("descriptive service") {
|
||||
return LabelPurpose::Descriptive;
|
||||
}
|
||||
if lower.contains("music only") {
|
||||
return LabelPurpose::Score;
|
||||
}
|
||||
if has_word(&lower, "commentary") {
|
||||
return LabelPurpose::Commentary;
|
||||
}
|
||||
if has_word(&lower, "descriptive")
|
||||
|| has_word(&lower, "description")
|
||||
|| has_word(&lower, "described")
|
||||
{
|
||||
return LabelPurpose::Descriptive;
|
||||
}
|
||||
if has_word(&lower, "score") {
|
||||
return LabelPurpose::Score;
|
||||
}
|
||||
if has_word(&lower, "ime") {
|
||||
return LabelPurpose::Ime;
|
||||
}
|
||||
LabelPurpose::Normal
|
||||
}
|
||||
|
||||
// ── Qualifier ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Classify a free-form English label string into a [`LabelQualifier`].
|
||||
///
|
||||
/// Recognized keywords (case-insensitive, word-boundary matched):
|
||||
/// - "sdh", "captions" → `Sdh`
|
||||
/// - "forced", "forced narrative" → `Forced`
|
||||
/// - "rnib", "descriptive service" → `DescriptiveService`
|
||||
/// - anything else → `None`
|
||||
///
|
||||
/// SDH (Subtitles for the Deaf and Hard of hearing) wins over Forced
|
||||
/// when both keywords are present, because an SDH track is its own
|
||||
/// stream regardless of whether the player flags it as "forced".
|
||||
pub fn qualifier(text: &str) -> LabelQualifier {
|
||||
let lower = text.to_lowercase();
|
||||
if has_word(&lower, "sdh") || has_word(&lower, "captions") {
|
||||
return LabelQualifier::Sdh;
|
||||
}
|
||||
if lower.contains("descriptive service") || has_word(&lower, "rnib") {
|
||||
return LabelQualifier::DescriptiveService;
|
||||
}
|
||||
if has_word(&lower, "forced") {
|
||||
return LabelQualifier::Forced;
|
||||
}
|
||||
LabelQualifier::None
|
||||
}
|
||||
|
||||
// ── Internal: word-boundary matching ────────────────────────────────────────
|
||||
|
||||
/// True if `needle` appears in `haystack` surrounded by non-alphanumeric
|
||||
/// boundaries (or string ends). `haystack` is assumed lowercase already.
|
||||
///
|
||||
/// This is the load-bearing primitive for `lang` / `purpose` /
|
||||
/// `qualifier`: bare-token matchers MUST use it, otherwise we match
|
||||
/// "english" inside "englishman" and "sdh" inside "lambdash". The
|
||||
/// existing parsers used `.contains()` and got lucky on the corpus;
|
||||
/// vocab guarantees the boundary.
|
||||
fn has_word(haystack: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let bytes = haystack.as_bytes();
|
||||
let nb = needle.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + nb.len() <= bytes.len() {
|
||||
if &bytes[i..i + nb.len()] == nb {
|
||||
let before = if i == 0 { None } else { Some(bytes[i - 1]) };
|
||||
let after = bytes.get(i + nb.len()).copied();
|
||||
let bound = |c: Option<u8>| match c {
|
||||
None => true,
|
||||
Some(b) => !b.is_ascii_alphanumeric(),
|
||||
};
|
||||
if bound(before) && bound(after) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn codec_known_aliases() {
|
||||
assert_eq!(codec("MLP"), "TrueHD");
|
||||
assert_eq!(codec("AC3"), "Dolby Digital");
|
||||
assert_eq!(codec("AC"), "Dolby Digital");
|
||||
assert_eq!(codec("DDL"), "Dolby Digital Plus");
|
||||
assert_eq!(codec("atmos"), "Dolby Atmos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_unknown_passes_through() {
|
||||
assert_eq!(codec("FX9"), "FX9");
|
||||
assert_eq!(codec(""), "");
|
||||
}
|
||||
|
||||
fn li(code: &'static str, variant: &'static str) -> LangInfo {
|
||||
LangInfo { code, variant }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_bare_names_have_empty_variant() {
|
||||
assert_eq!(lang("English"), Some(li("eng", "")));
|
||||
assert_eq!(lang("english"), Some(li("eng", "")));
|
||||
assert_eq!(lang("Spanish 5.1 Dolby Digital"), Some(li("spa", "")));
|
||||
assert_eq!(lang("japanese"), Some(li("jpn", "")));
|
||||
assert_eq!(lang("Italian"), Some(li("ita", "")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_compounds_carry_variant() {
|
||||
assert_eq!(
|
||||
lang("Brazilian Portuguese 5.1"),
|
||||
Some(li("por", "Brazilian"))
|
||||
);
|
||||
assert_eq!(lang("Castilian Spanish"), Some(li("spa", "Castilian")));
|
||||
assert_eq!(
|
||||
lang("Canadian French Dolby Digital"),
|
||||
Some(li("fra", "Canadian"))
|
||||
);
|
||||
assert_eq!(
|
||||
lang("Latin American Spanish"),
|
||||
Some(li("spa", "Latin American"))
|
||||
);
|
||||
assert_eq!(lang("Simplified Chinese"), Some(li("zho", "Simplified")));
|
||||
assert_eq!(lang("British English"), Some(li("eng", "British")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_compounds_win_over_bare() {
|
||||
// Brazilian Portuguese must map to (por, Brazilian) via the
|
||||
// compound rule, not be intercepted by bare "portuguese"
|
||||
// (which would yield (por, "") and lose the variant).
|
||||
assert_eq!(lang("Brazilian Portuguese").unwrap().variant, "Brazilian");
|
||||
assert_eq!(lang("Canadian French").unwrap().variant, "Canadian");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_unknown_returns_none() {
|
||||
assert_eq!(lang("Klingon Dolby Atmos"), None);
|
||||
assert_eq!(lang(""), None);
|
||||
assert_eq!(lang("eng"), None); // 3-letter codes are not English names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_word_boundary_avoids_substring_false_positive() {
|
||||
// No false positive — "engineering" must NOT match "english".
|
||||
assert_eq!(lang("Audio Engineering Demo"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_recognizes_commentary() {
|
||||
assert_eq!(purpose("English Commentary"), LabelPurpose::Commentary);
|
||||
assert_eq!(purpose("Director's Commentary"), LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_recognizes_descriptive() {
|
||||
assert_eq!(
|
||||
purpose("English Descriptive Audio"),
|
||||
LabelPurpose::Descriptive
|
||||
);
|
||||
assert_eq!(purpose("Audio Description"), LabelPurpose::Descriptive);
|
||||
assert_eq!(purpose("Described Video"), LabelPurpose::Descriptive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_descriptive_service_routes_to_descriptive() {
|
||||
// "Descriptive Service" is qualifier territory but the purpose
|
||||
// implication is Descriptive — vocab::purpose treats it as such.
|
||||
assert_eq!(
|
||||
purpose("English Descriptive Service"),
|
||||
LabelPurpose::Descriptive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_word_boundary_avoids_commenter_false_positive() {
|
||||
// "Commenter Pro audio" does NOT match "commentary" — the
|
||||
// existing dbp/ctrm hand-rolls would have. Vocab is stricter.
|
||||
assert_eq!(purpose("Commenter Pro audio track"), LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_recognizes_score() {
|
||||
assert_eq!(purpose("Music Only"), LabelPurpose::Score);
|
||||
assert_eq!(purpose("Isolated Score"), LabelPurpose::Score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_unknown_is_normal() {
|
||||
assert_eq!(purpose("English Dolby Atmos"), LabelPurpose::Normal);
|
||||
assert_eq!(purpose(""), LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_recognizes_sdh() {
|
||||
assert_eq!(qualifier("English SDH"), LabelQualifier::Sdh);
|
||||
assert_eq!(qualifier("English Captions"), LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_recognizes_forced() {
|
||||
assert_eq!(qualifier("English Forced"), LabelQualifier::Forced);
|
||||
assert_eq!(qualifier("Forced Narrative"), LabelQualifier::Forced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_recognizes_descriptive_service() {
|
||||
assert_eq!(
|
||||
qualifier("English RNIB"),
|
||||
LabelQualifier::DescriptiveService
|
||||
);
|
||||
assert_eq!(
|
||||
qualifier("English Descriptive Service"),
|
||||
LabelQualifier::DescriptiveService
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_sdh_wins_over_forced_when_both_present() {
|
||||
// SDH track is its own stream regardless of forced flag.
|
||||
assert_eq!(qualifier("English Forced SDH"), LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_unknown_is_none() {
|
||||
assert_eq!(qualifier("English"), LabelQualifier::None);
|
||||
assert_eq!(qualifier(""), LabelQualifier::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_word_basic() {
|
||||
assert!(has_word("english forced", "english"));
|
||||
assert!(has_word("english forced", "forced"));
|
||||
assert!(has_word("english", "english"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_word_rejects_substring() {
|
||||
assert!(!has_word("engineering", "english"));
|
||||
assert!(!has_word("englishman", "english"));
|
||||
assert!(!has_word("aenglish", "english"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_word_punctuation_boundary() {
|
||||
// "(SDH)" is a valid boundary — parentheses count as non-alphanum.
|
||||
assert!(has_word("english (sdh)", "sdh"));
|
||||
assert!(has_word("commentary,extra,info", "commentary"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Tolerant XML scraping helpers — promoted from two near-duplicate
|
||||
//! hand-rolls in `paramount.rs` (attribute extraction) and `criterion.rs`
|
||||
//! (tag-text extraction).
|
||||
//!
|
||||
//! These are NOT a full XML parser. They handle the subset of XML the
|
||||
//! BD-J authoring tools we've seen actually emit: ASCII tag/attr
|
||||
//! names, no entity references inside label strings, optional XML
|
||||
//! namespaces. Hardening goals over the prior `find("<tag>")` /
|
||||
//! `find(r#"name=""#)` matchers:
|
||||
//!
|
||||
//! 1. **Case-insensitive** tag and attribute names — vendors casing
|
||||
//! is inconsistent across authoring-tool revisions.
|
||||
//! 2. **Namespace-aware** — strip an optional `ns:` prefix so
|
||||
//! `<ns:playlist>` and `<playlist>` both match.
|
||||
//! 3. **Whitespace-tolerant** — multiple/tab/newline characters
|
||||
//! around `=` between attribute name and value; whitespace inside
|
||||
//! the opening tag.
|
||||
//! 4. **Quote-style tolerant** — both `"value"` and `'value'`.
|
||||
//! 5. **Self-closing tag handling** — `<tag />` and `<tag/>` both
|
||||
//! work; [`text`] returns `Some("")` for empty content.
|
||||
//!
|
||||
//! Out of scope (intentionally simple): XML entity decoding
|
||||
//! (`&`, `<`, etc.), CDATA sections, comments, processing
|
||||
//! instructions, DTD declarations. None of the BD-J authored disc
|
||||
//! data we've observed exercises any of those — labels are plain
|
||||
//! ASCII/Latin-1 in attribute values.
|
||||
|
||||
/// Extract the value of attribute `name` from one XML element
|
||||
/// fragment (e.g. `<playlist name="Feature" id="00222" />`).
|
||||
///
|
||||
/// Returns the raw attribute text (no entity decoding) or `None` if
|
||||
/// the attribute isn't present. Empty string for `name=""` is
|
||||
/// represented as `Some("")`.
|
||||
pub fn attr(element: &str, name: &str) -> Option<String> {
|
||||
let bytes = element.as_bytes();
|
||||
let name_lower = name.to_ascii_lowercase();
|
||||
let name_bytes = name_lower.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + name_bytes.len() < bytes.len() {
|
||||
// Find the next position where `name=` could start. We need
|
||||
// a word boundary before the name (whitespace or `<` or `:`).
|
||||
if i > 0 && is_name_char(bytes[i - 1]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if !slice_eq_ignore_case(&bytes[i..i + name_bytes.len()], name_bytes) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let after_name = i + name_bytes.len();
|
||||
// The character immediately after the name must not be a
|
||||
// name-continuation (otherwise we matched a prefix like
|
||||
// `lang_id` when looking for `lang`).
|
||||
if after_name < bytes.len() && is_name_char(bytes[after_name]) {
|
||||
i = after_name;
|
||||
continue;
|
||||
}
|
||||
// Walk past whitespace, then `=`, then more whitespace, then
|
||||
// the opening quote.
|
||||
let mut j = after_name;
|
||||
while j < bytes.len() && is_ws(bytes[j]) {
|
||||
j += 1;
|
||||
}
|
||||
if j >= bytes.len() || bytes[j] != b'=' {
|
||||
i = j.max(i + 1);
|
||||
continue;
|
||||
}
|
||||
j += 1; // past '='
|
||||
while j < bytes.len() && is_ws(bytes[j]) {
|
||||
j += 1;
|
||||
}
|
||||
if j >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let quote = bytes[j];
|
||||
if quote != b'"' && quote != b'\'' {
|
||||
// Unquoted attribute values aren't part of well-formed
|
||||
// XML (HTML5 allows them, XML doesn't). Skip.
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
let value_start = j + 1;
|
||||
let close = bytes[value_start..].iter().position(|&b| b == quote)?;
|
||||
let value = &element[value_start..value_start + close];
|
||||
return Some(value.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the trimmed text content of the first occurrence of
|
||||
/// `<tag>...</tag>` in `xml`. Returns `None` if the tag isn't found
|
||||
/// or its opening tag is malformed.
|
||||
///
|
||||
/// Whitespace around the inner text is stripped. Self-closing
|
||||
/// `<tag />` yields `Some("")`. Nested same-name tags are NOT
|
||||
/// handled — the first close encountered wins (this matches the
|
||||
/// prior behavior in criterion.rs).
|
||||
pub fn text(xml: &str, tag: &str) -> Option<String> {
|
||||
let (open_end, body_start) = find_open_tag(xml, tag, 0)?;
|
||||
// Self-closing — already consumed in find_open_tag if `/>`.
|
||||
if open_end == body_start {
|
||||
// Means find_open_tag returned the same offset twice for
|
||||
// self-closing form. (Not currently the case in our impl,
|
||||
// but defensive.)
|
||||
return Some(String::new());
|
||||
}
|
||||
// For self-closing tags, body_start is past `/>` and we have no
|
||||
// content. Detect by checking the char at body_start - 1 was `/`.
|
||||
if body_start >= 2 && &xml[body_start - 2..body_start] == "/>" {
|
||||
return Some(String::new());
|
||||
}
|
||||
// Find the matching close tag. Case-insensitive + namespace-aware.
|
||||
let close_start = find_close_tag(xml, tag, body_start)?;
|
||||
Some(xml[body_start..close_start].trim().to_string())
|
||||
}
|
||||
|
||||
/// Locate the next `<tag>` opening AND its closing `</tag>` in
|
||||
/// `xml`, starting at byte offset `from`. Returns `(element_start,
|
||||
/// element_end)` — `element_start` is the `<` of the opening tag,
|
||||
/// `element_end` is one past the `>` of the closing tag. Useful for
|
||||
/// iterating over repeated elements like `<playlist>` blocks in
|
||||
/// `paramount`.
|
||||
///
|
||||
/// Self-closing elements return the same offset for body_end as the
|
||||
/// element_end (i.e. `element_end - element_start` includes only the
|
||||
/// `<tag .../>` text).
|
||||
pub fn find_element(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> {
|
||||
let bytes = xml.as_bytes();
|
||||
let mut i = from;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] != b'<' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Try matching tag name at i+1 (after `<`).
|
||||
let after_lt = i + 1;
|
||||
if !matches_tag_name_at(bytes, after_lt, tag) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Found an open tag at offset i. Walk to find the closing `>`
|
||||
// of the open tag itself.
|
||||
let mut j = after_lt;
|
||||
// Skip past the tag name (and optional namespace prefix).
|
||||
while j < bytes.len() && (is_name_char(bytes[j]) || bytes[j] == b':') {
|
||||
j += 1;
|
||||
}
|
||||
// Walk attributes — track quoting state.
|
||||
let mut self_closing = false;
|
||||
while j < bytes.len() {
|
||||
match bytes[j] {
|
||||
b'>' => {
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'>' => {
|
||||
self_closing = true;
|
||||
j += 2;
|
||||
break;
|
||||
}
|
||||
b'"' | b'\'' => {
|
||||
let q = bytes[j];
|
||||
j += 1;
|
||||
while j < bytes.len() && bytes[j] != q {
|
||||
j += 1;
|
||||
}
|
||||
if j < bytes.len() {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
_ => j += 1,
|
||||
}
|
||||
}
|
||||
if self_closing {
|
||||
return Some((i, j));
|
||||
}
|
||||
// Find matching close. Doesn't handle nested same-name; OK
|
||||
// for our authoring-tool subset.
|
||||
let close_start = find_close_tag(xml, tag, j)?;
|
||||
let close_end = find_byte(bytes, b'>', close_start)? + 1;
|
||||
return Some((i, close_end));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// True if `bytes[start..]` opens a tag named `tag`, allowing an
|
||||
/// optional `ns:` namespace prefix. Comparison is case-insensitive.
|
||||
/// The character after the tag name must not be a name-continuation
|
||||
/// (so `<player>` doesn't match `<play>`).
|
||||
fn matches_tag_name_at(bytes: &[u8], start: usize, tag: &str) -> bool {
|
||||
let tag_lower = tag.to_ascii_lowercase();
|
||||
let tag_bytes = tag_lower.as_bytes();
|
||||
// Skip optional `prefix:` (one or more name chars + `:`).
|
||||
let mut name_start = start;
|
||||
let mut scan = start;
|
||||
while scan < bytes.len() && is_name_char(bytes[scan]) {
|
||||
scan += 1;
|
||||
}
|
||||
if scan < bytes.len() && bytes[scan] == b':' {
|
||||
name_start = scan + 1;
|
||||
}
|
||||
if name_start + tag_bytes.len() > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
if !slice_eq_ignore_case(&bytes[name_start..name_start + tag_bytes.len()], tag_bytes) {
|
||||
return false;
|
||||
}
|
||||
// Boundary: char after the tag name must be `>`, `/`, whitespace.
|
||||
let after = name_start + tag_bytes.len();
|
||||
if after >= bytes.len() {
|
||||
return false;
|
||||
}
|
||||
matches!(bytes[after], b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r')
|
||||
}
|
||||
|
||||
/// Find the offset of the next `</tag>` (or `</ns:tag>`) in `xml`
|
||||
/// starting at `from`. Case-insensitive; returns the offset of the
|
||||
/// `<`. None if not found.
|
||||
fn find_close_tag(xml: &str, tag: &str, from: usize) -> Option<usize> {
|
||||
let bytes = xml.as_bytes();
|
||||
let mut i = from;
|
||||
while i + 2 < bytes.len() {
|
||||
if bytes[i] == b'<' && bytes[i + 1] == b'/' {
|
||||
// Check tag name (with optional namespace).
|
||||
if matches_tag_name_at(bytes, i + 2, tag) {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the open tag of `<tag>` in `xml` starting at `from`. Returns
|
||||
/// `(after_open_lt, after_open_gt)` — the offsets are: just past
|
||||
/// the `<` of the open tag, and just past the `>` of the open tag.
|
||||
fn find_open_tag(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> {
|
||||
let bytes = xml.as_bytes();
|
||||
let (elem_start, _) = find_element(xml, tag, from)?;
|
||||
let after_lt = elem_start + 1;
|
||||
// Find the `>` that ends the open tag (handling quoted attrs).
|
||||
let mut j = after_lt;
|
||||
while j < bytes.len() {
|
||||
match bytes[j] {
|
||||
b'>' => return Some((after_lt, j + 1)),
|
||||
b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'>' => {
|
||||
return Some((after_lt, j + 2));
|
||||
}
|
||||
b'"' | b'\'' => {
|
||||
let q = bytes[j];
|
||||
j += 1;
|
||||
while j < bytes.len() && bytes[j] != q {
|
||||
j += 1;
|
||||
}
|
||||
if j < bytes.len() {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
_ => j += 1,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_byte(bytes: &[u8], target: u8, from: usize) -> Option<usize> {
|
||||
bytes[from..]
|
||||
.iter()
|
||||
.position(|&b| b == target)
|
||||
.map(|p| p + from)
|
||||
}
|
||||
|
||||
/// True if `c` can be part of an XML name token (rough). We accept
|
||||
/// alphanumerics, `_`, `-`, `.`.
|
||||
fn is_name_char(c: u8) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'.'
|
||||
}
|
||||
|
||||
fn is_ws(c: u8) -> bool {
|
||||
matches!(c, b' ' | b'\t' | b'\n' | b'\r')
|
||||
}
|
||||
|
||||
fn slice_eq_ignore_case(a: &[u8], b_lower: &[u8]) -> bool {
|
||||
if a.len() != b_lower.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter()
|
||||
.zip(b_lower.iter())
|
||||
.all(|(&x, &y)| x.to_ascii_lowercase() == y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attr_basic() {
|
||||
assert_eq!(
|
||||
attr(r#"<playlist name="Feature" id="00222" />"#, "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
assert_eq!(
|
||||
attr(r#"<playlist name="Feature" id="00222" />"#, "id"),
|
||||
Some("00222".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_case_insensitive_name() {
|
||||
assert_eq!(
|
||||
attr(r#"<playlist Name="Feature" />"#, "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
assert_eq!(
|
||||
attr(r#"<playlist NAME="Feature" />"#, "Name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_accepts_single_quotes() {
|
||||
assert_eq!(
|
||||
attr(r#"<playlist name='Feature' />"#, "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_whitespace_around_equals() {
|
||||
assert_eq!(
|
||||
attr(r#"<playlist name = "Feature" />"#, "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
assert_eq!(
|
||||
attr("<playlist name\n=\n\"Feature\" />", "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_missing_returns_none() {
|
||||
assert_eq!(attr(r#"<playlist name="X" />"#, "id"), None);
|
||||
assert_eq!(attr("", "name"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_no_substring_false_positive() {
|
||||
// Looking for "lang" should NOT match "lang_id" or
|
||||
// "language" because of the name-char boundary check.
|
||||
assert_eq!(attr(r#"<x lang_id="fra" language="eng" />"#, "lang"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_empty_value() {
|
||||
assert_eq!(attr(r#"<x name="" id="1" />"#, "name"), Some("".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_basic() {
|
||||
assert_eq!(text("<x>hello</x>", "x"), Some("hello".into()));
|
||||
assert_eq!(
|
||||
text("<x> hello world </x>", "x"),
|
||||
Some("hello world".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_case_insensitive_tag() {
|
||||
assert_eq!(text("<X>foo</X>", "x"), Some("foo".into()));
|
||||
assert_eq!(text("<Foo>bar</foo>", "foo"), Some("bar".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_namespace_prefix() {
|
||||
assert_eq!(text("<ns:tag>value</ns:tag>", "tag"), Some("value".into()));
|
||||
assert_eq!(text("<foo:Bar>v</foo:Bar>", "bar"), Some("v".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_self_closing() {
|
||||
assert_eq!(text("<x/>", "x"), Some("".into()));
|
||||
assert_eq!(text("<x />", "x"), Some("".into()));
|
||||
assert_eq!(text("<x attr=\"y\" />", "x"), Some("".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_attrs() {
|
||||
assert_eq!(
|
||||
text(r#"<x id="1" name="y">hello</x>"#, "x"),
|
||||
Some("hello".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_missing_close_returns_none() {
|
||||
assert_eq!(text("<x>hello", "x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_skips_inner_tags_naively() {
|
||||
// Limitation noted: nested same-name tags aren't handled.
|
||||
// Different-name nesting works (we just return everything
|
||||
// between the open and close).
|
||||
assert_eq!(
|
||||
text("<x><y>nested</y></x>", "x"),
|
||||
Some("<y>nested</y>".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_basic() {
|
||||
let xml = r#"<x /> <y attr="1">body</y>"#;
|
||||
let (s, e) = find_element(xml, "y", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], r#"<y attr="1">body</y>"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_self_closing() {
|
||||
let xml = r#"<x />"#;
|
||||
let (s, e) = find_element(xml, "x", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], "<x />");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_handles_quoted_gt_in_attr() {
|
||||
// A `>` inside a quoted attribute value should not terminate
|
||||
// the open tag prematurely.
|
||||
let xml = r#"<x attr="foo>bar">body</x>"#;
|
||||
let (s, e) = find_element(xml, "x", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], r#"<x attr="foo>bar">body</x>"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_iteration() {
|
||||
let xml = "<p>a</p><p>b</p><p>c</p>";
|
||||
let mut positions = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some((s, e)) = find_element(xml, "p", from) {
|
||||
positions.push(&xml[s..e]);
|
||||
from = e;
|
||||
}
|
||||
assert_eq!(positions, vec!["<p>a</p>", "<p>b</p>", "<p>c</p>"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_with_namespace() {
|
||||
let xml = r#"<root><ns:item id="1" /></root>"#;
|
||||
let (s, e) = find_element(xml, "item", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], r#"<ns:item id="1" />"#);
|
||||
}
|
||||
}
|
||||
+20
-24
@@ -82,7 +82,7 @@ pub mod event;
|
||||
pub mod halt;
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod ifo;
|
||||
pub(crate) mod io;
|
||||
pub mod io;
|
||||
pub mod keydb;
|
||||
pub mod labels;
|
||||
pub(crate) mod mpls;
|
||||
@@ -128,16 +128,19 @@ pub use halt::Halt;
|
||||
|
||||
// Generic bounded producer/consumer primitive used by sweep, patch, and
|
||||
// mux to overlap reads with writes via a dedicated consumer thread.
|
||||
// `Pipeline::spawn(depth, sink)` spawns the consumer; `pipe.send(item)`
|
||||
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
|
||||
// pushes one item with back-pressure; `pipe.finish()` joins the
|
||||
// consumer and surfaces its `close()` output. Callers implement `Sink`
|
||||
// to define per-item behaviour and end-of-stream finalisation.
|
||||
//
|
||||
// `DEFAULT_PIPELINE_DEPTH` (=4) is the depth sweep + mux use; patch
|
||||
// uses `WRITE_THROUGH_DEPTH` (=1) so each read fully drains before the
|
||||
// next can enqueue. Returning `Flow::Stop` from `apply` ends the
|
||||
// consumer cleanly (still calls `close()`).
|
||||
pub use io::pipeline::{DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_THROUGH_DEPTH};
|
||||
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
|
||||
// most should use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
|
||||
// `apply` ends the consumer cleanly (still calls `close()`).
|
||||
pub use io::pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
|
||||
pub use event::{Event, EventKind};
|
||||
@@ -162,8 +165,8 @@ pub use decrypt::{DecryptKeys, decrypt_sectors};
|
||||
// structured representation; never compare against display strings.
|
||||
// Note: `disc::Stream` here is the codec enum (audio / video / sub kind)
|
||||
// — not the `pes::Stream` trait re-exported below as `PesStream`. Two
|
||||
// different concepts, the same short name; both stay because both are
|
||||
// load-bearing in their respective domains.
|
||||
// different concepts, the same short name; the trait gets the `Pes`
|
||||
// prefix at the crate root to keep both addressable.
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
|
||||
@@ -176,7 +179,7 @@ pub use disc::{
|
||||
// All stream types implement `pes::Stream` — read PES frames from a source,
|
||||
// write PES frames to a sink. Pick the right type at construction:
|
||||
//
|
||||
// - `DiscStream` — physical drive or ISO (any `SectorReader`). Always read.
|
||||
// - `DiscStream` — physical drive or ISO (any `SectorSource`). Read-only.
|
||||
// - `MkvStream` — Matroska container. Read on `open()`, write on `create()`.
|
||||
// - `M2tsStream` — Blu-ray Transport Stream. Read on `open()`, write on `create()`.
|
||||
// - `NetworkStream` — TCP. Read on `listen()`, write on `connect()`.
|
||||
@@ -186,14 +189,11 @@ pub use disc::{
|
||||
// Most consumers use the URL resolvers (`input()` / `output()`) which pick
|
||||
// the right type from a scheme:// URL. Direct construction is for callers
|
||||
// that need to wire custom readers (e.g. autorip's drive-session reuse).
|
||||
// 0.18 trait split: `FrameSource` (read-only) and `FrameSink` (write-only)
|
||||
// supersede the unified `pes::Stream`. The old `Stream` re-export below
|
||||
// stays available for the deprecation window — re-exported as
|
||||
// `PesStream` to disambiguate from `disc::Stream` (the codec-kind enum
|
||||
// re-exported above), which would otherwise collide at the crate root.
|
||||
#[allow(deprecated)]
|
||||
// The trait is re-exported as `PesStream` here to disambiguate from
|
||||
// `disc::Stream` (the codec-kind enum re-exported above), which would
|
||||
// otherwise collide at the crate root.
|
||||
pub use pes::PesFrame;
|
||||
pub use pes::Stream as PesStream;
|
||||
pub use pes::{FrameSink, FrameSource, PesFrame};
|
||||
|
||||
pub use mux::DiscStream;
|
||||
pub use mux::M2tsStream;
|
||||
@@ -206,18 +206,14 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
// ─── Lower-level surfaces ───────────────────────────────────────────────────
|
||||
//
|
||||
// `ScsiTransport` is the platform-abstraction trait Drive uses; expose for
|
||||
// out-of-tree platform backends. `SectorSource` / `SectorSink` are the 0.18
|
||||
// out-of-tree platform backends. `SectorSource` / `SectorSink` are the
|
||||
// direction-typed read/write traits; `FileSectorSource` and `FileSectorSink`
|
||||
// are the ISO-on-disk implementations. [`DecryptingSectorSource`] is the
|
||||
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||
// `SectorSource` to get plaintext sectors out. The legacy `SectorReader` /
|
||||
// `FileSectorReader` names stay re-exported through the 0.18 migration
|
||||
// window so existing call sites compile unchanged; a blanket impl makes
|
||||
// every `SectorReader` automatically usable as a `SectorSource`.
|
||||
// `SectorSource` to get plaintext sectors out.
|
||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||
pub use sector::{
|
||||
DecryptingSectorSource, FileSectorReader, FileSectorSink, FileSectorSource, SectorReader,
|
||||
SectorSink, SectorSource,
|
||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, SectorSink, SectorSource,
|
||||
};
|
||||
pub use speed::DriveSpeed;
|
||||
pub use udf::{UdfFs, read_filesystem};
|
||||
|
||||
+42
-37
@@ -1,6 +1,6 @@
|
||||
//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
|
||||
//!
|
||||
//! One stream type for all disc sources. The source is a SectorReader —
|
||||
//! One stream type for all disc sources. The source is a SectorSource —
|
||||
//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care.
|
||||
//!
|
||||
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
|
||||
@@ -9,7 +9,7 @@ use crate::disc::{Disc, DiscTitle, Extent};
|
||||
use crate::drive::extract_scsi_context;
|
||||
use crate::event::{BatchSizeReason, Event, EventKind};
|
||||
use crate::halt::Halt;
|
||||
use crate::sector::{DecryptingSectorSource, SectorReader, SectorSource};
|
||||
use crate::sector::{DecryptingSectorSource, SectorSource};
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -97,7 +97,7 @@ impl AdaptiveBatch {
|
||||
|
||||
/// Disc stream. Reads sectors from any source → PES frames.
|
||||
///
|
||||
/// Sources: physical drive, ISO file, or any SectorReader.
|
||||
/// Sources: physical drive, ISO file, or any SectorSource.
|
||||
/// Decrypt, demux, and codec parsing happen internally.
|
||||
pub struct DiscStream {
|
||||
/// Underlying sector source wrapped in the 0.18
|
||||
@@ -105,7 +105,7 @@ pub struct DiscStream {
|
||||
/// call yields plaintext, so `fill_extents` no longer needs an
|
||||
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
||||
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
||||
reader: DecryptingSectorSource<Box<dyn SectorReader>>,
|
||||
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
||||
title: DiscTitle,
|
||||
disc: Option<Disc>,
|
||||
/// Mirror of the keys handed in at construction. The decorator
|
||||
@@ -160,11 +160,11 @@ pub struct DiscStream {
|
||||
impl DiscStream {
|
||||
/// Create a disc stream from any sector reader.
|
||||
///
|
||||
/// Works with physical drives and ISO files — both implement SectorReader.
|
||||
/// Works with physical drives and ISO files — both implement SectorSource.
|
||||
/// The caller opens the source, scans for titles/keys, and passes them in.
|
||||
/// The stream handles demuxing, decryption, and codec parsing internally.
|
||||
pub fn new(
|
||||
reader: Box<dyn SectorReader>,
|
||||
reader: Box<dyn SectorSource>,
|
||||
title: DiscTitle,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
@@ -173,6 +173,13 @@ impl DiscStream {
|
||||
let extents = title.extents.clone();
|
||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||
|
||||
// Debug log reader type at construction — critical for diagnosing mux reading from drive instead of ISO
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"DiscStream constructed with reader type: {}",
|
||||
std::any::type_name::<dyn SectorSource>()
|
||||
);
|
||||
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
@@ -315,10 +322,19 @@ impl DiscStream {
|
||||
// this loop can spend minutes shrinking and skipping sectors; without
|
||||
// the check, Stop wouldn't take effect until the outer PES read() loop
|
||||
// finally emits a frame, which may never happen.
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if self.is_halted() {
|
||||
return Err(crate::error::Error::Halted.into());
|
||||
}
|
||||
|
||||
// Debug: log slow reads during mux — helps diagnose stalls
|
||||
if cfg!(debug_assertions) && start_time.elapsed().as_secs() > 5 {
|
||||
tracing::debug!(target: "mux", "fill_extents waiting at LBA {} ({}s elapsed, sectors={})", lba, start_time.elapsed().as_secs(), remaining);
|
||||
}
|
||||
|
||||
let mut sectors = remaining.min(self.adaptive.current() as u32) as u16;
|
||||
// Align to 3-sector AACS units when possible. Partial units at
|
||||
// extent boundaries are safely handled by decrypt_sectors().
|
||||
@@ -387,7 +403,6 @@ impl DiscStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
|
||||
impl crate::pes::Stream for DiscStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
@@ -568,31 +583,26 @@ impl crate::pes::Stream for DiscStream {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! `DiscStream` is the only meaningful `FrameSource` impl in tree (every
|
||||
//! other concrete `pes::Stream` impl in `mux/*` is a sink). The 0.18
|
||||
//! round-1 blanket `impl<T: pes::Stream + Send> pes::FrameSource for T`
|
||||
//! covers `DiscStream` for free as long as it is `Send`. These tests
|
||||
//! lock that down: a static `Send` assertion plus a `Box<dyn FrameSource>`
|
||||
//! round trip exercising every `FrameSource` method through the trait
|
||||
//! object, so future Send-breaking edits to `DiscStream`'s interior
|
||||
//! types fail at compile time and the trait-bridge dispatch is verified
|
||||
//! at runtime.
|
||||
#![allow(deprecated)] // exercising the 0.18 deprecation-window blanket bridge.
|
||||
//! `DiscStream` is the only read-only `Stream` impl in tree (every
|
||||
//! other concrete impl in `mux/*` is bidirectional or write-only).
|
||||
//! These tests lock down a static `Send` assertion plus a
|
||||
//! `Box<dyn Stream>` round trip exercising every method through the
|
||||
//! trait object, so future Send-breaking edits to `DiscStream`'s
|
||||
//! interior types fail at compile time.
|
||||
use super::*;
|
||||
use crate::disc::{ContentFormat, DiscTitle};
|
||||
use crate::pes::FrameSource;
|
||||
use crate::pes::Stream;
|
||||
|
||||
/// Static-assert `DiscStream: Send`. The blanket
|
||||
/// `impl<T: pes::Stream + Send> pes::FrameSource for T` only fires for
|
||||
/// `Send` types — if a future field on `DiscStream` is non-`Send` (e.g.
|
||||
/// a `Box<dyn Read>` instead of `Box<dyn SectorReader>`), this fails
|
||||
/// Static-assert `DiscStream: Send`. The `Stream` trait has `Send` as a
|
||||
/// supertrait — if a future field on `DiscStream` is non-`Send` (e.g.
|
||||
/// a `Box<dyn Read>` instead of `Box<dyn SectorSource>`), this fails
|
||||
/// at compile time, before the runtime trait-object test below.
|
||||
fn _assert_disc_stream_is_send() {
|
||||
fn requires_send<T: Send>() {}
|
||||
requires_send::<DiscStream>();
|
||||
}
|
||||
|
||||
/// Trivial `SectorReader` that yields zeroed sectors. Empty title means
|
||||
/// Trivial `SectorSource` that yields zeroed sectors. Empty title means
|
||||
/// the demuxer produces no PES frames, so `read()` walks the extents to
|
||||
/// EOF and returns `Ok(None)`. That's enough to exercise the trait-object
|
||||
/// dispatch — the goal here is the bridge, not the demuxer.
|
||||
@@ -600,7 +610,7 @@ mod tests {
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl crate::sector::SectorReader for ZeroReader {
|
||||
impl crate::sector::SectorSource for ZeroReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
@@ -613,7 +623,7 @@ mod tests {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
@@ -628,13 +638,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest credible witness that `DiscStream` flows through the
|
||||
/// `FrameSource` blanket impl: build a `Box<dyn FrameSource>`, drive
|
||||
/// `read()` to EOF, exercise `info()` / `headers_ready()` /
|
||||
/// `codec_private()` through the trait object. The trait-bridge
|
||||
/// correctness is what's being verified — not demuxer behaviour.
|
||||
/// Smallest credible witness that `DiscStream` flows through `dyn Stream`:
|
||||
/// build a `Box<dyn Stream>`, drive `read()` to EOF, exercise `info()` /
|
||||
/// `headers_ready()` / `codec_private()` through the trait object.
|
||||
#[test]
|
||||
fn frame_source_via_dyn_object() {
|
||||
fn stream_via_dyn_object() {
|
||||
let reader = ZeroReader { capacity: 8 };
|
||||
let title = synthetic_title(8);
|
||||
let stream = DiscStream::new(
|
||||
@@ -645,7 +653,7 @@ mod tests {
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
|
||||
let mut src: Box<dyn FrameSource> = Box::new(stream);
|
||||
let mut src: Box<dyn Stream> = Box::new(stream);
|
||||
|
||||
// Empty-title fixture has no streams configured, so headers are
|
||||
// trivially ready and codec_private() yields nothing on track 0.
|
||||
@@ -657,11 +665,8 @@ mod tests {
|
||||
// produces no frames, but the call still routes through the blanket
|
||||
// dispatch into Stream::read.
|
||||
let mut frames = 0usize;
|
||||
loop {
|
||||
match src.read().expect("read") {
|
||||
Some(_) => frames += 1,
|
||||
None => break,
|
||||
}
|
||||
while src.read().expect("read").is_some() {
|
||||
frames += 1;
|
||||
if frames > 1024 {
|
||||
panic!("unexpected unbounded frame stream from empty title");
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
//! ISO sector reader — file-backed SectorReader for Blu-ray ISO images.
|
||||
//! ISO sector reader — file-backed SectorSource for Blu-ray ISO images.
|
||||
//!
|
||||
//! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048.
|
||||
//! Used by DiscStream::open_iso() and Disc::scan_image().
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
@@ -32,12 +32,12 @@ impl IsoSectorReader {
|
||||
Ok(Self { file, capacity })
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> u32 {
|
||||
pub fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for IsoSectorReader {
|
||||
impl SectorSource for IsoSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
@@ -73,7 +73,7 @@ mod tests {
|
||||
std::fs::write(&dir, &data).unwrap();
|
||||
|
||||
let mut reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap();
|
||||
assert_eq!(reader.capacity(), 4);
|
||||
assert_eq!(reader.capacity_sectors(), 4);
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
reader.read_sectors(0, 1, &mut buf, true).unwrap();
|
||||
@@ -94,7 +94,7 @@ mod tests {
|
||||
std::fs::write(&dir, &data).unwrap();
|
||||
|
||||
let reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap();
|
||||
assert_eq!(reader.capacity(), 10);
|
||||
assert_eq!(reader.capacity_sectors(), 10);
|
||||
|
||||
std::fs::remove_file(&dir).ok();
|
||||
}
|
||||
|
||||
@@ -173,7 +173,6 @@ impl M2tsStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
|
||||
impl crate::pes::Stream for M2tsStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
@@ -284,27 +283,3 @@ impl crate::pes::Stream for M2tsStream {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
|
||||
/// 0.18 deprecation window. Caller may pick either at the trait-object
|
||||
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
|
||||
/// Use `M2tsStream::create(writer, title)` to construct the write half;
|
||||
/// calling `FrameSink::write` on an `M2tsStream::open(reader)` instance
|
||||
/// returns `StreamReadOnly`.
|
||||
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
|
||||
impl crate::pes::FrameSink for M2tsStream {
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
<Self as crate::pes::Stream>::write(self, frame)
|
||||
}
|
||||
|
||||
fn finish(self: Box<Self>) -> io::Result<()> {
|
||||
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
|
||||
// Re-borrow inside the box, call Stream::finish, drop the box.
|
||||
let mut s: Self = *self;
|
||||
<Self as crate::pes::Stream>::finish(&mut s)
|
||||
}
|
||||
|
||||
fn info(&self) -> &crate::disc::DiscTitle {
|
||||
<Self as crate::pes::Stream>::info(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ impl MkvStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
|
||||
impl crate::pes::Stream for MkvStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
let rs = match self.mode {
|
||||
@@ -189,35 +188,6 @@ impl crate::pes::Stream for MkvStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
|
||||
/// 0.18 deprecation window. Caller may pick either at the trait-object
|
||||
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
|
||||
/// Use `MkvStream::create(writer, title)` to construct the write half;
|
||||
/// calling `FrameSink::write` on a `MkvStream::open(reader)` instance returns
|
||||
/// `StreamReadOnly`. `finish` is where the Cues index is written, so it must
|
||||
/// be called for the resulting MKV to be seekable.
|
||||
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
|
||||
impl crate::pes::FrameSink for MkvStream {
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
<Self as crate::pes::Stream>::write(self, frame)
|
||||
}
|
||||
|
||||
fn finish(self: Box<Self>) -> io::Result<()> {
|
||||
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
|
||||
// Re-borrow inside the box, call Stream::finish, drop the box.
|
||||
// The inner `MkvMuxer` is owned via `Option`, and `Stream::finish`
|
||||
// already takes it via `Option::take()` — moving `*self` out of the
|
||||
// box hands it the same field by-value, so the muxer's own
|
||||
// by-value `finish()` runs correctly.
|
||||
let mut s: Self = *self;
|
||||
<Self as crate::pes::Stream>::finish(&mut s)
|
||||
}
|
||||
|
||||
fn info(&self) -> &crate::disc::DiscTitle {
|
||||
<Self as crate::pes::Stream>::info(self)
|
||||
}
|
||||
}
|
||||
|
||||
// ── MKV header parsing (read side) ────────────────────────────
|
||||
|
||||
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
|
||||
|
||||
@@ -70,7 +70,6 @@ impl NetworkStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
|
||||
impl crate::pes::Stream for NetworkStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
match &mut self.mode {
|
||||
@@ -109,30 +108,6 @@ impl crate::pes::Stream for NetworkStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
|
||||
/// 0.18 deprecation window. Caller may pick either at the trait-object
|
||||
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
|
||||
/// Use `NetworkStream::connect(addr).meta(title)` to construct the write
|
||||
/// half; calling `FrameSink::write` on `NetworkStream::listen(addr)` returns
|
||||
/// `StreamReadOnly`.
|
||||
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
|
||||
impl crate::pes::FrameSink for NetworkStream {
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
<Self as crate::pes::Stream>::write(self, frame)
|
||||
}
|
||||
|
||||
fn finish(self: Box<Self>) -> io::Result<()> {
|
||||
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
|
||||
// Re-borrow inside the box, call Stream::finish, drop the box.
|
||||
let mut s: Self = *self;
|
||||
<Self as crate::pes::Stream>::finish(&mut s)
|
||||
}
|
||||
|
||||
fn info(&self) -> &DiscTitle {
|
||||
<Self as crate::pes::Stream>::info(self)
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkStream is PES-only — no IOStream/Read/Write byte interface.
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -182,7 +157,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires TCP; may be flaky in CI environments
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
|
||||
fn network_pes_roundtrip() {
|
||||
use crate::pes;
|
||||
|
||||
|
||||
+4
-34
@@ -16,7 +16,6 @@ impl NullStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSink in follow-up commit.
|
||||
impl crate::pes::Stream for NullStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
Ok(None)
|
||||
@@ -32,43 +31,16 @@ impl crate::pes::Stream for NullStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
|
||||
/// 0.18 deprecation window. Caller may pick either at the trait-object
|
||||
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
|
||||
/// The deprecation-window callers eventually migrate; this impl exists so
|
||||
/// new callers can target `FrameSink` without waiting for the rest of the
|
||||
/// migration to complete.
|
||||
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
|
||||
impl crate::pes::FrameSink for NullStream {
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
<Self as crate::pes::Stream>::write(self, frame)
|
||||
}
|
||||
|
||||
fn finish(self: Box<Self>) -> io::Result<()> {
|
||||
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
|
||||
// Re-borrow inside the box, call Stream::finish, drop the box.
|
||||
let mut s: Self = *self;
|
||||
<Self as crate::pes::Stream>::finish(&mut s)
|
||||
}
|
||||
|
||||
fn info(&self) -> &DiscTitle {
|
||||
<Self as crate::pes::Stream>::info(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pes::FrameSink;
|
||||
use crate::pes::Stream;
|
||||
|
||||
/// Smallest credible witness that the new FrameSink impl on a concrete
|
||||
/// `mux/*` sink works through the trait object: build a boxed
|
||||
/// `dyn FrameSink`, write a frame, finish it. The trait-bridge correctness
|
||||
/// is what's being verified — not NullStream-specific behaviour.
|
||||
/// Verify NullStream routes through the `Stream` trait object cleanly.
|
||||
#[test]
|
||||
fn frame_sink_via_dyn_object_writes_and_finishes() {
|
||||
fn stream_via_dyn_object_writes_and_finishes() {
|
||||
let title = DiscTitle::empty();
|
||||
let mut sink: Box<dyn FrameSink> = Box::new(NullStream::new(&title));
|
||||
let mut sink: Box<dyn Stream> = Box::new(NullStream::new(&title));
|
||||
|
||||
let frame = crate::pes::PesFrame {
|
||||
track: 0,
|
||||
@@ -77,9 +49,7 @@ mod tests {
|
||||
data: vec![0x01, 0x02, 0x03],
|
||||
};
|
||||
sink.write(&frame).unwrap();
|
||||
// info() routes through the trait object.
|
||||
let _ = sink.info();
|
||||
// finish() consumes the Box<Self> — must compile and run.
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -167,7 +167,6 @@ pub struct InputOptions {
|
||||
}
|
||||
|
||||
/// Open a PES input stream (produces PES frames).
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource in follow-up commit.
|
||||
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||
let parsed = parse_url(url);
|
||||
match parsed {
|
||||
@@ -187,7 +186,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?;
|
||||
let capacity = reader.capacity();
|
||||
let capacity = reader.capacity_sectors();
|
||||
let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
if disc.titles.is_empty() {
|
||||
@@ -204,7 +203,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
let title = disc.titles[idx].clone();
|
||||
let keys = disc.decrypt_keys();
|
||||
let format = disc.content_format;
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 64, format);
|
||||
// ISO file: use large batch size (16 MB) — sequential read from fast storage, no bad sectors.
|
||||
// Physical drives need small batches for adaptive error handling and retry logic.
|
||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||
let mut stream =
|
||||
DiscStream::new(Box::new(reader), title, keys, ISO_MUX_BATCH_SECTORS, format);
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
@@ -235,7 +238,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
|
||||
/// Open a PES output stream (consumes PES frames).
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSink in follow-up commit.
|
||||
pub fn output(
|
||||
url: &str,
|
||||
title: &crate::disc::DiscTitle,
|
||||
|
||||
@@ -60,7 +60,6 @@ impl StdioStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
|
||||
impl crate::pes::Stream for StdioStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
self.ensure_header_read()?;
|
||||
@@ -105,26 +104,3 @@ impl crate::pes::Stream for StdioStream {
|
||||
self.header_read || self.writer.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
|
||||
/// 0.18 deprecation window. Caller may pick either at the trait-object
|
||||
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
|
||||
/// Use `StdioStream::output(title)` to construct the write half; calling
|
||||
/// `FrameSink::write` on a `StdioStream::input()` returns `StreamReadOnly`.
|
||||
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
|
||||
impl crate::pes::FrameSink for StdioStream {
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
<Self as crate::pes::Stream>::write(self, frame)
|
||||
}
|
||||
|
||||
fn finish(self: Box<Self>) -> io::Result<()> {
|
||||
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
|
||||
// Re-borrow inside the box, call Stream::finish, drop the box.
|
||||
let mut s: Self = *self;
|
||||
<Self as crate::pes::Stream>::finish(&mut s)
|
||||
}
|
||||
|
||||
fn info(&self) -> &DiscTitle {
|
||||
<Self as crate::pes::Stream>::info(self)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-233
@@ -78,19 +78,20 @@ impl PesFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deprecated; use [`FrameSource`] for read-only sources or [`FrameSink`]
|
||||
/// for write-only sinks. The runtime direction-error semantics
|
||||
/// (`StreamReadOnly` / `StreamWriteOnly` from a wrong-direction call) are
|
||||
/// removed in 0.18 — direction is type-checked.
|
||||
#[deprecated(
|
||||
since = "0.18.0",
|
||||
note = "use FrameSource (read-only) or FrameSink (write-only) instead"
|
||||
)]
|
||||
pub trait Stream {
|
||||
/// Read the next frame, or `Ok(None)` at end of stream.
|
||||
/// A PES frame stream. One trait per format — same type opens for read
|
||||
/// (`open()` / `listen()` / `input()`) or write (`create()` / `connect()` /
|
||||
/// `output()`). Calling the wrong-direction method returns a typed
|
||||
/// `StreamReadOnly` / `StreamWriteOnly` error.
|
||||
///
|
||||
/// `Send` is required so streams can move across the producer/consumer
|
||||
/// threads in autorip's mux pipeline.
|
||||
pub trait Stream: Send {
|
||||
/// Read the next frame, or `Ok(None)` at end of stream. Returns
|
||||
/// `StreamWriteOnly` on a stream opened for writing.
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
|
||||
|
||||
/// Write a frame to the sink.
|
||||
/// Write a frame to the sink. Returns `StreamReadOnly` on a stream
|
||||
/// opened for reading.
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
|
||||
|
||||
/// Finalize the stream: flush buffered frames, write any container
|
||||
@@ -116,115 +117,10 @@ pub trait Stream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only source of PES frames.
|
||||
///
|
||||
/// Replaces the read half of the deprecated [`Stream`] trait. Implementors
|
||||
/// produce frames via [`read`](FrameSource::read) and never accept writes —
|
||||
/// passing a `FrameSource` where a sink is expected is a compile error,
|
||||
/// not a runtime `E9001`.
|
||||
///
|
||||
/// `info()` returns the source's `DiscTitle` metadata (track list, codec
|
||||
/// info, duration). It must be stable across the lifetime of the source.
|
||||
///
|
||||
/// `codec_private(track)` exposes per-track codec initialization data
|
||||
/// (H.264 SPS/PPS, HEVC VPS/SPS/PPS, AC-3 fscod, etc.) that downstream
|
||||
/// muxers may need before any frame is written. `headers_ready()` returns
|
||||
/// false until enough input frames have been seen to populate every video
|
||||
/// track's codec-private blob — callers buffer frames they read until
|
||||
/// `headers_ready()` returns true.
|
||||
pub trait FrameSource: Send {
|
||||
/// Read the next frame, or `Ok(None)` at end of stream.
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
|
||||
|
||||
/// Source metadata. Stable across reads — implementors must return a
|
||||
/// consistent reference for the lifetime of the source.
|
||||
fn info(&self) -> &crate::disc::DiscTitle;
|
||||
|
||||
/// Codec initialization data for a track (SPS/PPS, AC-3 fscod, etc.).
|
||||
/// `None` for tracks that don't need codec_private (raw passthrough).
|
||||
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// True when `codec_private` is available for every video track —
|
||||
/// callers buffer input frames until this flips, since some output
|
||||
/// formats (MKV) can't write frames without codec init data.
|
||||
fn headers_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Write-only sink of PES frames.
|
||||
///
|
||||
/// Replaces the write half of the deprecated [`Stream`] trait. Implementors
|
||||
/// accept frames via [`write`](FrameSink::write) and finalize via
|
||||
/// [`finish`](FrameSink::finish) — passing a `FrameSink` where a source is
|
||||
/// expected is a compile error, not a runtime `E9000`.
|
||||
///
|
||||
/// `finish` takes `Box<Self>` (rather than `&mut self` like `Stream::finish`)
|
||||
/// so that finalization is a one-shot terminal operation: callers cannot use
|
||||
/// the sink after `finish` returns. This is the standard idiom for terminal
|
||||
/// methods on `dyn Trait` objects.
|
||||
pub trait FrameSink: Send {
|
||||
/// Write a frame to the sink.
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
|
||||
|
||||
/// Finalize the sink: flush buffered frames, write any container index
|
||||
/// (e.g. MKV `Cues`), close the underlying file/socket. Consumes the
|
||||
/// sink — callers cannot use it afterwards.
|
||||
fn finish(self: Box<Self>) -> std::io::Result<()>;
|
||||
|
||||
/// Sink metadata. Stable across writes — implementors must return a
|
||||
/// consistent reference for the lifetime of the sink.
|
||||
fn info(&self) -> &crate::disc::DiscTitle;
|
||||
}
|
||||
|
||||
// Bridge: any **`Send`** type implementing the deprecated `Stream` trait
|
||||
// is also a `FrameSource`. This lets existing concrete `Stream` impls in
|
||||
// `mux/*` satisfy `FrameSource` bounds without per-type migration during
|
||||
// the 0.18 deprecation window.
|
||||
//
|
||||
// **Send caveat (read me before tightening `Stream` itself).** This
|
||||
// blanket carries a `T: Send` bound rather than promoting `Send` to a
|
||||
// supertrait of `Stream`, because not every concrete in-tree `Stream`
|
||||
// impl is `Send`: `MkvStream` and `M2tsStream` carry `Box<dyn Read>`
|
||||
// and `Box<dyn Write>` fields whose trait objects don't include `Send`.
|
||||
// Adding `Stream: Send` would force a wider audit (every `Box<dyn Read>`
|
||||
// becomes `Box<dyn Read + Send>`) than this commit is taking on, and
|
||||
// the type-level migration target is `FrameSource` / `FrameSink`
|
||||
// directly anyway. Consequence: coercing a non-Send `Box<dyn Stream>`
|
||||
// (the return shape of `crate::mux::input` / `output`) to
|
||||
// `Box<dyn FrameSource>` will fail with a `T: Send` trait-bound error.
|
||||
// The fix on the consumer side is to construct a Send-compliant
|
||||
// `FrameSource` / `FrameSink` directly rather than relying on this
|
||||
// bridge for non-Send streams.
|
||||
//
|
||||
// Note: `FrameSink` cannot be blanket-impl'd from `Stream` because
|
||||
// `Stream::finish` takes `&mut self` while `FrameSink::finish` takes
|
||||
// `Box<Self>`; concrete types will be migrated in a follow-up commit.
|
||||
#[allow(deprecated)]
|
||||
impl<T: Stream + Send + ?Sized> FrameSource for T {
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
|
||||
<Self as Stream>::read(self)
|
||||
}
|
||||
|
||||
fn info(&self) -> &crate::disc::DiscTitle {
|
||||
<Self as Stream>::info(self)
|
||||
}
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
<Self as Stream>::codec_private(self, track)
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
<Self as Stream>::headers_ready(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps any output stream and counts bytes written.
|
||||
///
|
||||
/// Progress tracking is a CLI concern — streams don't know their size.
|
||||
/// Wrap the output with CountingStream, then query bytes_written().
|
||||
/// Wrap the output with `CountingStream`, then query `bytes_written()`.
|
||||
///
|
||||
/// ```text
|
||||
/// let mut output = CountingStream::new(libfreemkv::output(dest, &title)?);
|
||||
@@ -233,13 +129,11 @@ impl<T: Stream + Send + ?Sized> FrameSource for T {
|
||||
/// let pct = output.bytes_written() as f64 / total as f64;
|
||||
/// }
|
||||
/// ```
|
||||
#[allow(deprecated)]
|
||||
pub struct CountingStream {
|
||||
inner: Box<dyn Stream>,
|
||||
written: u64,
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl CountingStream {
|
||||
pub fn new(inner: Box<dyn Stream>) -> Self {
|
||||
Self { inner, written: 0 }
|
||||
@@ -251,7 +145,6 @@ impl CountingStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl Stream for CountingStream {
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
|
||||
self.inner.read()
|
||||
@@ -293,88 +186,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct `FrameSource` impl (not via the deprecated `Stream` blanket).
|
||||
struct MockSource {
|
||||
frames: std::vec::IntoIter<PesFrame>,
|
||||
/// Minimal in-memory `Stream` for trait-shape tests. `read` replays
|
||||
/// pre-seeded frames; `write` collects them.
|
||||
struct MockStream {
|
||||
read_queue: std::vec::IntoIter<PesFrame>,
|
||||
written: Vec<PesFrame>,
|
||||
title: DiscTitle,
|
||||
}
|
||||
|
||||
impl MockSource {
|
||||
fn new(frames: Vec<PesFrame>) -> Self {
|
||||
impl MockStream {
|
||||
fn new(read_frames: Vec<PesFrame>) -> Self {
|
||||
Self {
|
||||
frames: frames.into_iter(),
|
||||
read_queue: read_frames.into_iter(),
|
||||
written: Vec::new(),
|
||||
title: DiscTitle::empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameSource for MockSource {
|
||||
impl Stream for MockStream {
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
|
||||
Ok(self.frames.next())
|
||||
Ok(self.read_queue.next())
|
||||
}
|
||||
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.title
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct `FrameSink` impl (not via the deprecated `Stream` blanket).
|
||||
struct MockSink {
|
||||
collected: Vec<PesFrame>,
|
||||
title: DiscTitle,
|
||||
}
|
||||
|
||||
impl MockSink {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
collected: Vec::new(),
|
||||
title: DiscTitle::empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameSink for MockSink {
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()> {
|
||||
self.collected.push(frame.clone());
|
||||
self.written.push(frame.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(self: Box<Self>) -> std::io::Result<()> {
|
||||
// Drop self; in real sinks this is where flush/fsync/close happens.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.title
|
||||
}
|
||||
}
|
||||
|
||||
/// Variant of `MockSink` whose `finish` returns the collected frames so
|
||||
/// the test can assert on them after the consuming `Box<Self>` call.
|
||||
struct CollectingSink {
|
||||
collected: Vec<PesFrame>,
|
||||
title: DiscTitle,
|
||||
}
|
||||
|
||||
impl CollectingSink {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
collected: Vec::new(),
|
||||
title: DiscTitle::empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameSink for CollectingSink {
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()> {
|
||||
self.collected.push(frame.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(self: Box<Self>) -> std::io::Result<()> {
|
||||
// Real CollectingSink consumers would expose `take()` before
|
||||
// finish; this trait method just confirms the boxed signature
|
||||
// compiles and runs.
|
||||
fn finish(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -384,72 +224,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_source_yields_frames_then_eof() {
|
||||
fn stream_read_yields_frames_then_eof() {
|
||||
let frames = vec![make_frame(0, 0), make_frame(1, 1_000), make_frame(0, 2_000)];
|
||||
let mut src = MockSource::new(frames.clone());
|
||||
let mut s = MockStream::new(frames.clone());
|
||||
|
||||
let f0 = src.read().unwrap().expect("first frame");
|
||||
let f0 = s.read().unwrap().expect("first frame");
|
||||
assert_eq!(f0.track, frames[0].track);
|
||||
assert_eq!(f0.pts, frames[0].pts);
|
||||
assert!(f0.keyframe);
|
||||
|
||||
let f1 = src.read().unwrap().expect("second frame");
|
||||
assert_eq!(f1.track, frames[1].track);
|
||||
let f1 = s.read().unwrap().expect("second frame");
|
||||
assert_eq!(f1.pts, frames[1].pts);
|
||||
|
||||
let f2 = src.read().unwrap().expect("third frame");
|
||||
assert_eq!(f2.track, frames[2].track);
|
||||
let f2 = s.read().unwrap().expect("third frame");
|
||||
assert_eq!(f2.pts, frames[2].pts);
|
||||
|
||||
assert!(src.read().unwrap().is_none());
|
||||
assert!(src.read().unwrap().is_none()); // idempotent at EOF
|
||||
assert!(s.read().unwrap().is_none());
|
||||
assert!(s.read().unwrap().is_none()); // idempotent at EOF
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_sink_collects_then_finishes() {
|
||||
let mut sink = MockSink::new();
|
||||
fn stream_write_collects_then_finishes() {
|
||||
let mut s = MockStream::new(Vec::new());
|
||||
let frames = [make_frame(0, 0), make_frame(1, 100), make_frame(2, 200)];
|
||||
|
||||
for f in &frames {
|
||||
sink.write(f).unwrap();
|
||||
s.write(f).unwrap();
|
||||
}
|
||||
assert_eq!(sink.collected.len(), 3);
|
||||
assert_eq!(sink.collected[0].pts, 0);
|
||||
assert_eq!(sink.collected[1].pts, 100);
|
||||
assert_eq!(sink.collected[2].pts, 200);
|
||||
|
||||
// Box-and-finish — the `self: Box<Self>` shape must compile and run.
|
||||
Box::new(sink).finish().unwrap();
|
||||
assert_eq!(s.written.len(), 3);
|
||||
s.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_sink_via_dyn_object() {
|
||||
let frames = [make_frame(0, 0), make_frame(0, 33)];
|
||||
let mut sink: Box<dyn FrameSink> = Box::new(CollectingSink::new());
|
||||
|
||||
for f in &frames {
|
||||
sink.write(f).unwrap();
|
||||
}
|
||||
// info() routes through the trait object.
|
||||
let _ = sink.info();
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
|
||||
/// The deprecated blanket impl: any concrete `Stream` should also act as
|
||||
/// a `FrameSource`. `NullStream` has the simplest constructor of every
|
||||
/// concrete `Stream` impl in `mux/*`, so it's the smallest credible
|
||||
/// witness that the bridge compiles and dispatches correctly.
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn deprecated_stream_satisfies_frame_source() {
|
||||
let title = DiscTitle::empty();
|
||||
let mut null = crate::mux::NullStream::new(&title);
|
||||
|
||||
let src: &mut dyn FrameSource = &mut null;
|
||||
// NullStream::read returns Ok(None) — it's a write-only sink.
|
||||
assert!(src.read().unwrap().is_none());
|
||||
// info() forwards through the blanket impl.
|
||||
let _ = src.info();
|
||||
assert!(src.headers_ready());
|
||||
fn stream_via_dyn_object() {
|
||||
let mut s: Box<dyn Stream> = Box::new(MockStream::new(vec![make_frame(0, 0)]));
|
||||
let frame = s.read().unwrap().expect("first frame");
|
||||
s.write(&frame).unwrap();
|
||||
let _ = s.info();
|
||||
s.finish().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -13,7 +13,7 @@ use std::path::Path;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
use super::{SectorReader, SectorSink};
|
||||
use super::{SectorSink, SectorSource};
|
||||
|
||||
/// SectorSource backed by a file (ISO image).
|
||||
///
|
||||
@@ -49,14 +49,14 @@ impl FileSectorSource {
|
||||
}
|
||||
}
|
||||
|
||||
// Implement the legacy `SectorReader` trait. The blanket impl in
|
||||
// Implement the legacy `SectorSource` trait. The blanket impl in
|
||||
// `super` produces the `SectorSource` impl automatically — no need
|
||||
// to write both, and writing both would conflict. This keeps the
|
||||
// 0.17 method-resolution path intact (callers with `SectorReader`
|
||||
// 0.17 method-resolution path intact (callers with `SectorSource`
|
||||
// in scope can still write `fsr.read_sectors(..)` against a
|
||||
// `FileSectorSource`).
|
||||
impl SectorReader for FileSectorSource {
|
||||
fn capacity(&self) -> u32 {
|
||||
impl SectorSource for FileSectorSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
|
||||
@@ -147,8 +147,8 @@ impl SectorSink for FileSectorSink {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Bring the 0.18 trait into scope (not super::*: the super
|
||||
// module also re-exports the legacy `SectorReader`, and
|
||||
// having both `SectorReader::read_sectors` and
|
||||
// module also re-exports the legacy `SectorSource`, and
|
||||
// having both `SectorSource::read_sectors` and
|
||||
// `SectorSource::read_sectors` visible would force every
|
||||
// call site to disambiguate). External consumers see the
|
||||
// same surface this test exercises.
|
||||
|
||||
+60
-149
@@ -1,29 +1,17 @@
|
||||
//! Sector-level I/O traits.
|
||||
//!
|
||||
//! 0.18 splits the unidirectional read trait from a write trait at
|
||||
//! the sector layer, so the type system catches "wrong direction"
|
||||
//! mistakes at compile time instead of runtime. See
|
||||
//! `(internal)/memory/0_18_redesign.md`.
|
||||
//! The sector layer is direction-typed: [`SectorSource`] reads
|
||||
//! 2048-byte sectors, [`SectorSink`] writes them. Concrete impls
|
||||
//! never do both — physical drives are read-only, file-backed
|
||||
//! ISO images are opened for read OR write at construction time.
|
||||
//!
|
||||
//! - [`SectorSource`] reads 2048-byte sectors. Implemented by
|
||||
//! `Drive` (via the legacy [`SectorReader`] alias) and
|
||||
//! [`FileSectorSource`] (ISO-backed).
|
||||
//! - [`SectorSink`] writes 2048-byte sectors. Implemented by
|
||||
//! [`FileSectorSink`] (ISO-backed) and, in later commits, by
|
||||
//! sweep/patch consumer adapters.
|
||||
//! - [`SectorSource`] is implemented by `Drive` (hardware) and
|
||||
//! [`FileSectorSource`] / `IsoSectorReader` (file-backed).
|
||||
//! - [`SectorSink`] is implemented by [`FileSectorSink`]
|
||||
//! (ISO-backed) and sweep/patch consumer adapters.
|
||||
//! - [`DecryptingSectorSource`] is a decorator that wraps any
|
||||
//! `SectorSource` and applies the existing AACS / CSS in-place
|
||||
//! decrypt to plaintext-out.
|
||||
//!
|
||||
//! [`SectorReader`] is the 0.17 read trait. It stays on through
|
||||
//! the 0.18 migration window so existing call sites
|
||||
//! (`Drive`, `IsoSectorReader`, `BufferedSectorReader`,
|
||||
//! `DiscStream`, `verify`) compile unchanged. A blanket impl
|
||||
//! forwards every `SectorReader` impl to `SectorSource`, so new
|
||||
//! code should target `SectorSource` / `SectorSink` directly. The
|
||||
//! formal `#[deprecated]` attribute lands once the internal
|
||||
//! callers have migrated; see the comment on `SectorReader` for
|
||||
//! why this commit holds it back.
|
||||
//! `SectorSource` and applies AACS / CSS in-place decrypt to
|
||||
//! yield plaintext sectors.
|
||||
|
||||
pub mod decrypting;
|
||||
pub mod file;
|
||||
@@ -32,13 +20,14 @@ use crate::error::Result;
|
||||
|
||||
/// Read 2048-byte sectors from a disc, image, or composed source.
|
||||
///
|
||||
/// Direction-typed: a `SectorSource` cannot be written to. Wrap the
|
||||
/// inner source in [`DecryptingSectorSource`] to get plaintext
|
||||
/// sectors out of an encrypted disc.
|
||||
/// Wrap the inner source in [`DecryptingSectorSource`] to get
|
||||
/// plaintext sectors out of an encrypted disc.
|
||||
pub trait SectorSource: Send {
|
||||
/// Total capacity in sectors, if known. Returns 0 when unknown
|
||||
/// Total capacity in sectors, if known. Default `0` = unknown
|
||||
/// (e.g. live drives that haven't completed `READ CAPACITY` yet).
|
||||
fn capacity_sectors(&self) -> u32;
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Read `count` sectors starting at `lba` into `buf`.
|
||||
/// `buf` must be at least `count * 2048` bytes.
|
||||
@@ -60,10 +49,52 @@ pub trait SectorSource: Send {
|
||||
fn set_speed(&mut self, _kbs: u16) {}
|
||||
}
|
||||
|
||||
// Forwarding impls so `Box<dyn SectorSource>` and `&mut dyn SectorSource`
|
||||
// satisfy the `SectorSource` trait bound when wrapped by generic
|
||||
// decorators like `DecryptingSectorSource<S: SectorSource>`.
|
||||
impl SectorSource for Box<dyn SectorSource> {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
(**self).capacity_sectors()
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
(**self).read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
(**self).set_speed(kbs)
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for &mut (dyn SectorSource + '_) {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
(**self).capacity_sectors()
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
(**self).read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
(**self).set_speed(kbs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write 2048-byte sectors to a disc image or composed sink.
|
||||
///
|
||||
/// Direction-typed: a `SectorSink` cannot be read from. The
|
||||
/// terminal [`finish`] takes `Box<Self>` so it can run on `dyn
|
||||
/// The terminal [`finish`] takes `Box<Self>` so it can run on `dyn
|
||||
/// SectorSink` and consume the sink (`fsync` + close).
|
||||
///
|
||||
/// [`finish`]: SectorSink::finish
|
||||
@@ -78,125 +109,5 @@ pub trait SectorSink: Send {
|
||||
fn finish(self: Box<Self>) -> Result<()>;
|
||||
}
|
||||
|
||||
/// 0.17 read trait. Slated for removal once internal call sites
|
||||
/// migrate to [`SectorSource`] in follow-up commits; until then
|
||||
/// it remains the trait that `Drive`, `IsoSectorReader`,
|
||||
/// `BufferedSectorReader`, and existing `&mut dyn SectorReader`
|
||||
/// signatures use unchanged.
|
||||
///
|
||||
/// New code should implement [`SectorSource`] directly. The
|
||||
/// blanket impl below makes any `SectorReader` automatically
|
||||
/// usable wherever a `SectorSource` is expected, so a one-way
|
||||
/// migration off `SectorReader` is possible per-callsite without
|
||||
/// touching the impls.
|
||||
//
|
||||
// NOTE: not marked `#[deprecated]` in this commit — `cargo clippy
|
||||
// -- -D warnings` (the CI gauntlet) treats deprecation as an
|
||||
// error, and the existing `Drive` / `udf::BufferedSectorReader` /
|
||||
// `mux::DiscStream` / `verify` call sites all go through this
|
||||
// trait. The deprecation attribute lands together with the
|
||||
// migration commits that move those call sites to
|
||||
// `SectorSource`. The behavioural contract — "this trait is
|
||||
// going away in 0.18" — is documented above and tracked in
|
||||
// `(internal)/memory/0_18_redesign.md`.
|
||||
pub trait SectorReader: Send {
|
||||
/// Read `count` sectors starting at `lba` into `buf`.
|
||||
/// See [`SectorSource::read_sectors`] for semantics.
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize>;
|
||||
|
||||
/// Total capacity in sectors, if known.
|
||||
fn capacity(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, _kbs: u16) {}
|
||||
}
|
||||
|
||||
// Blanket impl: anything implementing the legacy `SectorReader`
|
||||
// trait automatically satisfies `SectorSource`. This is what keeps
|
||||
// existing impls (`Drive`, `IsoSectorReader`, `BufferedSectorReader`,
|
||||
// etc.) compiling without source changes during the migration. The
|
||||
// reverse direction (impl SectorReader for SectorSource) is
|
||||
// intentionally NOT provided — new code targets the new trait.
|
||||
impl<T: SectorReader + ?Sized> SectorSource for T {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
<T as SectorReader>::capacity(self)
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
<T as SectorReader>::read_sectors(self, lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
<T as SectorReader>::set_speed(self, kbs)
|
||||
}
|
||||
}
|
||||
|
||||
// Forwarding impls so callers can wrap `&mut dyn SectorReader` /
|
||||
// `Box<dyn SectorReader>` in [`DecryptingSectorSource`] without
|
||||
// having to unbox or re-borrow inside the lib's hot paths. The
|
||||
// generic `&mut T` / `Box<T>` blankets would conflict with the
|
||||
// `SectorReader → SectorSource` blanket above (a downstream crate
|
||||
// could `impl SectorReader for &mut U`); the specific
|
||||
// `dyn SectorReader` instantiations are unambiguous because
|
||||
// `SectorReader` is the very trait whose `dyn` we're targeting.
|
||||
impl SectorSource for &mut (dyn SectorReader + '_) {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
<dyn SectorReader as SectorReader>::capacity(*self)
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
<dyn SectorReader as SectorReader>::read_sectors(*self, lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
<dyn SectorReader as SectorReader>::set_speed(*self, kbs)
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for Box<dyn SectorReader> {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
<dyn SectorReader as SectorReader>::capacity(&**self)
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
<dyn SectorReader as SectorReader>::read_sectors(&mut **self, lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
<dyn SectorReader as SectorReader>::set_speed(&mut **self, kbs)
|
||||
}
|
||||
}
|
||||
|
||||
pub use decrypting::DecryptingSectorSource;
|
||||
pub use file::{FileSectorSink, FileSectorSource};
|
||||
|
||||
// Backwards-compat alias for the public API. `FileSectorReader` is
|
||||
// the 0.17 name; new code uses `FileSectorSource`. Both point at
|
||||
// the same type. The `#[deprecated]` attribute lands together with
|
||||
// the migration commits that retire the alias from internal uses.
|
||||
pub type FileSectorReader = FileSectorSource;
|
||||
|
||||
+17
-17
@@ -19,7 +19,7 @@
|
||||
//! BD-ROM Part 3 — Blu-ray filesystem profile
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// A UDF filesystem parsed from disc.
|
||||
#[derive(Debug)]
|
||||
@@ -84,7 +84,7 @@ impl UdfFs {
|
||||
/// Reads sector by sector from disc — no buffering.
|
||||
/// Get the absolute starting LBA of a file on disc.
|
||||
/// Used by the rip pipeline to locate m2ts content sectors.
|
||||
pub fn file_start_lba(&self, reader: &mut dyn SectorReader, path: &str) -> Result<u32> {
|
||||
pub fn file_start_lba(&self, reader: &mut dyn SectorSource, path: &str) -> Result<u32> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
@@ -115,7 +115,7 @@ impl UdfFs {
|
||||
Ok(self.partition_start + data_lba)
|
||||
}
|
||||
|
||||
pub fn read_file(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<u8>> {
|
||||
pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
|
||||
@@ -175,7 +175,7 @@ impl UdfFs {
|
||||
///
|
||||
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
|
||||
/// MKB_RO.inf, ContentHash*, ContentRevocation*
|
||||
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
|
||||
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// UDF structure: sector 0 through end of metadata partition
|
||||
@@ -194,7 +194,7 @@ impl UdfFs {
|
||||
|
||||
/// All sector ranges that contain data (metadata + all files including STREAM).
|
||||
/// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps.
|
||||
pub fn all_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
|
||||
pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// UDF structure sectors
|
||||
@@ -212,7 +212,7 @@ impl UdfFs {
|
||||
|
||||
fn collect_all_file_ranges(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
entry: &DirEntry,
|
||||
ranges: &mut Vec<(u32, u32)>,
|
||||
) -> Result<()> {
|
||||
@@ -238,7 +238,7 @@ impl UdfFs {
|
||||
|
||||
fn collect_file_ranges(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
entry: &DirEntry,
|
||||
ranges: &mut Vec<(u32, u32)>,
|
||||
) -> Result<()> {
|
||||
@@ -276,7 +276,7 @@ impl UdfFs {
|
||||
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
|
||||
/// and return its first allocation extent: (data_lba, data_length).
|
||||
/// The data_lba is partition-relative.
|
||||
fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
fn read_icb_extent(&self, reader: &mut dyn SectorSource, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
let extents = self.read_icb_extents(reader, meta_lba)?;
|
||||
extents.first().copied().ok_or(Error::DiscRead {
|
||||
sector: 0,
|
||||
@@ -290,7 +290,7 @@ impl UdfFs {
|
||||
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents).
|
||||
fn read_icb_extents(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
meta_lba: u32,
|
||||
) -> Result<Vec<(u32, u32)>> {
|
||||
let mut icb = [0u8; 2048];
|
||||
@@ -367,7 +367,7 @@ impl UdfFs {
|
||||
/// Returns Vec of (absolute_lba, sector_count) covering the entire file.
|
||||
pub fn file_extents(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
path: &str,
|
||||
) -> Result<Vec<(u32, u32)>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
@@ -417,7 +417,7 @@ impl UdfFs {
|
||||
/// 3. Metadata partition file → metadata content location
|
||||
/// 4. FSD → root directory ICB
|
||||
/// 5. Root directory → file tree
|
||||
pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
|
||||
// Step 1: Anchor Volume Descriptor Pointer at sector 256
|
||||
// ECMA-167 §10.2 — always at sector 256
|
||||
let mut avdp = [0u8; 2048];
|
||||
@@ -593,7 +593,7 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
/// and points to its ICB.
|
||||
#[allow(clippy::only_used_in_recursion)]
|
||||
fn read_directory(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
part_start: u32,
|
||||
meta_start: u32,
|
||||
meta_lba: u32,
|
||||
@@ -758,7 +758,7 @@ fn read_directory(
|
||||
}
|
||||
|
||||
/// Read file size (info_length) from an Extended File Entry ICB.
|
||||
fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result<u64> {
|
||||
fn read_file_size(reader: &mut dyn SectorSource, meta_start: u32, meta_lba: u32) -> Result<u64> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(reader, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
@@ -874,7 +874,7 @@ fn parse_dstring(data: &[u8]) -> String {
|
||||
/// Each SCSI command has ~500ms overhead on USB drives, so reading 32 sectors
|
||||
/// at once (one command) is 32x faster than 32 individual reads.
|
||||
pub(crate) struct BufferedSectorReader<'a> {
|
||||
inner: &'a mut dyn SectorReader,
|
||||
inner: &'a mut dyn SectorSource,
|
||||
cache_start: u32,
|
||||
cache: Vec<u8>,
|
||||
cache_sectors: u32,
|
||||
@@ -884,7 +884,7 @@ pub(crate) struct BufferedSectorReader<'a> {
|
||||
}
|
||||
|
||||
impl<'a> BufferedSectorReader<'a> {
|
||||
pub(crate) fn new(inner: &'a mut dyn SectorReader, batch: u16) -> Self {
|
||||
pub(crate) fn new(inner: &'a mut dyn SectorSource, batch: u16) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
cache_start: u32::MAX,
|
||||
@@ -952,7 +952,7 @@ impl BufferedSectorReader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for BufferedSectorReader<'_> {
|
||||
impl SectorSource for BufferedSectorReader<'_> {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
@@ -996,7 +996,7 @@ impl SectorReader for BufferedSectorReader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
fn read_sector(reader: &mut dyn SectorSource, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
reader.read_sectors(lba, 1, buf, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::disc::{Chapter, DiscTitle};
|
||||
use crate::progress::Progress;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Health status of a single sector read.
|
||||
@@ -82,7 +82,7 @@ impl VerifyResult {
|
||||
/// Reads in batches for speed, falls back to single-sector on failure.
|
||||
/// The progress callback returns false to request early stop.
|
||||
pub fn verify_title(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
title: &DiscTitle,
|
||||
batch_sectors: u16,
|
||||
on_progress: Option<&dyn Progress>,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
//! Disc scanning pipeline tests.
|
||||
|
||||
use libfreemkv::SectorReader;
|
||||
use libfreemkv::SectorSource;
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::{Disc, DiscTitle, ScanOptions};
|
||||
use std::collections::HashMap;
|
||||
@@ -20,7 +20,7 @@ impl MockSectorReader {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for MockSectorReader {
|
||||
impl SectorSource for MockSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
//! Integration tests for progress reporting, halt behavior, drop safety,
|
||||
//! and the file-backed sector reader round trip.
|
||||
|
||||
// 0.18 trait split: this suite still drives the deprecated `pes::Stream`
|
||||
// trait directly. It will be migrated to `FrameSource`/`FrameSink` in the
|
||||
// follow-up that ports concrete impls.
|
||||
#![allow(deprecated)]
|
||||
|
||||
use libfreemkv::disc::{CopyOptions, DiscRegion};
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::pes::Stream as PesStream;
|
||||
use libfreemkv::{
|
||||
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorReader,
|
||||
SectorReader,
|
||||
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorSource,
|
||||
SectorSource,
|
||||
};
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
@@ -37,7 +32,7 @@ impl ZeroSectorReader {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for ZeroSectorReader {
|
||||
impl SectorSource for ZeroSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
@@ -51,7 +46,7 @@ impl SectorReader for ZeroSectorReader {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
@@ -72,7 +67,7 @@ impl SlowZeroSectorReader {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for SlowZeroSectorReader {
|
||||
impl SectorSource for SlowZeroSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
@@ -86,7 +81,7 @@ impl SectorReader for SlowZeroSectorReader {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
@@ -106,6 +101,7 @@ fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
@@ -308,7 +304,7 @@ fn test_drop_impls_do_not_panic_or_block() {
|
||||
panic!("DiscStream drop did not complete within 100ms");
|
||||
}
|
||||
|
||||
// ── 5. FileSectorReader round trip ────────────────────────────────────────
|
||||
// ── 5. FileSectorSource round trip ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_file_sector_reader_round_trip() {
|
||||
@@ -325,9 +321,13 @@ fn test_file_sector_reader_round_trip() {
|
||||
tmp.flush().expect("flush");
|
||||
|
||||
let path = tmp.path().to_path_buf();
|
||||
let mut fsr = FileSectorReader::open(&path).expect("open FileSectorReader");
|
||||
let mut fsr = FileSectorSource::open(&path).expect("open FileSectorSource");
|
||||
|
||||
assert_eq!(fsr.capacity(), N_SECTORS as u32, "capacity mismatch");
|
||||
assert_eq!(
|
||||
fsr.capacity_sectors(),
|
||||
N_SECTORS as u32,
|
||||
"capacity mismatch"
|
||||
);
|
||||
|
||||
// Read each sector individually and compare.
|
||||
let mut buf = vec![0u8; SECTOR_SIZE];
|
||||
@@ -395,7 +395,7 @@ impl FailingSectorReader {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for FailingSectorReader {
|
||||
impl SectorSource for FailingSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
@@ -422,7 +422,7 @@ impl SectorReader for FailingSectorReader {
|
||||
})
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
@@ -459,11 +459,17 @@ fn test_disc_copy_completes_full_disc_with_failing_reader() {
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
// Hard bound — even at 0 ms per read, 1024 sectors with skip-forward
|
||||
// should complete in well under a second on any host.
|
||||
// Hard bound — Pass 1 must NOT infinite-loop on a fully-failing
|
||||
// reader. The threshold accommodates the 2026-05-10 wedge-
|
||||
// avoidance pause (PASS_1_FAIL_PAUSE_SECS = 5 s on each failed
|
||||
// batch). With batch=32 and 1024 sectors that's up to ~5 batch
|
||||
// failures + a few damage-jump pauses before fast-trigger jumps
|
||||
// us past end-of-disc — well-bounded total, ~20-30 s typical.
|
||||
// The point of this test is "finishes cleanly, not infinitely",
|
||||
// not "completes in milliseconds."
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 5 s"
|
||||
elapsed < Duration::from_secs(60),
|
||||
"Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 60 s (not infinite)"
|
||||
);
|
||||
|
||||
// Per RIP_DESIGN.md §2.1: Pass 1 must reach end of disc regardless of
|
||||
@@ -565,7 +571,7 @@ struct BlockSizeFailingReader {
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl SectorReader for BlockSizeFailingReader {
|
||||
impl SectorSource for BlockSizeFailingReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
@@ -591,7 +597,7 @@ impl SectorReader for BlockSizeFailingReader {
|
||||
}
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
@@ -649,12 +655,16 @@ fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
|
||||
|
||||
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
|
||||
//
|
||||
// The video-damage-time display needs bytes_unreadable_total (confirmed dead)
|
||||
// separate from bytes_pending_total (might still recover). This test verifies
|
||||
// that a Pass 2 with some confirmed failures produces correct field values.
|
||||
// 2026-05-11 design call: Pass N never marks bytes as `Unreadable` mid-multipass —
|
||||
// failed reads stay `NonTrimmed` so the next pass can retry them. The orchestrator
|
||||
// (autorip) promotes still-NonTrimmed bytes to Unreadable after the FINAL retry
|
||||
// pass completes. This test was rewritten from its pre-design-call shape (which
|
||||
// asserted Pass 2 produced bytes_unreadable > 0) to verify the new invariant:
|
||||
// pass-level retries keep failed bytes in `bytes_pending` so subsequent passes
|
||||
// get more shots at them.
|
||||
|
||||
#[test]
|
||||
fn test_pass_progress_separates_unreadable_from_pending() {
|
||||
fn test_pass2_leaves_failed_reads_as_pending_not_unreadable() {
|
||||
let capacity_sectors: u32 = 128;
|
||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
|
||||
@@ -728,24 +738,30 @@ fn test_pass_progress_separates_unreadable_from_pending() {
|
||||
pass2.bytes_good, 0,
|
||||
"pass2: still no good sectors (reader always fails)"
|
||||
);
|
||||
assert!(
|
||||
pass2.bytes_unreadable > 0,
|
||||
"pass2: some sectors confirmed unreadable"
|
||||
// 2026-05-11 design: pass-level retries do NOT promote failed bytes
|
||||
// to Unreadable. Failed bytes stay NonTrimmed (pending) so a later
|
||||
// pass can retry. End-of-recovery promotion is an orchestrator
|
||||
// concern (autorip), not the patch loop's.
|
||||
assert_eq!(
|
||||
pass2.bytes_unreadable, 0,
|
||||
"pass2: Disc::patch never marks Unreadable mid-multipass — orchestrator promotes after final pass"
|
||||
);
|
||||
assert!(
|
||||
pass2.bytes_pending < pass1.bytes_pending,
|
||||
"pass2: fewer pending sectors than pass1"
|
||||
// bytes_pending stays at total_bytes because everything still
|
||||
// failed and nothing got recovered or promoted out of pending.
|
||||
assert_eq!(
|
||||
pass2.bytes_pending, total_bytes,
|
||||
"pass2: failed bytes remain NonTrimmed for the next pass to retry"
|
||||
);
|
||||
|
||||
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
|
||||
let observed_pending = last_pending.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
observed_unreadable > 0,
|
||||
"progress should report confirmed unreadable bytes"
|
||||
assert_eq!(
|
||||
observed_unreadable, 0,
|
||||
"progress should report zero confirmed-unreadable mid-pass under the new design"
|
||||
);
|
||||
assert!(
|
||||
observed_pending == 0 || observed_pending < total_bytes,
|
||||
"pending should shrink as sectors are confirmed unreadable"
|
||||
observed_pending > 0,
|
||||
"progress should report pending bytes as the reader keeps failing"
|
||||
);
|
||||
|
||||
// Video damage time: unreadable / total * duration
|
||||
|
||||
@@ -15,7 +15,7 @@ use libfreemkv::disc::CopyOptions;
|
||||
use libfreemkv::disc::DiscRegion;
|
||||
use libfreemkv::disc::mapfile::{Mapfile, SectorStatus};
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorReader};
|
||||
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorSource};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -30,8 +30,10 @@ struct PatternedSectorReader {
|
||||
trace: Arc<Mutex<Vec<(u32, u16)>>>,
|
||||
}
|
||||
|
||||
type ReadTrace = Arc<Mutex<Vec<(u32, u16)>>>;
|
||||
|
||||
impl PatternedSectorReader {
|
||||
fn new(capacity: u32, bad_lbas: HashSet<u32>) -> (Self, Arc<Mutex<Vec<(u32, u16)>>>) {
|
||||
fn new(capacity: u32, bad_lbas: HashSet<u32>) -> (Self, ReadTrace) {
|
||||
let trace = Arc::new(Mutex::new(Vec::new()));
|
||||
(
|
||||
Self {
|
||||
@@ -44,7 +46,7 @@ impl PatternedSectorReader {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for PatternedSectorReader {
|
||||
impl SectorSource for PatternedSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
@@ -68,13 +70,19 @@ impl SectorReader for PatternedSectorReader {
|
||||
});
|
||||
}
|
||||
}
|
||||
for chunk in buf.chunks_mut(SECTOR_SIZE) {
|
||||
chunk.fill((lba & 0xff) as u8);
|
||||
// Fill each sector with ITS OWN LBA byte, not the starting LBA's
|
||||
// byte. This matches real drive behavior: a multi-sector READ
|
||||
// returns per-sector-correct data. Pre-0.18.13 only single-sector
|
||||
// reads were exercised by patch tests, so the cheaper "fill the
|
||||
// whole batch with one byte" worked; adaptive batching needs the
|
||||
// per-sector pattern to verify correct positioning.
|
||||
for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() {
|
||||
chunk.fill(((lba + i as u32) & 0xff) as u8);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
@@ -92,6 +100,7 @@ fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
//! Integration tests for the PES stream pipeline.
|
||||
|
||||
// 0.18 trait split: this suite still drives the deprecated `pes::Stream`
|
||||
// trait directly. It will be migrated to `FrameSource`/`FrameSink` in the
|
||||
// follow-up that ports concrete impls.
|
||||
#![allow(deprecated)]
|
||||
|
||||
use libfreemkv::mux::meta::M2tsMeta;
|
||||
use libfreemkv::pes::Stream as PesStream;
|
||||
use libfreemkv::*;
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
//! UDF parser tests using a MockSectorReader.
|
||||
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::{SectorReader, read_filesystem};
|
||||
use libfreemkv::{SectorSource, read_filesystem};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
@@ -38,7 +38,7 @@ impl MockSectorReader {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for MockSectorReader {
|
||||
impl SectorSource for MockSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
@@ -463,11 +463,11 @@ fn find_dir_case_insensitive() {
|
||||
|
||||
#[test]
|
||||
fn sector_reader_is_object_safe() {
|
||||
// Verify SectorReader can be used as a trait object
|
||||
// Verify SectorSource can be used as a trait object
|
||||
let mut reader = MockSectorReader::new();
|
||||
reader.set_sector(0, vec![42u8; SECTOR_SIZE]);
|
||||
|
||||
let dyn_reader: &mut dyn SectorReader = &mut reader;
|
||||
let dyn_reader: &mut dyn SectorSource = &mut reader;
|
||||
let mut buf = vec![0u8; SECTOR_SIZE];
|
||||
let n = dyn_reader.read_sectors(0, 1, &mut buf, true).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE);
|
||||
|
||||
Reference in New Issue
Block a user