Unified Stream trait: read() and write() on one type

Stream trait: read() returns PesFrame, write() accepts PesFrame.
A stream is a stream — you read from it or write to it.
No separate Input/Output traits.

API: libfreemkv::input(url) and libfreemkv::output(url, title, codecs)
Returns Box<dyn Stream>.
This commit is contained in:
MattJackson
2026-04-15 03:33:29 +00:00
parent 323d04b7b7
commit 547babf39a
33 changed files with 689 additions and 400 deletions
+10
View File
@@ -6,6 +6,16 @@ on:
pull_request: pull_request:
jobs: jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
with:
components: clippy, rustfmt
- run: cargo fmt --check
- run: cargo clippy -- -D warnings
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+4
View File
@@ -31,3 +31,7 @@ libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2" libc = "0.2"
[[bench]]
name = "sgio_read"
harness = false
+71
View File
@@ -0,0 +1,71 @@
// Mimics ISO dump exactly — read + write + progress
use libfreemkv::Drive;
use std::io::Write;
use std::path::Path;
use std::time::Instant;
fn main() {
let device = std::env::args()
.skip(1)
.find(|a| !a.starts_with('-'))
.unwrap_or_else(|| {
let drives = libfreemkv::find_drives();
if drives.is_empty() {
eprintln!("No drives found");
std::process::exit(1);
}
drives[0].device_path().to_string()
});
let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| {
eprintln!("Cannot open {}: {}", device, e);
std::process::exit(1);
});
eprintln!("wait_ready...");
let _ = drive.wait_ready();
eprintln!("read_capacity...");
let cap = drive.read_capacity().unwrap();
eprintln!("capacity: {} sectors", cap);
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
let mut buf = vec![0u8; batch as usize * 2048];
// Open /dev/null writer like ISO dump does
let file = std::fs::File::create("/dev/null").unwrap();
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
eprintln!("Reading 1000 batches ({:.1} MB) with write + progress...",
1000.0 * batch as f64 * 2048.0 / 1_048_576.0);
let start = Instant::now();
let mut ok = 0u32;
let mut fail = 0u32;
let mut bytes: u64 = 0;
for i in 0..1000u32 {
let lba = i * batch as u32;
match drive.read(lba, batch, &mut buf) {
Ok(_) => {
writer.write_all(&buf).unwrap();
ok += 1;
}
Err(e) => {
fail += 1;
if fail <= 5 { eprintln!(" FAIL LBA {}: {}", lba, e); }
buf.fill(0);
writer.write_all(&buf).unwrap();
}
}
bytes += buf.len() as u64;
if i % 50 == 0 && i > 0 {
let elapsed = start.elapsed().as_secs_f64();
let mb = bytes as f64 / 1_048_576.0;
eprint!("\r {:.1} MB | {:.1} MB/s ", mb, mb / elapsed);
}
}
let elapsed = start.elapsed().as_secs_f64();
let mb = ok as f64 * batch as f64 * 2048.0 / 1_048_576.0;
eprintln!("\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s", ok, fail, mb, elapsed, mb / elapsed);
}
+70
View File
@@ -0,0 +1,70 @@
// Minimal ISO dumper — find exact stall point
use libfreemkv::Drive;
use std::io::{BufWriter, Write};
use std::path::Path;
use std::time::Instant;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("Usage: iso_dump <device> <output>");
std::process::exit(1);
}
let mut drive = Drive::open(Path::new(&args[1])).unwrap();
drive.wait_ready().unwrap();
let _ = drive.init();
let _ = drive.probe_disc();
// AACS handshake — required to read past the protected area
eprint!("Scanning disc... ");
let _ = libfreemkv::Disc::scan(&mut drive, &libfreemkv::ScanOptions::default());
eprintln!("OK");
let cap = drive.read_capacity().unwrap();
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
eprintln!("Device: {} | {} sectors | batch {}", args[1], cap, batch);
let file = std::fs::File::create(&args[2]).unwrap();
let mut w = BufWriter::with_capacity(4 * 1024 * 1024, file);
let mut buf = vec![0u8; batch as usize * 2048];
let mut lba: u32 = 0;
let start = Instant::now();
let mut last = Instant::now();
let mut bytes: u64 = 0;
let mut last_bytes: u64 = 0;
while lba < cap {
let count = ((cap - lba) as u16).min(batch);
let n = count as usize * 2048;
// Tiny yield between reads — test if pacing prevents firmware throttle
std::thread::yield_now();
let t0 = Instant::now();
let ok = drive.read(lba, count, &mut buf[..n]).is_ok();
let read_ms = t0.elapsed().as_millis();
// Flag slow reads
if read_ms > 2000 {
eprintln!("\n SLOW READ: LBA {} took {}ms (ok={})", lba, read_ms, ok);
}
if !ok { buf[..n].fill(0); }
w.write_all(&buf[..n]).unwrap();
lba += count as u32;
bytes += n as u64;
if last.elapsed().as_millis() >= 1000 {
let delta = bytes - last_bytes;
let speed = delta as f64 / last.elapsed().as_secs_f64() / 1_048_576.0;
let avg = bytes as f64 / start.elapsed().as_secs_f64() / 1_048_576.0;
let pct = bytes as f64 / (cap as f64 * 2048.0) * 100.0;
eprint!("\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ", pct, lba, speed, avg, bytes as f64 / 1e9);
last_bytes = bytes;
last = Instant::now();
}
}
w.flush().unwrap();
eprintln!("\nDone: {:.1} GB in {:.0}s", bytes as f64 / 1e9, start.elapsed().as_secs_f64());
}
+1 -4
View File
@@ -1050,10 +1050,7 @@ pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 1
} }
/// Read data keys after successful authentication (for AACS 2.0 bus encryption). /// Read data keys after successful authentication (for AACS 2.0 bus encryption).
pub fn read_data_keys( pub fn read_data_keys(session: &mut Drive, auth: &mut AacsAuth) -> Result<([u8; 16], [u8; 16])> {
session: &mut Drive,
auth: &mut AacsAuth,
) -> Result<([u8; 16], [u8; 16])> {
// REPORT DISC STRUCTURE format 0x84 // REPORT DISC STRUCTURE format 0x84
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36); let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsDataKey)?; let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsDataKey)?;
+7 -5
View File
@@ -188,9 +188,13 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
// Try each processing key against each UV/cvalue pair // Try each processing key against each UV/cvalue pair
for pk in processing_keys { for pk in processing_keys {
for i in 0..num_uvs { for i in 0..num_uvs {
if (i + 1) * 16 > cvalues.len() { continue; } if (i + 1) * 16 > cvalues.len() {
continue;
}
let record_start = i * 5; let record_start = i * 5;
if record_start + 5 > uvs.len() { continue; } if record_start + 5 > uvs.len() {
continue;
}
let _u_mask_shift = uvs[record_start]; let _u_mask_shift = uvs[record_start];
let uv = &uvs[record_start + 1..record_start + 5]; let uv = &uvs[record_start + 1..record_start + 5];
let cv = &cvalues[i * 16..(i + 1) * 16]; let cv = &cvalues[i * 16..(i + 1) * 16];
@@ -448,9 +452,7 @@ const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). /// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs. /// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive( pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result<Vec<u8>> {
session: &mut crate::drive::Drive,
) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
let cdb = [ let cdb = [
+4 -4
View File
@@ -104,8 +104,8 @@ impl Disc {
1 | 6 | 7 => Some(Stream::Video(VideoStream { 1 | 6 | 7 => Some(Stream::Video(VideoStream {
pid: s.pid, pid: s.pid,
codec, codec,
resolution: format_resolution(s.video_format, s.video_rate), resolution: Resolution::from_video_format(s.video_format),
frame_rate: format_framerate(s.video_rate), frame_rate: FrameRate::from_video_rate(s.video_rate),
hdr: match s.dynamic_range { hdr: match s.dynamic_range {
1 => HdrFormat::Hdr10, 1 => HdrFormat::Hdr10,
2 => HdrFormat::DolbyVision, 2 => HdrFormat::DolbyVision,
@@ -137,9 +137,9 @@ impl Disc {
Some(Stream::Audio(AudioStream { Some(Stream::Audio(AudioStream {
pid: s.pid, pid: s.pid,
codec, codec,
channels: format_channels(s.audio_format), channels: AudioChannels::from_audio_format(s.audio_format),
language: s.language.clone(), language: s.language.clone(),
sample_rate: format_samplerate(s.audio_rate), sample_rate: SampleRate::from_audio_rate(s.audio_rate),
secondary: s.stream_type == 5, secondary: s.stream_type == 5,
label: String::new(), label: String::new(),
})) }))
+7 -32
View File
@@ -20,20 +20,13 @@ impl Disc {
let mut title_number: u16 = 0; let mut title_number: u16 = 0;
for ts in &dvd_info.title_sets { for ts in &dvd_info.title_sets {
// Map DvdVideoAttr to Stream::Video
let video_codec = match ts.video.codec.as_str() {
"mpeg2" => Codec::Mpeg2,
"mpeg1" => Codec::Mpeg2, // treat MPEG-1 as MPEG-2 for container purposes
_ => Codec::Mpeg2,
};
let video_stream = Stream::Video(VideoStream { let video_stream = Stream::Video(VideoStream {
pid: 0xE0, // DVD video PID (standard MPEG PS video stream) pid: 0xE0, // DVD video PID (standard MPEG PS video stream)
codec: video_codec, codec: ts.video.codec,
resolution: ts.video.resolution.clone(), resolution: ts.video.resolution,
frame_rate: match ts.video.standard.as_str() { frame_rate: match ts.video.standard.as_str() {
"PAL" => "25".to_string(), "PAL" => FrameRate::F25,
_ => "29.97".to_string(), _ => FrameRate::F29_97,
}, },
hdr: HdrFormat::Sdr, hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709, color_space: ColorSpace::Bt709,
@@ -47,31 +40,13 @@ impl Disc {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, a)| { .map(|(i, a)| {
let codec = match a.codec.as_str() { let codec = a.codec;
"ac3" => Codec::Ac3,
"dts" => Codec::Dts,
"lpcm" => Codec::Lpcm,
"mpeg1" | "mpeg2" => Codec::Mpeg2,
_ => Codec::Unknown(0),
};
let channels = match a.channels {
1 => "mono".to_string(),
2 => "stereo".to_string(),
6 => "5.1".to_string(),
8 => "7.1".to_string(),
n => format!("{n}ch"),
};
let sample_rate = match a.sample_rate {
48000 => "48kHz".to_string(),
96000 => "96kHz".to_string(),
sr => format!("{}kHz", sr / 1000),
};
Stream::Audio(AudioStream { Stream::Audio(AudioStream {
pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs
codec, codec,
channels, channels: AudioChannels::from_count(a.channels),
language: a.language.clone(), language: a.language.clone(),
sample_rate, sample_rate: SampleRate::from_hz(a.sample_rate),
secondary: false, secondary: false,
label: String::new(), label: String::new(),
}) })
+22 -10
View File
@@ -87,18 +87,30 @@ pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
/// Mask a string for privacy (letters->A, digits->0). /// Mask a string for privacy (letters->A, digits->0).
pub fn mask_string(s: &str) -> String { pub fn mask_string(s: &str) -> String {
s.chars().map(|c| { s.chars()
if c.is_ascii_alphabetic() { 'A' } .map(|c| {
else if c.is_ascii_digit() { '0' } if c.is_ascii_alphabetic() {
else { c } 'A'
}).collect() } else if c.is_ascii_digit() {
'0'
} else {
c
}
})
.collect()
} }
/// Mask bytes for privacy. /// Mask bytes for privacy.
pub fn mask_bytes(data: &[u8]) -> Vec<u8> { pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
data.iter().map(|&b| { data.iter()
if b.is_ascii_alphabetic() { b'A' } .map(|&b| {
else if b.is_ascii_digit() { b'0' } if b.is_ascii_alphabetic() {
else { b } b'A'
}).collect() } else if b.is_ascii_digit() {
b'0'
} else {
b
}
})
.collect()
} }
+8 -3
View File
@@ -10,6 +10,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
if !std::path::Path::new(&path).exists() { if !std::path::Path::new(&path).exists() {
continue; continue;
} }
// Skip stale device nodes — sysfs entry must exist
let sysfs = format!("/sys/class/scsi_generic/sg{i}/device/model");
if !std::path::Path::new(&sysfs).exists() {
continue;
}
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) { if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
if let Ok(id) = DriveId::from_drive(transport.as_mut()) { if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 { if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
@@ -21,6 +26,7 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
drives drives
} }
#[allow(dead_code)]
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> { pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
if path.contains("/sg") { if path.contains("/sg") {
if !std::path::Path::new(path).exists() { if !std::path::Path::new(path).exists() {
@@ -39,9 +45,8 @@ pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
&& sg_id.product_id == sr_id.product_id && sg_id.product_id == sr_id.product_id
&& sg_id.serial_number == sr_id.serial_number && sg_id.serial_number == sr_id.serial_number
{ {
let warning = format!( let warning =
"{path} is a block device (sr) — using {sg_path} (sg) for raw access" format!("{path} is a block device (sr) — using {sg_path} (sg) for raw access");
);
return Ok((sg_path, Some(warning))); return Ok((sg_path, Some(warning)));
} }
} }
+3 -1
View File
@@ -7,7 +7,9 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new(); let mut drives = Vec::new();
for i in 0..16 { for i in 0..16 {
let path = format!("/dev/disk{}", i); let path = format!("/dev/disk{}", i);
if !std::path::Path::new(&path).exists() { continue; } if !std::path::Path::new(&path).exists() {
continue;
}
match crate::scsi::open(std::path::Path::new(&path)) { match crate::scsi::open(std::path::Path::new(&path)) {
Ok(mut transport) => { Ok(mut transport) => {
if let Ok(id) = DriveId::from_drive(transport.as_mut()) { if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
+19
View File
@@ -25,6 +25,25 @@ pub struct Event {
/// Types of events the lib can fire. /// Types of events the lib can fire.
#[derive(Debug)] #[derive(Debug)]
pub enum EventKind { pub enum EventKind {
// ── Init sequence events ────────────────────────────────────────
/// Drive opened successfully.
DriveOpened { device: String },
/// Drive is ready (disc spun up).
DriveReady,
/// Firmware init completed.
InitComplete { success: bool },
/// Disc probe completed.
ProbeComplete { success: bool },
/// Disc scan completed.
ScanComplete { titles: usize },
// ── Read events ─────────────────────────────────────────────────
/// Bytes successfully read and written to output. /// Bytes successfully read and written to output.
BytesRead { BytesRead {
/// Bytes written so far. /// Bytes written so far.
+25 -50
View File
@@ -7,6 +7,7 @@
//! The parser reads IFO files via UDF and extracts enough information //! The parser reads IFO files via UDF and extracts enough information
//! to build DiscTitle structs (parallel to MPLS for Blu-ray). //! to build DiscTitle structs (parallel to MPLS for Blu-ray).
use crate::disc::{Codec, Resolution};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::sector::SectorReader; use crate::sector::SectorReader;
use crate::udf::UdfFs; use crate::udf::UdfFs;
@@ -61,8 +62,8 @@ pub struct DvdCell {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct DvdVideoAttr { pub struct DvdVideoAttr {
pub codec: String, pub codec: Codec,
pub resolution: String, pub resolution: Resolution,
pub aspect: String, pub aspect: String,
pub standard: String, pub standard: String,
} }
@@ -70,7 +71,7 @@ pub struct DvdVideoAttr {
/// DVD audio stream attributes. /// DVD audio stream attributes.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DvdAudioAttr { pub struct DvdAudioAttr {
pub codec: String, pub codec: Codec,
pub channels: u8, pub channels: u8,
pub sample_rate: u32, pub sample_rate: u32,
pub language: String, pub language: String,
@@ -349,41 +350,15 @@ fn parse_video_attr(data: &[u8]) -> Result<DvdVideoAttr> {
_ => "4:3", _ => "4:3",
}; };
let resolution = match (b0 >> 4) & 0x03 { let resolution = if standard == "PAL" {
0 => { Resolution::R576i
if standard == "PAL" {
"720x576"
} else { } else {
"720x480" Resolution::R480i
}
}
1 => {
if standard == "PAL" {
"704x576"
} else {
"704x480"
}
}
2 => {
if standard == "PAL" {
"352x576"
} else {
"352x480"
}
}
3 => {
if standard == "PAL" {
"352x288"
} else {
"352x240"
}
}
_ => "720x480",
}; };
Ok(DvdVideoAttr { Ok(DvdVideoAttr {
codec: "mpeg2".to_string(), codec: Codec::Mpeg2,
resolution: resolution.to_string(), resolution,
aspect: aspect.to_string(), aspect: aspect.to_string(),
standard: standard.to_string(), standard: standard.to_string(),
}) })
@@ -396,12 +371,12 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
let coding_mode = (b0 >> 5) & 0x07; let coding_mode = (b0 >> 5) & 0x07;
let codec = match coding_mode { let codec = match coding_mode {
0 => "ac3", 0 => Codec::Ac3,
2 => "mpeg1", 2 => Codec::Mpeg1,
3 => "mpeg2", 3 => Codec::Mp2,
4 => "lpcm", 4 => Codec::Lpcm,
6 => "dts", 6 => Codec::Dts,
_ => "unknown", _ => Codec::Unknown(coding_mode),
}; };
let sample_rate_flag = (b0 >> 3) & 0x03; let sample_rate_flag = (b0 >> 3) & 0x03;
@@ -434,7 +409,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
}; };
Ok(DvdAudioAttr { Ok(DvdAudioAttr {
codec: codec.to_string(), codec,
channels, channels,
sample_rate, sample_rate,
language, language,
@@ -688,15 +663,15 @@ mod tests {
assert_eq!(title.cells.len(), 1); assert_eq!(title.cells.len(), 1);
let video = DvdVideoAttr { let video = DvdVideoAttr {
codec: "mpeg2".to_string(), codec: Codec::Mpeg2,
resolution: "720x480".to_string(), resolution: Resolution::R480i,
aspect: "16:9".to_string(), aspect: "16:9".to_string(),
standard: "NTSC".to_string(), standard: "NTSC".to_string(),
}; };
assert_eq!(video.codec, "mpeg2"); assert_eq!(video.codec, Codec::Mpeg2);
let audio = DvdAudioAttr { let audio = DvdAudioAttr {
codec: "ac3".to_string(), codec: Codec::Ac3,
channels: 6, channels: 6,
sample_rate: 48000, sample_rate: 48000,
language: "en".to_string(), language: "en".to_string(),
@@ -730,8 +705,8 @@ mod tests {
let attr = parse_video_attr(&data).unwrap(); let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, "NTSC"); assert_eq!(attr.standard, "NTSC");
assert_eq!(attr.aspect, "16:9"); assert_eq!(attr.aspect, "16:9");
assert_eq!(attr.resolution, "720x480"); assert_eq!(attr.resolution, Resolution::R480i);
assert_eq!(attr.codec, "mpeg2"); assert_eq!(attr.codec, Codec::Mpeg2);
} }
#[test] #[test]
@@ -743,7 +718,7 @@ mod tests {
let attr = parse_video_attr(&data).unwrap(); let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, "PAL"); assert_eq!(attr.standard, "PAL");
assert_eq!(attr.aspect, "4:3"); assert_eq!(attr.aspect, "4:3");
assert_eq!(attr.resolution, "720x576"); assert_eq!(attr.resolution, Resolution::R576i);
} }
#[test] #[test]
@@ -759,7 +734,7 @@ mod tests {
data[3] = b'n'; data[3] = b'n';
let attr = parse_audio_attr(&data, 0).unwrap(); let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.codec, "ac3"); assert_eq!(attr.codec, Codec::Ac3);
assert_eq!(attr.sample_rate, 48000); assert_eq!(attr.sample_rate, 48000);
assert_eq!(attr.channels, 6); assert_eq!(attr.channels, 6);
assert_eq!(attr.language, "en"); assert_eq!(attr.language, "en");
@@ -777,7 +752,7 @@ mod tests {
data[3] = b'r'; data[3] = b'r';
let attr = parse_audio_attr(&data, 0).unwrap(); let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.codec, "dts"); assert_eq!(attr.codec, Codec::Dts);
assert_eq!(attr.sample_rate, 96000); assert_eq!(attr.sample_rate, 96000);
assert_eq!(attr.channels, 2); assert_eq!(attr.channels, 2);
assert_eq!(attr.language, "fr"); assert_eq!(attr.language, "fr");
+1 -1
View File
@@ -110,7 +110,7 @@ pub use mux::MkvStream;
pub use mux::NetworkStream; pub use mux::NetworkStream;
pub use mux::NullStream; pub use mux::NullStream;
pub use mux::StdioStream; pub use mux::StdioStream;
pub use mux::{open_input, open_output, open_pes_input, open_pes_output, parse_url, InputOptions, StreamUrl}; pub use mux::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl};
pub use scsi::ScsiTransport; pub use scsi::ScsiTransport;
pub use sector::SectorReader; pub use sector::SectorReader;
pub use speed::DriveSpeed; pub use speed::DriveSpeed;
+2 -1
View File
@@ -172,7 +172,8 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> {
} }
fn find_next_sc(data: &[u8], from: usize) -> Option<usize> { fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
(from..data.len().saturating_sub(2)).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01) (from..data.len().saturating_sub(2))
.find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
} }
#[cfg(test)] #[cfg(test)]
+11 -2
View File
@@ -333,8 +333,8 @@ impl IOStream for DiscStream {
} }
} }
impl crate::pes::InputStream for DiscStream { impl crate::pes::Stream for DiscStream {
fn next_frame(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
// Return buffered frame if available // Return buffered frame if available
if let Some(frame) = self.pending_frames.pop_front() { if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame)); return Ok(Some(frame));
@@ -399,6 +399,15 @@ impl crate::pes::InputStream for DiscStream {
} }
} }
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "disc is read-only"))
}
fn finish(&mut self) -> io::Result<()> {
self.drive.unlock_tray();
Ok(())
}
fn info(&self) -> &DiscTitle { fn info(&self) -> &DiscTitle {
&self.title &self.title
} }
+8 -2
View File
@@ -256,8 +256,8 @@ impl IsoStream {
} }
} }
impl crate::pes::InputStream for IsoStream { impl crate::pes::Stream for IsoStream {
fn next_frame(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
// Return buffered frame // Return buffered frame
if let Some(frame) = self.pending_frames.pop_front() { if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame)); return Ok(Some(frame));
@@ -301,6 +301,12 @@ impl crate::pes::InputStream for IsoStream {
} }
} }
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "ISO is read-only for PES"))
}
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &crate::disc::DiscTitle { fn info(&self) -> &crate::disc::DiscTitle {
&self.disc_title &self.disc_title
} }
+1 -1
View File
@@ -412,7 +412,7 @@ impl<W: Write + Seek> IsoWriter<W> {
icb[ad_offset..ad_offset + 4].copy_from_slice(&(extent_len as u32).to_le_bytes()); icb[ad_offset..ad_offset + 4].copy_from_slice(&(extent_len as u32).to_le_bytes());
icb[ad_offset + 4..ad_offset + 8].copy_from_slice(&sector_pos.to_le_bytes()); icb[ad_offset + 4..ad_offset + 8].copy_from_slice(&sector_pos.to_le_bytes());
ad_offset += 8; // each short_ad is 8 bytes ad_offset += 8; // each short_ad is 8 bytes
let extent_sectors = ((extent_len + SECTOR_SIZE - 1) / SECTOR_SIZE) as u32; let extent_sectors = extent_len.div_ceil(SECTOR_SIZE) as u32;
sector_pos += extent_sectors; sector_pos += extent_sectors;
remaining -= extent_len; remaining -= extent_len;
} }
+19 -74
View File
@@ -3,9 +3,7 @@
//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...] //! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
//! Other tools skip the header during TS sync recovery (scan for 0x47). //! Other tools skip the header during TS sync recovery (scan for 0x47).
use crate::disc::{ use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream};
AudioStream, Codec, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{self, Read, Seek, SeekFrom, Write}; use std::io::{self, Read, Seek, SeekFrom, Write};
@@ -84,25 +82,25 @@ impl M2tsMeta {
.map(|s| match s { .map(|s| match s {
Stream::Video(v) => MetaStream::Video { Stream::Video(v) => MetaStream::Video {
pid: v.pid, pid: v.pid,
codec: codec_to_str(v.codec), codec: v.codec.id().into(),
resolution: v.resolution.clone(), resolution: v.resolution.to_string(),
frame_rate: v.frame_rate.clone(), frame_rate: v.frame_rate.to_string(),
hdr: hdr_to_str(v.hdr), hdr: v.hdr.id().into(),
label: v.label.clone(), label: v.label.clone(),
secondary: v.secondary, secondary: v.secondary,
}, },
Stream::Audio(a) => MetaStream::Audio { Stream::Audio(a) => MetaStream::Audio {
pid: a.pid, pid: a.pid,
codec: codec_to_str(a.codec), codec: a.codec.id().into(),
channels: a.channels.clone(), channels: a.channels.to_string(),
language: a.language.clone(), language: a.language.clone(),
sample_rate: a.sample_rate.clone(), sample_rate: a.sample_rate.to_string(),
label: a.label.clone(), label: a.label.clone(),
secondary: a.secondary, secondary: a.secondary,
}, },
Stream::Subtitle(s) => MetaStream::Subtitle { Stream::Subtitle(s) => MetaStream::Subtitle {
pid: s.pid, pid: s.pid,
codec: codec_to_str(s.codec), codec: s.codec.id().into(),
language: s.language.clone(), language: s.language.clone(),
forced: s.forced, forced: s.forced,
}, },
@@ -133,10 +131,10 @@ impl M2tsMeta {
secondary, secondary,
} => Stream::Video(VideoStream { } => Stream::Video(VideoStream {
pid: *pid, pid: *pid,
codec: str_to_codec(codec), codec: codec.parse().unwrap(),
resolution: resolution.clone(), resolution: resolution.parse().unwrap(),
frame_rate: frame_rate.clone(), frame_rate: frame_rate.parse().unwrap(),
hdr: str_to_hdr(hdr), hdr: hdr.parse().unwrap(),
color_space: ColorSpace::Bt709, color_space: ColorSpace::Bt709,
secondary: *secondary, secondary: *secondary,
label: label.clone(), label: label.clone(),
@@ -151,10 +149,10 @@ impl M2tsMeta {
secondary, secondary,
} => Stream::Audio(AudioStream { } => Stream::Audio(AudioStream {
pid: *pid, pid: *pid,
codec: str_to_codec(codec), codec: codec.parse().unwrap(),
channels: channels.clone(), channels: channels.parse().unwrap(),
language: language.clone(), language: language.clone(),
sample_rate: sample_rate.clone(), sample_rate: sample_rate.parse().unwrap(),
secondary: *secondary, secondary: *secondary,
label: label.clone(), label: label.clone(),
}), }),
@@ -165,7 +163,7 @@ impl M2tsMeta {
forced, forced,
} => Stream::Subtitle(SubtitleStream { } => Stream::Subtitle(SubtitleStream {
pid: *pid, pid: *pid,
codec: str_to_codec(codec), codec: codec.parse().unwrap(),
language: language.clone(), language: language.clone(),
forced: *forced, forced: *forced,
codec_data: None, codec_data: None,
@@ -277,58 +275,5 @@ pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>
Ok(Some(meta)) Ok(Some(meta))
} }
// Codec string conversion (compact, no English — just codec identifiers) // Serialization uses Codec::id() / HdrFormat::id() and Display impls.
fn codec_to_str(c: Codec) -> String { // Deserialization uses FromStr impls (.parse()) on each enum.
match c {
Codec::Hevc => "hevc",
Codec::H264 => "h264",
Codec::Vc1 => "vc1",
Codec::Mpeg2 => "mpeg2",
Codec::TrueHd => "truehd",
Codec::DtsHdMa => "dtshd_ma",
Codec::DtsHdHr => "dtshd_hr",
Codec::Dts => "dts",
Codec::Ac3 => "ac3",
Codec::Ac3Plus => "eac3",
Codec::Lpcm => "lpcm",
Codec::Pgs => "pgs",
Codec::DvdSub => "dvdsub",
Codec::Unknown(_) => "unknown",
}
.into()
}
fn str_to_codec(s: &str) -> Codec {
match s {
"hevc" => Codec::Hevc,
"h264" => Codec::H264,
"vc1" => Codec::Vc1,
"mpeg2" => Codec::Mpeg2,
"truehd" => Codec::TrueHd,
"dtshd_ma" => Codec::DtsHdMa,
"dtshd_hr" => Codec::DtsHdHr,
"dts" => Codec::Dts,
"ac3" => Codec::Ac3,
"eac3" => Codec::Ac3Plus,
"lpcm" => Codec::Lpcm,
"pgs" => Codec::Pgs,
_ => Codec::Unknown(0),
}
}
fn hdr_to_str(h: HdrFormat) -> String {
match h {
HdrFormat::Sdr => "sdr",
HdrFormat::Hdr10 => "hdr10",
HdrFormat::DolbyVision => "dv",
}
.into()
}
fn str_to_hdr(s: &str) -> HdrFormat {
match s {
"hdr10" => HdrFormat::Hdr10,
"dv" => HdrFormat::DolbyVision,
_ => HdrFormat::Sdr,
}
}
+5 -42
View File
@@ -35,7 +35,7 @@ impl MkvTrack {
Codec::Mpeg2 => "V_MPEG2", Codec::Mpeg2 => "V_MPEG2",
_ => "V_MPEG2", _ => "V_MPEG2",
}; };
let (w, h) = parse_resolution(&v.resolution); let (w, h) = v.resolution.pixels();
Self { Self {
track_type: ebml::TRACK_TYPE_VIDEO, track_type: ebml::TRACK_TYPE_VIDEO,
codec_id, codec_id,
@@ -61,8 +61,8 @@ impl MkvTrack {
Codec::Lpcm => "A_PCM/INT/BIG", Codec::Lpcm => "A_PCM/INT/BIG",
_ => "A_AC3", _ => "A_AC3",
}; };
let sr = parse_sample_rate(&a.sample_rate); let sr = a.sample_rate.hz();
let ch = parse_channels(&a.channels); let ch = a.channels.count();
Self { Self {
track_type: ebml::TRACK_TYPE_AUDIO, track_type: ebml::TRACK_TYPE_AUDIO,
codec_id, codec_id,
@@ -414,45 +414,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Helpers // Helpers
// ============================================================ // ============================================================
fn parse_resolution(s: &str) -> (u32, u32) { // Old parse_resolution/parse_sample_rate/parse_channels removed —
if s.contains("2160") { // Resolution::pixels(), SampleRate::hz(), AudioChannels::count() replace them.
(3840, 2160)
} else if s.contains("1080") {
(1920, 1080)
} else if s.contains("720") {
(1280, 720)
} else if s.contains("576") {
(720, 576)
} else if s.contains("480") {
(720, 480)
} else {
(1920, 1080)
}
}
fn parse_sample_rate(s: &str) -> f64 {
if s.contains("192") {
192_000.0
} else if s.contains("96") {
96000.0
} else {
48000.0
}
}
fn parse_channels(s: &str) -> u8 {
if s.contains("7.1") {
8
} else if s.contains("5.1") {
6
} else if s.contains("stereo") || s.contains("2.0") {
2
} else if s.contains("mono") {
1
} else {
6
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+11 -4
View File
@@ -6,11 +6,12 @@
use super::mkv::{MkvMuxer, MkvTrack}; use super::mkv::{MkvMuxer, MkvTrack};
use super::WriteSeek; use super::WriteSeek;
use crate::disc::DiscTitle; use crate::disc::DiscTitle;
use crate::pes::{OutputStream, PesFrame}; use crate::pes::PesFrame;
use std::io; use std::io;
pub struct MkvOutputStream { pub struct MkvOutputStream {
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>, muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
title: DiscTitle,
} }
impl MkvOutputStream { impl MkvOutputStream {
@@ -43,12 +44,16 @@ impl MkvOutputStream {
&title.chapters, &title.chapters,
)?; )?;
Ok(Self { muxer: Some(muxer) }) Ok(Self { muxer: Some(muxer), title: title.clone() })
} }
} }
impl OutputStream for MkvOutputStream { impl crate::pes::Stream for MkvOutputStream {
fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> { fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "MKV output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
if let Some(ref mut muxer) = self.muxer { if let Some(ref mut muxer) = self.muxer {
muxer.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data) muxer.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
} else { } else {
@@ -63,4 +68,6 @@ impl OutputStream for MkvOutputStream {
Ok(()) Ok(())
} }
} }
fn info(&self) -> &DiscTitle { &self.title }
} }
+1 -1
View File
@@ -45,7 +45,7 @@ pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream; pub use mkvstream::MkvStream;
pub use network::NetworkStream; pub use network::NetworkStream;
pub use null::NullStream; pub use null::NullStream;
pub use resolve::{open_input, open_output, open_pes_input, open_pes_output, parse_url, InputOptions, StreamUrl}; pub use resolve::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl};
pub use stdio::StdioStream; pub use stdio::StdioStream;
use crate::disc::DiscTitle; use crate::disc::DiscTitle;
+6 -5
View File
@@ -148,7 +148,8 @@ impl Read for NetworkStream {
mod tests { mod tests {
use super::*; use super::*;
use crate::disc::{ use crate::disc::{
AudioStream, Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream, AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat,
Resolution, SampleRate, Stream, VideoStream,
}; };
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
@@ -165,8 +166,8 @@ mod tests {
Stream::Video(VideoStream { Stream::Video(VideoStream {
pid: 0x1011, pid: 0x1011,
codec: Codec::Hevc, codec: Codec::Hevc,
resolution: "2160p".into(), resolution: Resolution::R2160p,
frame_rate: "23.976".into(), frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10, hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020, color_space: ColorSpace::Bt2020,
secondary: false, secondary: false,
@@ -175,9 +176,9 @@ mod tests {
Stream::Audio(AudioStream { Stream::Audio(AudioStream {
pid: 0x1100, pid: 0x1100,
codec: Codec::TrueHd, codec: Codec::TrueHd,
channels: "7.1".into(), channels: AudioChannels::Surround71,
language: "eng".into(), language: "eng".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: false, secondary: false,
label: "English".into(), label: "English".into(),
}), }),
+87 -76
View File
@@ -1,19 +1,15 @@
//! PES output adapters — every output format muxes from PES frames. //! PES output streams — each writes its own format from PES frames.
//!
//! Each output knows its own format:
//! - M2TS: PES → BD-TS packets → file (via TsMuxer)
//! - Null: discard
//! - Stdio: raw frame data to stdout
//! - Network: PES → BD-TS → TCP (via TsMuxer)
use super::tsmux::TsMuxer; use super::tsmux::TsMuxer;
use crate::disc::DiscTitle; use crate::disc::DiscTitle;
use crate::pes::{OutputStream, PesFrame}; use crate::pes::PesFrame;
use std::io::{self, Write}; use std::io::{self, Write};
/// M2TS output — PES frames → BD-TS packets → file. // ── M2TS ────────────────────────────────────────────────────────────────────
pub struct M2tsOutputStream { pub struct M2tsOutputStream {
muxer: TsMuxer<io::BufWriter<std::fs::File>>, muxer: TsMuxer<io::BufWriter<std::fs::File>>,
title: DiscTitle,
} }
impl M2tsOutputStream { impl M2tsOutputStream {
@@ -21,11 +17,89 @@ impl M2tsOutputStream {
let file = std::fs::File::create(path) let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path, e)))?; .map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path, e)))?;
let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file); let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
let pids = Self::extract_pids(title); let pids = extract_pids(title);
Ok(Self { Ok(Self { muxer: TsMuxer::new(writer, &pids), title: title.clone() })
muxer: TsMuxer::new(writer, &pids),
})
} }
}
impl crate::pes::Stream for M2tsOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "M2TS output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
self.muxer.write_frame(frame.track, frame.pts, &frame.data)
}
fn finish(&mut self) -> io::Result<()> { self.muxer.finish_ref() }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Null ────────────────────────────────────────────────────────────────────
pub struct NullOutputStream { title: DiscTitle }
impl NullOutputStream {
pub fn new(title: &DiscTitle) -> Self { Self { title: title.clone() } }
}
impl crate::pes::Stream for NullOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> { Ok(None) }
fn write(&mut self, _: &PesFrame) -> io::Result<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Stdio ───────────────────────────────────────────────────────────────────
pub struct StdioOutputStream {
writer: io::BufWriter<io::Stdout>,
title: DiscTitle,
}
impl StdioOutputStream {
pub fn new(title: &DiscTitle) -> Self {
Self { writer: io::BufWriter::new(io::stdout()), title: title.clone() }
}
}
impl crate::pes::Stream for StdioOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "stdio output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
self.writer.write_all(&frame.data)
}
fn finish(&mut self) -> io::Result<()> { self.writer.flush() }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Network ─────────────────────────────────────────────────────────────────
pub struct NetworkOutputStream {
muxer: TsMuxer<io::BufWriter<std::net::TcpStream>>,
title: DiscTitle,
}
impl NetworkOutputStream {
pub fn connect(addr: &str, title: &DiscTitle) -> io::Result<Self> {
let stream = std::net::TcpStream::connect(addr)?;
let writer = io::BufWriter::with_capacity(256 * 1024, stream);
let pids = extract_pids(title);
Ok(Self { muxer: TsMuxer::new(writer, &pids), title: title.clone() })
}
}
impl crate::pes::Stream for NetworkOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "network output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
self.muxer.write_frame(frame.track, frame.pts, &frame.data)
}
fn finish(&mut self) -> io::Result<()> { self.muxer.finish_ref() }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn extract_pids(title: &DiscTitle) -> Vec<u16> { fn extract_pids(title: &DiscTitle) -> Vec<u16> {
title.streams.iter().map(|s| match s { title.streams.iter().map(|s| match s {
@@ -34,66 +108,3 @@ impl M2tsOutputStream {
crate::disc::Stream::Subtitle(s) => s.pid, crate::disc::Stream::Subtitle(s) => s.pid,
}).collect() }).collect()
} }
}
impl OutputStream for M2tsOutputStream {
fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> {
self.muxer.write_frame(frame.track, frame.pts, &frame.data)
}
fn finish(&mut self) -> io::Result<()> {
self.muxer.finish_ref()
}
}
/// Null output — discards all frames.
pub struct NullOutputStream;
impl OutputStream for NullOutputStream {
fn write_frame(&mut self, _frame: &PesFrame) -> io::Result<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> { Ok(()) }
}
/// Stdio output — writes raw frame data to stdout.
pub struct StdioOutputStream {
writer: io::BufWriter<io::Stdout>,
}
impl StdioOutputStream {
pub fn new() -> Self {
Self { writer: io::BufWriter::new(io::stdout()) }
}
}
impl OutputStream for StdioOutputStream {
fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> {
self.writer.write_all(&frame.data)
}
fn finish(&mut self) -> io::Result<()> {
self.writer.flush()
}
}
/// Network output — PES frames → BD-TS → TCP.
pub struct NetworkOutputStream {
muxer: TsMuxer<io::BufWriter<std::net::TcpStream>>,
}
impl NetworkOutputStream {
pub fn connect(addr: &str, title: &DiscTitle) -> io::Result<Self> {
let stream = std::net::TcpStream::connect(addr)?;
let writer = io::BufWriter::with_capacity(256 * 1024, stream);
let pids = M2tsOutputStream::extract_pids(title);
Ok(Self {
muxer: TsMuxer::new(writer, &pids),
})
}
}
impl OutputStream for NetworkOutputStream {
fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> {
self.muxer.write_frame(frame.track, frame.pts, &frame.data)
}
fn finish(&mut self) -> io::Result<()> {
self.muxer.finish_ref()
}
}
-1
View File
@@ -82,7 +82,6 @@ impl PsDemuxer {
let mut pos = 0; let mut pos = 0;
while let Some(sc) = find_start_code(&self.buffer, pos) { while let Some(sc) = find_start_code(&self.buffer, pos) {
if sc + 3 >= self.buffer.len() { if sc + 3 >= self.buffer.len() {
// Not enough bytes to read the start code ID. // Not enough bytes to read the start code ID.
break; break;
+5 -5
View File
@@ -305,7 +305,7 @@ pub struct InputOptions {
// ── PES-based open ────────────────────────────────────────────────────────── // ── PES-based open ──────────────────────────────────────────────────────────
/// Open a PES input stream (produces PES frames). /// Open a PES input stream (produces PES frames).
pub fn open_pes_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::InputStream>> { pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url); let parsed = parse_url(url);
match parsed { match parsed {
StreamUrl::Iso { ref path } => { StreamUrl::Iso { ref path } => {
@@ -369,11 +369,11 @@ pub fn open_pes_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crat
} }
/// Open a PES output stream (consumes PES frames). /// Open a PES output stream (consumes PES frames).
pub fn open_pes_output( pub fn output(
url: &str, url: &str,
title: &crate::disc::DiscTitle, title: &crate::disc::DiscTitle,
codec_privates: &[Option<Vec<u8>>], codec_privates: &[Option<Vec<u8>>],
) -> io::Result<Box<dyn crate::pes::OutputStream>> { ) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url); let parsed = parse_url(url);
match parsed { match parsed {
StreamUrl::Mkv { ref path } => { StreamUrl::Mkv { ref path } => {
@@ -393,10 +393,10 @@ pub fn open_pes_output(
Ok(Box::new(super::pesout::NetworkOutputStream::connect(addr, title)?)) Ok(Box::new(super::pesout::NetworkOutputStream::connect(addr, title)?))
} }
StreamUrl::Stdio => { StreamUrl::Stdio => {
Ok(Box::new(super::pesout::StdioOutputStream::new())) Ok(Box::new(super::pesout::StdioOutputStream::new(title)))
} }
StreamUrl::Null => { StreamUrl::Null => {
Ok(Box::new(super::pesout::NullOutputStream)) Ok(Box::new(super::pesout::NullOutputStream::new(title)))
} }
StreamUrl::Disc { .. } => { StreamUrl::Disc { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only")) Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only"))
+20 -25
View File
@@ -1,11 +1,10 @@
//! PES framethe universal intermediate format. //! Stream — read PES frames in, write PES frames out.
//! //!
//! Every input stream produces PES frames. Every output stream consumes them. //! A stream is a stream. You read() from it or write() to it.
//! The pipeline just moves frames: input.next_frame() → output.write_frame(). //! The stream handles its own format internally.
//! //!
//! A PES frame is one unit of elementary stream data: a video frame, //! disc.read() → PES frame (sectors → decrypt → demux internally)
//! an audio frame, a subtitle packet. It carries a track ID, timestamp, //! mkv.write(frame) → MKV file (mux internally)
//! and the raw codec data.
/// One frame of elementary stream data. /// One frame of elementary stream data.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -32,27 +31,23 @@ impl PesFrame {
} }
} }
/// Input stream — produces PES frames from any source. /// A stream. Read from it or write to it. Not both.
pub trait InputStream { pub trait Stream {
/// Get the next frame. Returns None at end of stream. /// Read the next frame. Returns None at end of stream.
fn next_frame(&mut self) -> std::io::Result<Option<PesFrame>>; fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
/// Stream metadata (tracks, duration, etc). /// Write a frame.
fn info(&self) -> &crate::disc::DiscTitle; fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
/// Codec initialization data for a track (SPS/PPS for HEVC, etc).
/// Returns None until enough frames have been parsed.
fn codec_private(&self, track: usize) -> Option<Vec<u8>>;
/// True when codec_private is available for all video tracks.
fn headers_ready(&self) -> bool;
}
/// Output stream — consumes PES frames to any destination.
pub trait OutputStream {
/// Write one frame.
fn write_frame(&mut self, frame: &PesFrame) -> std::io::Result<()>;
/// Finalize (flush, write index, close). /// Finalize (flush, write index, close).
fn finish(&mut self) -> std::io::Result<()>; fn finish(&mut self) -> std::io::Result<()>;
/// Stream metadata.
fn info(&self) -> &crate::disc::DiscTitle;
/// Codec initialization data for a track (SPS/PPS, etc).
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> { None }
/// True when codec_private is available for all video tracks.
fn headers_ready(&self) -> bool { true }
} }
+10 -6
View File
@@ -178,7 +178,7 @@ impl Mt1959 {
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> { fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let mut unlocked = false; let mut unlocked = false;
for _attempt in 0..6 { for _attempt in 0..3 {
match self.do_unlock(scsi) { match self.do_unlock(scsi) {
Ok(_) => { Ok(_) => {
unlocked = true; unlocked = true;
@@ -188,15 +188,17 @@ impl Mt1959 {
return Err(Error::UnlockFailed); return Err(Error::UnlockFailed);
} }
Err(_) => { Err(_) => {
let ok = if self.mode == MODE_A { let loaded = if self.mode == MODE_A {
variant_a::load_firmware(self, scsi).is_ok() variant_a::load_firmware(self, scsi).is_ok()
} else { } else {
variant_b::load_firmware(self, scsi).is_ok() variant_b::load_firmware(self, scsi).is_ok()
}; };
if ok { if !loaded {
unlocked = true; continue;
break;
} }
// Firmware upload resets the drive. Give it time to
// fully recover before retrying unlock.
std::thread::sleep(std::time::Duration::from_secs(10));
} }
} }
} }
@@ -319,7 +321,9 @@ impl PlatformDriver for Mt1959 {
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> { fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if !self.unlocked { if !self.unlocked {
self.run_init(scsi)?; // Don't retry init here — if init() failed, probing can't work either.
// Retrying causes repeated USB bus resets on BU40N.
return Ok(());
} }
if self.probed { if self.probed {
return Ok(()); return Ok(());
+44
View File
@@ -187,6 +187,50 @@ impl UdfFs {
Ok(merged) Ok(merged)
} }
/// 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)>> {
let mut ranges = Vec::new();
// UDF structure sectors
let meta_end = self.metadata_start + self.metadata_sectors;
ranges.push((0, meta_end));
// Walk entire tree including STREAM directories
self.collect_all_file_ranges(reader, &self.root, &mut ranges)?;
// Merge overlapping/adjacent ranges and sort
ranges.sort_by_key(|r| r.0);
let merged = merge_ranges(&ranges);
Ok(merged)
}
fn collect_all_file_ranges(
&self,
reader: &mut dyn SectorReader,
entry: &DirEntry,
ranges: &mut Vec<(u32, u32)>,
) -> Result<()> {
for child in &entry.entries {
if child.is_dir {
self.collect_all_file_ranges(reader, child, ranges)?;
} else {
// Include the ICB sector
ranges.push((self.meta_to_abs(child.meta_lba), 1));
// Include ALL file data extents (large m2ts files have many)
if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
for (data_lba, data_len) in extents {
let abs_start = self.partition_start + data_lba;
let sector_count = (data_len as u64).div_ceil(2048) as u32;
ranges.push((abs_start, sector_count));
}
}
}
}
Ok(())
}
fn collect_file_ranges( fn collect_file_ranges(
&self, &self,
reader: &mut dyn SectorReader, reader: &mut dyn SectorReader,
+57 -20
View File
@@ -363,8 +363,7 @@ fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
/// The standard AACS IV, copied here independently so we are NOT importing /// The standard AACS IV, copied here independently so we are NOT importing
/// the library's constant — this IS the cross-validation reference value. /// the library's constant — this IS the cross-validation reference value.
const CROSS_AACS_IV: [u8; 16] = [ const CROSS_AACS_IV: [u8; 16] = [
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
]; ];
/// Build a plaintext aligned unit with TS sync markers and recognisable /// Build a plaintext aligned unit with TS sync markers and recognisable
@@ -373,8 +372,8 @@ const CROSS_AACS_IV: [u8; 16] = [
#[test] #[test]
fn aacs_cross_validation_encrypt_then_decrypt() { fn aacs_cross_validation_encrypt_then_decrypt() {
let unit_key: [u8; 16] = [ let unit_key: [u8; 16] = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10, 0x10,
]; ];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN]; let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
@@ -403,7 +402,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
for i in 0..16 { for i in 0..16 {
dk[i] = derived[i] ^ header[i]; dk[i] = derived[i] ^ header[i];
} }
ref_aes_cbc_encrypt(&dk, &CROSS_AACS_IV, &mut plaintext[16..aacs::ALIGNED_UNIT_LEN]); ref_aes_cbc_encrypt(
&dk,
&CROSS_AACS_IV,
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
);
// Sanity: ciphertext should differ // Sanity: ciphertext should differ
assert_ne!( assert_ne!(
@@ -414,7 +417,10 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
// -- Decrypt with the library -- // -- Decrypt with the library --
let ok = aacs::decrypt_unit(&mut plaintext, &unit_key); let ok = aacs::decrypt_unit(&mut plaintext, &unit_key);
assert!(ok, "decrypt_unit returned false (TS sync verification failed)"); assert!(
ok,
"decrypt_unit returned false (TS sync verification failed)"
);
assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared"); assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared");
// Compare (byte 0 flag was cleared) // Compare (byte 0 flag was cleared)
@@ -432,8 +438,8 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
#[test] #[test]
fn aacs_cross_validation_alternate_key() { fn aacs_cross_validation_alternate_key() {
let unit_key: [u8; 16] = [ let unit_key: [u8; 16] = [
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x08,
]; ];
let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN]; let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
@@ -452,7 +458,11 @@ fn aacs_cross_validation_alternate_key() {
for i in 0..16 { for i in 0..16 {
dk[i] = derived[i] ^ header[i]; dk[i] = derived[i] ^ header[i];
} }
ref_aes_cbc_encrypt(&dk, &CROSS_AACS_IV, &mut plaintext[16..aacs::ALIGNED_UNIT_LEN]); ref_aes_cbc_encrypt(
&dk,
&CROSS_AACS_IV,
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
);
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key)); assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
@@ -469,8 +479,8 @@ fn aacs_cross_validation_alternate_key() {
#[test] #[test]
fn aacs_bus_decrypt_cross_validation() { fn aacs_bus_decrypt_cross_validation() {
let read_data_key: [u8; 16] = [ let read_data_key: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x00,
]; ];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN]; let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
@@ -552,10 +562,22 @@ fn css_roundtrip_with_snapshot() {
#[test] #[test]
fn css_roundtrip_multiple_keys() { fn css_roundtrip_multiple_keys() {
let cases: &[([u8; 5], [u8; 5])] = &[ let cases: &[([u8; 5], [u8; 5])] = &[
([0x00, 0x00, 0x00, 0x00, 0x00], [0x00, 0x00, 0x00, 0x00, 0x00]), (
([0xFF, 0xFF, 0xFF, 0xFF, 0xFF], [0xFF, 0xFF, 0xFF, 0xFF, 0xFF]), [0x00, 0x00, 0x00, 0x00, 0x00],
([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]), [0x00, 0x00, 0x00, 0x00, 0x00],
([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]), ),
(
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
),
(
[0x01, 0x02, 0x03, 0x04, 0x05],
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
),
(
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
[0x12, 0x34, 0x56, 0x78, 0x9A],
),
]; ];
for (idx, (key, seed)) in cases.iter().enumerate() { for (idx, (key, seed)) in cases.iter().enumerate() {
@@ -594,11 +616,26 @@ fn css_roundtrip_multiple_keys() {
#[test] #[test]
fn css_stevenson_attack_validates_cracked_key() { fn css_stevenson_attack_validates_cracked_key() {
let candidates: &[([u8; 5], [u8; 5])] = &[ let candidates: &[([u8; 5], [u8; 5])] = &[
([0x42, 0x13, 0x37, 0xBE, 0xEF], [0x11, 0x22, 0x33, 0x44, 0x55]), (
([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]), [0x42, 0x13, 0x37, 0xBE, 0xEF],
([0x10, 0x20, 0x30, 0x40, 0x50], [0x05, 0x06, 0x07, 0x08, 0x09]), [0x11, 0x22, 0x33, 0x44, 0x55],
([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]), ),
([0x55, 0xAA, 0x55, 0xAA, 0x55], [0x00, 0x00, 0x00, 0x00, 0x00]), (
[0x01, 0x02, 0x03, 0x04, 0x05],
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
),
(
[0x10, 0x20, 0x30, 0x40, 0x50],
[0x05, 0x06, 0x07, 0x08, 0x09],
),
(
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
[0x12, 0x34, 0x56, 0x78, 0x9A],
),
(
[0x55, 0xAA, 0x55, 0xAA, 0x55],
[0x00, 0x00, 0x00, 0x00, 0x00],
),
]; ];
let mut any_cracked = false; let mut any_cracked = false;
+131 -6
View File
@@ -143,9 +143,15 @@ fn scan_options_with_keydb_pathbuf() {
// ── detect_format integration tests ─────────────────────────────────────── // ── detect_format integration tests ───────────────────────────────────────
use libfreemkv::{Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream}; use libfreemkv::{
Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
};
fn title_with_video(codec: Codec, resolution: &str, content_format: ContentFormat) -> DiscTitle { fn title_with_video(
codec: Codec,
resolution: Resolution,
content_format: ContentFormat,
) -> DiscTitle {
DiscTitle { DiscTitle {
playlist: "00800.mpls".into(), playlist: "00800.mpls".into(),
playlist_id: 800, playlist_id: 800,
@@ -155,8 +161,8 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma
streams: vec![Stream::Video(VideoStream { streams: vec![Stream::Video(VideoStream {
pid: 0x1011, pid: 0x1011,
codec, codec,
resolution: resolution.into(), resolution,
frame_rate: "23.976".into(), frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr, hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709, color_space: ColorSpace::Bt709,
secondary: false, secondary: false,
@@ -191,13 +197,13 @@ fn disc_title_duration_display_edge_cases() {
#[test] #[test]
fn content_format_default_bdts() { fn content_format_default_bdts() {
let t = title_with_video(Codec::H264, "1080p", ContentFormat::BdTs); let t = title_with_video(Codec::H264, Resolution::R1080p, ContentFormat::BdTs);
assert_eq!(t.content_format, ContentFormat::BdTs); assert_eq!(t.content_format, ContentFormat::BdTs);
} }
#[test] #[test]
fn content_format_dvd_mpegps() { fn content_format_dvd_mpegps() {
let t = title_with_video(Codec::Mpeg2, "480i", ContentFormat::MpegPs); let t = title_with_video(Codec::Mpeg2, Resolution::R480i, ContentFormat::MpegPs);
assert_eq!(t.content_format, ContentFormat::MpegPs); assert_eq!(t.content_format, ContentFormat::MpegPs);
} }
@@ -368,6 +374,125 @@ fn resolve_encryption_no_aacs_dir() {
assert!(disc.aacs.is_none(), "aacs should be None without /AACS dir"); assert!(disc.aacs.is_none(), "aacs should be None without /AACS dir");
} }
// ── Batch count arithmetic tests ──────────────────────────────────────────
// Regression tests for the u16 truncation bug: when (remaining as u16) was
// used instead of remaining.min(batch as u32) as u16, any remaining count
// that was a multiple of 65536 would truncate to 0, causing an infinite loop.
/// Simulates the fixed batch count calculation from pipe.rs / drive.rs
fn safe_batch_count(remaining: u32, batch_sectors: u16) -> u16 {
remaining.min(batch_sectors as u32) as u16
}
/// Simulates the BUGGY calculation that caused the infinite loop
fn buggy_batch_count(remaining: u32, batch_sectors: u16) -> u16 {
(remaining as u16).min(batch_sectors)
}
#[test]
fn batch_count_normal() {
// Normal case: remaining > batch_sectors
assert_eq!(safe_batch_count(1000, 60), 60);
assert_eq!(safe_batch_count(47533152, 60), 60);
}
#[test]
fn batch_count_last_batch() {
// Last batch: remaining < batch_sectors
assert_eq!(safe_batch_count(30, 60), 30);
assert_eq!(safe_batch_count(1, 60), 1);
}
#[test]
fn batch_count_exact_boundary() {
// Exact boundary: remaining == batch_sectors
assert_eq!(safe_batch_count(60, 60), 60);
}
#[test]
fn batch_count_u16_overflow_regression() {
// THE BUG: remaining is a multiple of 65536 → truncates to 0
// 47513600 = 725 * 65536, lower 16 bits = 0
let remaining: u32 = 47533152 - 19552; // = 47513600
assert_eq!(remaining, 47513600);
assert_eq!(remaining % 65536, 0, "remaining should be multiple of 65536");
// Buggy version produces 0 → infinite loop
assert_eq!(buggy_batch_count(remaining, 60), 0);
// Fixed version produces 60
assert_eq!(safe_batch_count(remaining, 60), 60);
}
#[test]
fn batch_count_other_u16_overflow_values() {
// Other multiples of 65536
assert_eq!(safe_batch_count(65536, 60), 60);
assert_eq!(safe_batch_count(131072, 60), 60);
assert_eq!(safe_batch_count(65536 * 100, 60), 60);
// Verify buggy version fails on all of these
assert_eq!(buggy_batch_count(65536, 60), 0);
assert_eq!(buggy_batch_count(131072, 60), 0);
assert_eq!(buggy_batch_count(65536 * 100, 60), 0);
}
#[test]
fn batch_count_near_u16_boundary() {
// Values just below and above 65536
assert_eq!(safe_batch_count(65535, 60), 60);
assert_eq!(safe_batch_count(65536, 60), 60);
assert_eq!(safe_batch_count(65537, 60), 60);
// Buggy: 65535 as u16 = 65535, min(60) = 60 (OK by accident)
assert_eq!(buggy_batch_count(65535, 60), 60);
// Buggy: 65536 as u16 = 0, min(60) = 0 (BUG)
assert_eq!(buggy_batch_count(65536, 60), 0);
// Buggy: 65537 as u16 = 1, min(60) = 1 (wrong but doesn't loop)
assert_eq!(buggy_batch_count(65537, 60), 1);
}
#[test]
fn batch_count_real_disc_sizes() {
let batch: u16 = 60;
// DVD-5: ~2,295,104 sectors
assert_eq!(safe_batch_count(2295104, batch), 60);
// BD-25: ~12,219,392 sectors
assert_eq!(safe_batch_count(12219392, batch), 60);
// BD-50: ~24,438,784 sectors
assert_eq!(safe_batch_count(24438784, batch), 60);
// UHD BD-66: ~33,554,432 sectors
assert_eq!(safe_batch_count(33554432, batch), 60);
// UHD BD-100: ~47,533,152 sectors
assert_eq!(safe_batch_count(47533152, batch), 60);
// Last few sectors of each
assert_eq!(safe_batch_count(52, batch), 52);
assert_eq!(safe_batch_count(3, batch), 3);
}
#[test]
fn batch_count_zero_remaining() {
// Zero remaining should produce 0 (loop exits before this)
assert_eq!(safe_batch_count(0, 60), 0);
}
#[test]
fn batch_count_max_batch_sizes() {
// Test with different batch sizes used by detect_max_batch_sectors
for &batch in &[3u16, 6, 9, 30, 60, 120, 240, 510] {
// Large remaining should always return batch
assert_eq!(safe_batch_count(47533152, batch), batch);
// Small remaining should return remaining
assert_eq!(safe_batch_count(1, batch), 1);
}
}
#[test] #[test]
fn resolve_encryption_no_keydb() { fn resolve_encryption_no_keydb() {
// A UDF image with /AACS directory but no keydb path -> aacs is None // A UDF image with /AACS directory but no keydb path -> aacs is None
+36 -36
View File
@@ -15,8 +15,8 @@ fn sample_disc_title() -> DiscTitle {
Stream::Video(VideoStream { Stream::Video(VideoStream {
pid: 0x1011, pid: 0x1011,
codec: Codec::Hevc, codec: Codec::Hevc,
resolution: "2160p".into(), resolution: Resolution::R2160p,
frame_rate: "23.976".into(), frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10, hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt709, color_space: ColorSpace::Bt709,
secondary: false, secondary: false,
@@ -25,18 +25,18 @@ fn sample_disc_title() -> DiscTitle {
Stream::Audio(AudioStream { Stream::Audio(AudioStream {
pid: 0x1100, pid: 0x1100,
codec: Codec::TrueHd, codec: Codec::TrueHd,
channels: "7.1".into(), channels: AudioChannels::Surround71,
language: "eng".into(), language: "eng".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: false, secondary: false,
label: "English Atmos".into(), label: "English Atmos".into(),
}), }),
Stream::Audio(AudioStream { Stream::Audio(AudioStream {
pid: 0x1101, pid: 0x1101,
codec: Codec::Ac3, codec: Codec::Ac3,
channels: "5.1".into(), channels: AudioChannels::Surround51,
language: "fra".into(), language: "fra".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: false, secondary: false,
label: "French".into(), label: "French".into(),
}), }),
@@ -201,7 +201,7 @@ fn m2ts_meta_roundtrip() {
// Check video // Check video
if let Stream::Video(v) = &restored.streams[0] { if let Stream::Video(v) = &restored.streams[0] {
assert_eq!(v.codec, Codec::Hevc); assert_eq!(v.codec, Codec::Hevc);
assert_eq!(v.resolution, "2160p"); assert_eq!(v.resolution, Resolution::R2160p);
assert_eq!(v.label, "Main"); assert_eq!(v.label, "Main");
} else { } else {
panic!("expected video"); panic!("expected video");
@@ -413,8 +413,8 @@ fn meta_codec_roundtrip() {
streams.push(Stream::Video(VideoStream { streams.push(Stream::Video(VideoStream {
pid: (0x1011 + i) as u16, pid: (0x1011 + i) as u16,
codec, codec,
resolution: "1080p".into(), resolution: Resolution::R1080p,
frame_rate: "23.976".into(), frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr, hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709, color_space: ColorSpace::Bt709,
secondary: false, secondary: false,
@@ -425,9 +425,9 @@ fn meta_codec_roundtrip() {
streams.push(Stream::Audio(AudioStream { streams.push(Stream::Audio(AudioStream {
pid: (0x1100 + i) as u16, pid: (0x1100 + i) as u16,
codec, codec,
channels: "5.1".into(), channels: AudioChannels::Surround51,
language: "eng".into(), language: "eng".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: false, secondary: false,
label: String::new(), label: String::new(),
})); }));
@@ -509,8 +509,8 @@ fn meta_all_stream_types() {
Stream::Video(VideoStream { Stream::Video(VideoStream {
pid: 0x1011, pid: 0x1011,
codec: Codec::Hevc, codec: Codec::Hevc,
resolution: "2160p".into(), resolution: Resolution::R2160p,
frame_rate: "23.976".into(), frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10, hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt709, color_space: ColorSpace::Bt709,
secondary: false, secondary: false,
@@ -519,9 +519,9 @@ fn meta_all_stream_types() {
Stream::Audio(AudioStream { Stream::Audio(AudioStream {
pid: 0x1100, pid: 0x1100,
codec: Codec::TrueHd, codec: Codec::TrueHd,
channels: "7.1".into(), channels: AudioChannels::Surround71,
language: "eng".into(), language: "eng".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: false, secondary: false,
label: "Primary Audio".into(), label: "Primary Audio".into(),
}), }),
@@ -535,9 +535,9 @@ fn meta_all_stream_types() {
Stream::Audio(AudioStream { Stream::Audio(AudioStream {
pid: 0x1110, pid: 0x1110,
codec: Codec::Ac3, codec: Codec::Ac3,
channels: "stereo".into(), channels: AudioChannels::Stereo,
language: "eng".into(), language: "eng".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: true, secondary: true,
label: "Commentary".into(), label: "Commentary".into(),
}), }),
@@ -553,7 +553,7 @@ fn meta_all_stream_types() {
// Video preserved // Video preserved
if let Stream::Video(v) = &restored.streams[0] { if let Stream::Video(v) = &restored.streams[0] {
assert_eq!(v.codec, Codec::Hevc); assert_eq!(v.codec, Codec::Hevc);
assert_eq!(v.resolution, "2160p"); assert_eq!(v.resolution, Resolution::R2160p);
assert_eq!(v.label, "Primary"); assert_eq!(v.label, "Primary");
assert!(!v.secondary); assert!(!v.secondary);
} else { } else {
@@ -563,7 +563,7 @@ fn meta_all_stream_types() {
// Primary audio preserved // Primary audio preserved
if let Stream::Audio(a) = &restored.streams[1] { if let Stream::Audio(a) = &restored.streams[1] {
assert_eq!(a.codec, Codec::TrueHd); assert_eq!(a.codec, Codec::TrueHd);
assert_eq!(a.channels, "7.1"); assert_eq!(a.channels, AudioChannels::Surround71);
assert!(!a.secondary); assert!(!a.secondary);
} else { } else {
panic!("expected audio"); panic!("expected audio");
@@ -642,9 +642,9 @@ fn mkvstream_roundtrip_bdts() {
streams: vec![Stream::Audio(AudioStream { streams: vec![Stream::Audio(AudioStream {
pid: 0x1100, pid: 0x1100,
codec: Codec::Ac3, codec: Codec::Ac3,
channels: "5.1".into(), channels: AudioChannels::Surround51,
language: "eng".into(), language: "eng".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: false, secondary: false,
label: "English".into(), label: "English".into(),
})], })],
@@ -688,8 +688,8 @@ fn mkvstream_meta_preserves_all_streams() {
Stream::Video(VideoStream { Stream::Video(VideoStream {
pid: 0x1011, pid: 0x1011,
codec: Codec::H264, codec: Codec::H264,
resolution: "1080p".into(), resolution: Resolution::R1080p,
frame_rate: "23.976".into(), frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr, hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709, color_space: ColorSpace::Bt709,
secondary: false, secondary: false,
@@ -698,18 +698,18 @@ fn mkvstream_meta_preserves_all_streams() {
Stream::Audio(AudioStream { Stream::Audio(AudioStream {
pid: 0x1100, pid: 0x1100,
codec: Codec::Ac3, codec: Codec::Ac3,
channels: "5.1".into(), channels: AudioChannels::Surround51,
language: "eng".into(), language: "eng".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: false, secondary: false,
label: "English".into(), label: "English".into(),
}), }),
Stream::Audio(AudioStream { Stream::Audio(AudioStream {
pid: 0x1101, pid: 0x1101,
codec: Codec::DtsHdMa, codec: Codec::DtsHdMa,
channels: "7.1".into(), channels: AudioChannels::Surround71,
language: "fra".into(), language: "fra".into(),
sample_rate: "48kHz".into(), sample_rate: SampleRate::S48,
secondary: false, secondary: false,
label: "French".into(), label: "French".into(),
}), }),
@@ -781,8 +781,8 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
streams: vec![Stream::Video(VideoStream { streams: vec![Stream::Video(VideoStream {
pid: 0x1011, pid: 0x1011,
codec: Codec::H264, codec: Codec::H264,
resolution: "1080p".into(), resolution: Resolution::R1080p,
frame_rate: "23.976".into(), frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr, hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709, color_space: ColorSpace::Bt709,
secondary: false, secondary: false,
@@ -961,7 +961,11 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
let data = output2.lock().unwrap().clone().into_inner(); let data = output2.lock().unwrap().clone().into_inner();
// Verify output starts with EBML magic (0x1A45DFA3) // Verify output starts with EBML magic (0x1A45DFA3)
assert!(data.len() >= 4, "MKV output too small: {} bytes", data.len()); assert!(
data.len() >= 4,
"MKV output too small: {} bytes",
data.len()
);
assert_eq!( assert_eq!(
&data[0..4], &data[0..4],
&[0x1A, 0x45, 0xDF, 0xA3], &[0x1A, 0x45, 0xDF, 0xA3],
@@ -970,17 +974,13 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
// Verify output contains a Tracks element (0x1654AE6B) // Verify output contains a Tracks element (0x1654AE6B)
let tracks_needle = [0x16, 0x54, 0xAE, 0x6B]; let tracks_needle = [0x16, 0x54, 0xAE, 0x6B];
let has_tracks = data let has_tracks = data.windows(4).any(|w| w == tracks_needle);
.windows(4)
.any(|w| w == tracks_needle);
assert!(has_tracks, "output should contain Tracks element"); assert!(has_tracks, "output should contain Tracks element");
// Verify codecPrivate is non-empty (not all zeros) // Verify codecPrivate is non-empty (not all zeros)
// CodecPrivate element ID is 0x63A2 // CodecPrivate element ID is 0x63A2
let cp_needle = [0x63, 0xA2]; let cp_needle = [0x63, 0xA2];
let cp_pos = data let cp_pos = data.windows(2).position(|w| w == cp_needle);
.windows(2)
.position(|w| w == cp_needle);
if let Some(pos) = cp_pos { if let Some(pos) = cp_pos {
// After the ID, there's a size VINT, then the data // After the ID, there's a size VINT, then the data
let after_id = pos + 2; let after_id = pos + 2;