mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7220c62b | ||
|
|
404c005e0b | ||
|
|
87342290c5 | ||
|
|
8bbf630d82 | ||
|
|
45dddc1810 | ||
|
|
cfa80cb881 | ||
|
|
b510ae9b29 | ||
|
|
9a1c3cc218 | ||
|
|
9ae7d6b38a | ||
|
|
d983985faa | ||
|
|
56fe26c9b8 | ||
|
|
7a83f3244d | ||
|
|
15f5b49fc3 | ||
|
|
ff6004a567 | ||
|
|
bd644d2f60 | ||
|
|
ab63950ef1 | ||
|
|
cab87ffdf4 | ||
|
|
ccb1fadedf | ||
|
|
610f1b62b6 | ||
|
|
f400e4fc72 | ||
|
|
5a9980cb6b | ||
|
|
2f48877992 | ||
|
|
7c59d063ee | ||
|
|
e4ffe7cc91 | ||
|
|
2e23e848df | ||
|
|
fbdfccdd83 | ||
|
|
2f52188f77 | ||
|
|
252e58cab0 | ||
|
|
3b96976a7a | ||
|
|
3385de5704 | ||
|
|
e9c907a2ca |
@@ -6,23 +6,33 @@ on:
|
||||
pull_request:
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo test
|
||||
|
||||
check-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo check
|
||||
|
||||
check-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo check
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo test
|
||||
|
||||
publish:
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
env:
|
||||
|
||||
+6
-1
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.8.3"
|
||||
version = "0.10.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
@@ -30,3 +31,7 @@ libc = "0.2"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[[bench]]
|
||||
name = "sgio_read"
|
||||
harness = false
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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).
|
||||
pub fn read_data_keys(
|
||||
session: &mut Drive,
|
||||
auth: &mut AacsAuth,
|
||||
) -> Result<([u8; 16], [u8; 16])> {
|
||||
pub fn read_data_keys(session: &mut Drive, auth: &mut AacsAuth) -> Result<([u8; 16], [u8; 16])> {
|
||||
// REPORT DISC STRUCTURE format 0x84
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
|
||||
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsDataKey)?;
|
||||
|
||||
+7
-5
@@ -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
|
||||
for pk in processing_keys {
|
||||
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;
|
||||
if record_start + 5 > uvs.len() { continue; }
|
||||
if record_start + 5 > uvs.len() {
|
||||
continue;
|
||||
}
|
||||
let _u_mask_shift = uvs[record_start];
|
||||
let uv = &uvs[record_start + 1..record_start + 5];
|
||||
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).
|
||||
/// Returns the concatenated MKB data from all packs.
|
||||
pub fn read_mkb_from_drive(
|
||||
session: &mut crate::drive::Drive,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result<Vec<u8>> {
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||
|
||||
let cdb = [
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Decrypt-on-read layer.
|
||||
//!
|
||||
//! Decrypts sectors in-place using resolved keys from disc scanning.
|
||||
//! Handles AACS 1.0, AACS 2.0, and CSS transparently.
|
||||
//! The caller never sees encrypted data unless explicitly bypassed.
|
||||
|
||||
use crate::aacs;
|
||||
use crate::css;
|
||||
|
||||
/// Resolved decryption state from disc scanning.
|
||||
/// Passed to `decrypt_sectors()` — the caller doesn't need to know
|
||||
/// which encryption scheme is in use.
|
||||
#[derive(Clone)]
|
||||
pub enum DecryptKeys {
|
||||
/// No encryption on this disc.
|
||||
None,
|
||||
/// AACS (Blu-ray / UHD). Unit keys + optional read data key.
|
||||
Aacs {
|
||||
unit_keys: Vec<(u32, [u8; 16])>,
|
||||
read_data_key: Option<[u8; 16]>,
|
||||
},
|
||||
/// CSS (DVD). Title key for sector descrambling.
|
||||
Css { title_key: [u8; 5] },
|
||||
}
|
||||
|
||||
impl DecryptKeys {
|
||||
/// True if there are keys to decrypt with.
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
!matches!(self, DecryptKeys::None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a buffer of sectors in-place.
|
||||
///
|
||||
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
||||
/// For CSS: processes per 2048-byte sector.
|
||||
/// For None: no-op.
|
||||
///
|
||||
/// `unit_key_idx` selects which AACS unit key to use (0 for most discs).
|
||||
///
|
||||
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
||||
/// Never produces silently corrupted output.
|
||||
pub fn decrypt_sectors(
|
||||
buf: &mut [u8],
|
||||
keys: &DecryptKeys,
|
||||
unit_key_idx: usize,
|
||||
) -> Result<(), crate::error::Error> {
|
||||
match keys {
|
||||
DecryptKeys::None => {}
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys,
|
||||
read_data_key,
|
||||
} => {
|
||||
let uk = match unit_keys.get(unit_key_idx) {
|
||||
Some((_, k)) => *k,
|
||||
None => {
|
||||
return Err(crate::error::Error::DecryptFailed {
|
||||
reason: format!(
|
||||
"unit_key_idx {} out of range (have {} keys)",
|
||||
unit_key_idx,
|
||||
unit_keys.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
let rdk = read_data_key.as_ref();
|
||||
let unit_len = aacs::ALIGNED_UNIT_LEN;
|
||||
|
||||
for chunk in buf.chunks_mut(unit_len) {
|
||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
||||
aacs::decrypt_unit_full(chunk, &uk, rdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
DecryptKeys::Css { title_key } => {
|
||||
for chunk in buf.chunks_mut(2048) {
|
||||
css::lfsr::descramble_sector(title_key, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+5
-4
@@ -104,8 +104,8 @@ impl Disc {
|
||||
1 | 6 | 7 => Some(Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: format_resolution(s.video_format, s.video_rate),
|
||||
frame_rate: format_framerate(s.video_rate),
|
||||
resolution: Resolution::from_video_format(s.video_format),
|
||||
frame_rate: FrameRate::from_video_rate(s.video_rate),
|
||||
hdr: match s.dynamic_range {
|
||||
1 => HdrFormat::Hdr10,
|
||||
2 => HdrFormat::DolbyVision,
|
||||
@@ -137,9 +137,9 @@ impl Disc {
|
||||
Some(Stream::Audio(AudioStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
channels: format_channels(s.audio_format),
|
||||
channels: AudioChannels::from_audio_format(s.audio_format),
|
||||
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,
|
||||
label: String::new(),
|
||||
}))
|
||||
@@ -187,6 +187,7 @@ impl Disc {
|
||||
chapters,
|
||||
extents,
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+8
-32
@@ -20,20 +20,13 @@ impl Disc {
|
||||
let mut title_number: u16 = 0;
|
||||
|
||||
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 {
|
||||
pid: 0xE0, // DVD video PID (standard MPEG PS video stream)
|
||||
codec: video_codec,
|
||||
resolution: ts.video.resolution.clone(),
|
||||
codec: ts.video.codec,
|
||||
resolution: ts.video.resolution,
|
||||
frame_rate: match ts.video.standard.as_str() {
|
||||
"PAL" => "25".to_string(),
|
||||
_ => "29.97".to_string(),
|
||||
"PAL" => FrameRate::F25,
|
||||
_ => FrameRate::F29_97,
|
||||
},
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
@@ -47,31 +40,13 @@ impl Disc {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
let codec = match a.codec.as_str() {
|
||||
"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),
|
||||
};
|
||||
let codec = a.codec;
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs
|
||||
codec,
|
||||
channels,
|
||||
channels: AudioChannels::from_count(a.channels),
|
||||
language: a.language.clone(),
|
||||
sample_rate,
|
||||
sample_rate: SampleRate::from_hz(a.sample_rate),
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})
|
||||
@@ -136,6 +111,7 @@ impl Disc {
|
||||
chapters: Vec::new(),
|
||||
extents,
|
||||
content_format: ContentFormat::MpegPs,
|
||||
codec_privates: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+9
-11
@@ -25,12 +25,14 @@ impl Disc {
|
||||
let keydb_path = opts.resolve_keydb()?;
|
||||
let keydb = KeyDb::load(&keydb_path).ok()?;
|
||||
|
||||
let mut last_error = None;
|
||||
for hc in &keydb.host_certs {
|
||||
const MAX_CERT_ATTEMPTS: usize = 16;
|
||||
for hc in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS) {
|
||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
||||
Ok(mut auth) => {
|
||||
let volume_id =
|
||||
aacs::handshake::read_volume_id(session, &mut auth).unwrap_or([0u8; 16]);
|
||||
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
|
||||
};
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
.ok()
|
||||
.map(|(rdk, _)| rdk);
|
||||
@@ -39,17 +41,13 @@ impl Disc {
|
||||
read_data_key,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// Try next host cert
|
||||
last_error = Some(e);
|
||||
Err(_) => {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
last_error.map(|_e| HandshakeResult {
|
||||
volume_id: [0u8; 16],
|
||||
read_data_key: None,
|
||||
})
|
||||
// All host certs failed — return None, not a fake success
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
|
||||
+556
-136
@@ -116,6 +116,9 @@ pub struct DiscTitle {
|
||||
pub extents: Vec<Extent>,
|
||||
/// Content format for this title
|
||||
pub content_format: ContentFormat,
|
||||
/// Codec initialization data per stream (SPS/PPS, etc).
|
||||
/// Index matches `streams`. None for streams without codec init data.
|
||||
pub codec_privates: Vec<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// A clip reference within a title.
|
||||
@@ -148,10 +151,10 @@ pub struct VideoStream {
|
||||
pub pid: u16,
|
||||
/// Codec (HEVC, H.264, VC-1, MPEG-2)
|
||||
pub codec: Codec,
|
||||
/// Resolution (e.g. "2160p", "1080p", "1080i")
|
||||
pub resolution: String,
|
||||
/// Frame rate (e.g. "23.976", "25")
|
||||
pub frame_rate: String,
|
||||
/// Resolution
|
||||
pub resolution: Resolution,
|
||||
/// Frame rate
|
||||
pub frame_rate: FrameRate,
|
||||
/// HDR format
|
||||
pub hdr: HdrFormat,
|
||||
/// Color space
|
||||
@@ -169,12 +172,12 @@ pub struct AudioStream {
|
||||
pub pid: u16,
|
||||
/// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
|
||||
pub codec: Codec,
|
||||
/// Channel layout (e.g. "5.1", "7.1", "stereo", "mono")
|
||||
pub channels: String,
|
||||
/// Channel layout
|
||||
pub channels: AudioChannels,
|
||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Sample rate (e.g. "48kHz", "96kHz")
|
||||
pub sample_rate: String,
|
||||
/// Sample rate
|
||||
pub sample_rate: SampleRate,
|
||||
/// Whether this is a secondary stream (commentary)
|
||||
pub secondary: bool,
|
||||
/// Extra label
|
||||
@@ -204,6 +207,8 @@ pub enum Codec {
|
||||
H264,
|
||||
Vc1,
|
||||
Mpeg2,
|
||||
Mpeg1,
|
||||
Av1,
|
||||
// Audio
|
||||
TrueHd,
|
||||
DtsHdMa,
|
||||
@@ -212,19 +217,118 @@ pub enum Codec {
|
||||
Ac3,
|
||||
Ac3Plus,
|
||||
Lpcm,
|
||||
Aac,
|
||||
Mp2,
|
||||
Mp3,
|
||||
Flac,
|
||||
Opus,
|
||||
// Subtitle
|
||||
Pgs,
|
||||
DvdSub,
|
||||
Srt,
|
||||
Ssa,
|
||||
// Unknown
|
||||
Unknown(u8),
|
||||
}
|
||||
|
||||
/// Video resolution.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Resolution {
|
||||
/// 480i (720x480 interlaced) — NTSC DVD
|
||||
R480i,
|
||||
/// 480p (720x480 progressive)
|
||||
R480p,
|
||||
/// 576i (720x576 interlaced) — PAL DVD
|
||||
R576i,
|
||||
/// 576p (720x576 progressive)
|
||||
R576p,
|
||||
/// 720p (1280x720 progressive) — some Blu-rays
|
||||
R720p,
|
||||
/// 1080i (1920x1080 interlaced) — broadcast, some BD
|
||||
R1080i,
|
||||
/// 1080p (1920x1080 progressive) — standard Blu-ray
|
||||
R1080p,
|
||||
/// 2160p (3840x2160 progressive) — 4K UHD Blu-ray
|
||||
R2160p,
|
||||
/// 4320p (7680x4320 progressive) — 8K, future-proof
|
||||
R4320p,
|
||||
/// Unknown resolution
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Video frame rate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum FrameRate {
|
||||
/// 23.976 fps — film-based BD/UHD (NTSC pulldown)
|
||||
F23_976,
|
||||
/// 24.000 fps — true film rate
|
||||
F24,
|
||||
/// 25.000 fps — PAL standard
|
||||
F25,
|
||||
/// 29.970 fps — NTSC standard
|
||||
F29_97,
|
||||
/// 30.000 fps
|
||||
F30,
|
||||
/// 50.000 fps — PAL high frame rate
|
||||
F50,
|
||||
/// 59.940 fps — NTSC high frame rate
|
||||
F59_94,
|
||||
/// 60.000 fps
|
||||
F60,
|
||||
/// Unknown frame rate
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Audio channel layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioChannels {
|
||||
/// 1.0 mono
|
||||
Mono,
|
||||
/// 2.0 stereo
|
||||
Stereo,
|
||||
/// 2.1 (stereo + LFE)
|
||||
Stereo21,
|
||||
/// 4.0 quadraphonic
|
||||
Quad,
|
||||
/// 5.0 surround (no LFE)
|
||||
Surround50,
|
||||
/// 5.1 surround — standard BD/DVD surround
|
||||
Surround51,
|
||||
/// 6.1 surround (DTS-ES, Dolby EX)
|
||||
Surround61,
|
||||
/// 7.1 surround — UHD Atmos beds, DTS:X
|
||||
Surround71,
|
||||
/// Unknown channel layout
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Audio sample rate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SampleRate {
|
||||
/// 44.1 kHz — CD audio (rare on disc)
|
||||
S44_1,
|
||||
/// 48 kHz — standard BD/DVD/UHD audio
|
||||
S48,
|
||||
/// 96 kHz — high-res BD audio
|
||||
S96,
|
||||
/// 192 kHz — highest BD audio (LPCM)
|
||||
S192,
|
||||
/// 48/96 kHz combo (secondary audio resampled)
|
||||
S48_96,
|
||||
/// 48/192 kHz combo (secondary audio resampled)
|
||||
S48_192,
|
||||
/// Unknown sample rate
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// HDR format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum HdrFormat {
|
||||
Sdr,
|
||||
Hdr10,
|
||||
Hdr10Plus,
|
||||
DolbyVision,
|
||||
Hlg,
|
||||
}
|
||||
|
||||
/// Color space.
|
||||
@@ -254,25 +358,51 @@ pub struct Extent {
|
||||
// ─── Display helpers ────────────────────────────────────────────────────────
|
||||
|
||||
impl Codec {
|
||||
/// Human-readable display name.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Codec::Hevc => "HEVC",
|
||||
Codec::H264 => "H.264",
|
||||
Codec::Vc1 => "VC-1",
|
||||
Codec::Mpeg2 => "MPEG-2",
|
||||
Codec::TrueHd => "TrueHD",
|
||||
Codec::DtsHdMa => "DTS-HD MA",
|
||||
Codec::DtsHdHr => "DTS-HD HR",
|
||||
Codec::Dts => "DTS",
|
||||
Codec::Ac3 => "AC-3",
|
||||
Codec::Ac3Plus => "AC-3+",
|
||||
Codec::Lpcm => "LPCM",
|
||||
Codec::Pgs => "PGS",
|
||||
Codec::DvdSub => "DVD Subtitle",
|
||||
Codec::Unknown(_) => "Unknown",
|
||||
for (_, name, v) in Self::ALL_CODECS {
|
||||
if v == self {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
/// Compact identifier for serialization (lowercase, no spaces).
|
||||
pub fn id(&self) -> &'static str {
|
||||
for (id, _, v) in Self::ALL_CODECS {
|
||||
if v == self {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
"unknown"
|
||||
}
|
||||
|
||||
const ALL_CODECS: &[(&'static str, &'static str, Codec)] = &[
|
||||
("hevc", "HEVC", Codec::Hevc),
|
||||
("h264", "H.264", Codec::H264),
|
||||
("vc1", "VC-1", Codec::Vc1),
|
||||
("mpeg2", "MPEG-2", Codec::Mpeg2),
|
||||
("mpeg1", "MPEG-1", Codec::Mpeg1),
|
||||
("av1", "AV1", Codec::Av1),
|
||||
("truehd", "TrueHD", Codec::TrueHd),
|
||||
("dtshd_ma", "DTS-HD MA", Codec::DtsHdMa),
|
||||
("dtshd_hr", "DTS-HD HR", Codec::DtsHdHr),
|
||||
("dts", "DTS", Codec::Dts),
|
||||
("ac3", "AC-3", Codec::Ac3),
|
||||
("eac3", "EAC-3", Codec::Ac3Plus),
|
||||
("lpcm", "LPCM", Codec::Lpcm),
|
||||
("aac", "AAC", Codec::Aac),
|
||||
("mp2", "MP2", Codec::Mp2),
|
||||
("mp3", "MP3", Codec::Mp3),
|
||||
("flac", "FLAC", Codec::Flac),
|
||||
("opus", "Opus", Codec::Opus),
|
||||
("pgs", "PGS", Codec::Pgs),
|
||||
("dvdsub", "DVD Subtitle", Codec::DvdSub),
|
||||
("srt", "SRT", Codec::Srt),
|
||||
("ssa", "SSA", Codec::Ssa),
|
||||
];
|
||||
|
||||
fn from_coding_type(ct: u8) -> Self {
|
||||
match ct {
|
||||
0x24 => Codec::Hevc,
|
||||
@@ -293,14 +423,233 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Codec {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolution {
|
||||
/// Parse from MPLS video_format byte.
|
||||
pub fn from_video_format(vf: u8) -> Self {
|
||||
match vf {
|
||||
1 => Resolution::R480i,
|
||||
2 => Resolution::R576i,
|
||||
3 => Resolution::R480p,
|
||||
4 => Resolution::R1080i,
|
||||
5 => Resolution::R720p,
|
||||
6 => Resolution::R1080p,
|
||||
7 => Resolution::R576p,
|
||||
8 => Resolution::R2160p,
|
||||
_ => Resolution::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel dimensions (width, height).
|
||||
pub fn pixels(&self) -> (u32, u32) {
|
||||
match self {
|
||||
Resolution::R480i | Resolution::R480p => (720, 480),
|
||||
Resolution::R576i | Resolution::R576p => (720, 576),
|
||||
Resolution::R720p => (1280, 720),
|
||||
Resolution::R1080i | Resolution::R1080p => (1920, 1080),
|
||||
Resolution::R2160p => (3840, 2160),
|
||||
Resolution::R4320p => (7680, 4320),
|
||||
Resolution::Unknown => (1920, 1080),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this is a UHD (4K+) resolution.
|
||||
pub fn is_uhd(&self) -> bool {
|
||||
matches!(self, Resolution::R2160p | Resolution::R4320p)
|
||||
}
|
||||
|
||||
/// True if this is an HD (720p+) resolution.
|
||||
pub fn is_hd(&self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
Resolution::R480i
|
||||
| Resolution::R480p
|
||||
| Resolution::R576i
|
||||
| Resolution::R576p
|
||||
| Resolution::Unknown
|
||||
)
|
||||
}
|
||||
|
||||
/// True if this is an SD (480/576) resolution.
|
||||
pub fn is_sd(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Resolution::R480i | Resolution::R480p | Resolution::R576i | Resolution::R576p
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse from pixel height (e.g. from MKV track).
|
||||
pub fn from_height(h: u32) -> Self {
|
||||
match h {
|
||||
0..=480 => Resolution::R480p,
|
||||
481..=576 => Resolution::R576p,
|
||||
577..=720 => Resolution::R720p,
|
||||
721..=1080 => Resolution::R1080p,
|
||||
1081..=2160 => Resolution::R2160p,
|
||||
_ => Resolution::R4320p,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display for Resolution is generated by enum_str! macro
|
||||
|
||||
impl FrameRate {
|
||||
/// Parse from MPLS video_rate byte.
|
||||
pub fn from_video_rate(vr: u8) -> Self {
|
||||
match vr {
|
||||
1 => FrameRate::F23_976,
|
||||
2 => FrameRate::F24,
|
||||
3 => FrameRate::F25,
|
||||
4 => FrameRate::F29_97,
|
||||
5 => FrameRate::F30,
|
||||
6 => FrameRate::F50,
|
||||
7 => FrameRate::F59_94,
|
||||
8 => FrameRate::F60,
|
||||
_ => FrameRate::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame rate as (numerator, denominator) for precise representation.
|
||||
pub fn as_fraction(&self) -> (u32, u32) {
|
||||
match self {
|
||||
FrameRate::F23_976 => (24000, 1001),
|
||||
FrameRate::F24 => (24, 1),
|
||||
FrameRate::F25 => (25, 1),
|
||||
FrameRate::F29_97 => (30000, 1001),
|
||||
FrameRate::F30 => (30, 1),
|
||||
FrameRate::F50 => (50, 1),
|
||||
FrameRate::F59_94 => (60000, 1001),
|
||||
FrameRate::F60 => (60, 1),
|
||||
FrameRate::Unknown => (0, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display for FrameRate is generated by enum_str! macro
|
||||
|
||||
impl AudioChannels {
|
||||
/// Parse from MPLS audio_format byte.
|
||||
pub fn from_audio_format(af: u8) -> Self {
|
||||
match af {
|
||||
1 => AudioChannels::Mono,
|
||||
3 => AudioChannels::Stereo,
|
||||
6 => AudioChannels::Surround51,
|
||||
12 => AudioChannels::Surround71,
|
||||
_ if af > 0 => AudioChannels::Unknown,
|
||||
_ => AudioChannels::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel count as a number.
|
||||
pub fn count(&self) -> u8 {
|
||||
match self {
|
||||
AudioChannels::Mono => 1,
|
||||
AudioChannels::Stereo => 2,
|
||||
AudioChannels::Stereo21 => 3,
|
||||
AudioChannels::Quad => 4,
|
||||
AudioChannels::Surround50 => 5,
|
||||
AudioChannels::Surround51 => 6,
|
||||
AudioChannels::Surround61 => 7,
|
||||
AudioChannels::Surround71 => 8,
|
||||
AudioChannels::Unknown => 6,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from channel count number.
|
||||
pub fn from_count(n: u8) -> Self {
|
||||
match n {
|
||||
1 => AudioChannels::Mono,
|
||||
2 => AudioChannels::Stereo,
|
||||
3 => AudioChannels::Stereo21,
|
||||
4 => AudioChannels::Quad,
|
||||
5 => AudioChannels::Surround50,
|
||||
6 => AudioChannels::Surround51,
|
||||
7 => AudioChannels::Surround61,
|
||||
8 => AudioChannels::Surround71,
|
||||
_ => AudioChannels::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display for AudioChannels is generated by enum_str! macro
|
||||
|
||||
impl SampleRate {
|
||||
/// Parse from MPLS audio_rate byte.
|
||||
pub fn from_audio_rate(ar: u8) -> Self {
|
||||
match ar {
|
||||
1 => SampleRate::S48,
|
||||
4 => SampleRate::S96,
|
||||
5 => SampleRate::S192,
|
||||
12 => SampleRate::S48_192,
|
||||
14 => SampleRate::S48_96,
|
||||
_ => SampleRate::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample rate in Hz (primary rate for combo rates).
|
||||
pub fn hz(&self) -> f64 {
|
||||
match self {
|
||||
SampleRate::S44_1 => 44100.0,
|
||||
SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
|
||||
SampleRate::S96 => 96000.0,
|
||||
SampleRate::S192 => 192000.0,
|
||||
SampleRate::Unknown => 48000.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from Hz value.
|
||||
pub fn from_hz(hz: u32) -> Self {
|
||||
match hz {
|
||||
44100 => SampleRate::S44_1,
|
||||
48000 => SampleRate::S48,
|
||||
96000 => SampleRate::S96,
|
||||
192000 => SampleRate::S192,
|
||||
_ => SampleRate::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display for SampleRate is generated by enum_str! macro
|
||||
|
||||
impl HdrFormat {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
HdrFormat::Sdr => "SDR",
|
||||
HdrFormat::Hdr10 => "HDR10",
|
||||
HdrFormat::Hdr10Plus => "HDR10+",
|
||||
HdrFormat::DolbyVision => "Dolby Vision",
|
||||
HdrFormat::Hlg => "HLG",
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_HDR: &[(&'static str, HdrFormat)] = &[
|
||||
("sdr", HdrFormat::Sdr),
|
||||
("hdr10", HdrFormat::Hdr10),
|
||||
("hdr10+", HdrFormat::Hdr10Plus),
|
||||
("dv", HdrFormat::DolbyVision),
|
||||
("hlg", HdrFormat::Hlg),
|
||||
];
|
||||
|
||||
/// Compact identifier for serialization.
|
||||
pub fn id(&self) -> &'static str {
|
||||
for (id, v) in Self::ALL_HDR {
|
||||
if v == self {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
"sdr"
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HdrFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorSpace {
|
||||
@@ -313,6 +662,131 @@ impl ColorSpace {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ColorSpace {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
|
||||
//
|
||||
// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
|
||||
// and id() all derive from this one table — no string appears twice.
|
||||
|
||||
macro_rules! enum_str {
|
||||
($name:ident, $default:expr, [ $( ($s:expr, $v:expr) ),* $(,)? ]) => {
|
||||
impl $name {
|
||||
const ALL: &[(&'static str, $name)] = &[ $( ($s, $v), )* ];
|
||||
}
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
for (s, v) in $name::ALL {
|
||||
if v == self { return f.write_str(s); }
|
||||
}
|
||||
f.write_str("")
|
||||
}
|
||||
}
|
||||
impl std::str::FromStr for $name {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> std::result::Result<Self, ()> {
|
||||
for (k, v) in $name::ALL {
|
||||
if *k == s { return Ok(*v); }
|
||||
}
|
||||
Ok($default)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
enum_str!(
|
||||
Resolution,
|
||||
Resolution::Unknown,
|
||||
[
|
||||
("480i", Resolution::R480i),
|
||||
("480p", Resolution::R480p),
|
||||
("576i", Resolution::R576i),
|
||||
("576p", Resolution::R576p),
|
||||
("720p", Resolution::R720p),
|
||||
("1080i", Resolution::R1080i),
|
||||
("1080p", Resolution::R1080p),
|
||||
("2160p", Resolution::R2160p),
|
||||
("4320p", Resolution::R4320p),
|
||||
]
|
||||
);
|
||||
|
||||
enum_str!(
|
||||
FrameRate,
|
||||
FrameRate::Unknown,
|
||||
[
|
||||
("23.976", FrameRate::F23_976),
|
||||
("24", FrameRate::F24),
|
||||
("25", FrameRate::F25),
|
||||
("29.97", FrameRate::F29_97),
|
||||
("30", FrameRate::F30),
|
||||
("50", FrameRate::F50),
|
||||
("59.94", FrameRate::F59_94),
|
||||
("60", FrameRate::F60),
|
||||
]
|
||||
);
|
||||
|
||||
enum_str!(
|
||||
AudioChannels,
|
||||
AudioChannels::Unknown,
|
||||
[
|
||||
("mono", AudioChannels::Mono),
|
||||
("stereo", AudioChannels::Stereo),
|
||||
("2.1", AudioChannels::Stereo21),
|
||||
("4.0", AudioChannels::Quad),
|
||||
("5.0", AudioChannels::Surround50),
|
||||
("5.1", AudioChannels::Surround51),
|
||||
("6.1", AudioChannels::Surround61),
|
||||
("7.1", AudioChannels::Surround71),
|
||||
]
|
||||
);
|
||||
|
||||
enum_str!(
|
||||
SampleRate,
|
||||
SampleRate::Unknown,
|
||||
[
|
||||
("44.1kHz", SampleRate::S44_1),
|
||||
("48kHz", SampleRate::S48),
|
||||
("96kHz", SampleRate::S96),
|
||||
("192kHz", SampleRate::S192),
|
||||
("48/96kHz", SampleRate::S48_96),
|
||||
("48/192kHz", SampleRate::S48_192),
|
||||
]
|
||||
);
|
||||
|
||||
impl std::str::FromStr for Codec {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> std::result::Result<Self, ()> {
|
||||
for (id, _, v) in Codec::ALL_CODECS {
|
||||
if *id == s {
|
||||
return Ok(*v);
|
||||
}
|
||||
}
|
||||
Ok(Codec::Unknown(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for HdrFormat {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> std::result::Result<Self, ()> {
|
||||
for (id, v) in HdrFormat::ALL_HDR {
|
||||
if *id == s {
|
||||
return Ok(*v);
|
||||
}
|
||||
}
|
||||
// Also accept display names
|
||||
for (_id, v) in HdrFormat::ALL_HDR {
|
||||
if HdrFormat::name(v) == s {
|
||||
return Ok(*v);
|
||||
}
|
||||
}
|
||||
Ok(HdrFormat::Sdr)
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscTitle {
|
||||
/// Empty DiscTitle with no streams.
|
||||
pub fn empty() -> Self {
|
||||
@@ -326,6 +800,7 @@ impl DiscTitle {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,13 +1108,13 @@ impl Disc {
|
||||
for title in titles.iter().take(3) {
|
||||
for stream in &title.streams {
|
||||
if let Stream::Video(v) = stream {
|
||||
if v.resolution.contains("2160") {
|
||||
if v.resolution.is_uhd() {
|
||||
return DiscFormat::Uhd;
|
||||
}
|
||||
if v.resolution.contains("1080") || v.resolution.contains("720") {
|
||||
if v.resolution.is_hd() {
|
||||
return DiscFormat::BluRay;
|
||||
}
|
||||
if v.resolution.contains("480") || v.resolution.contains("576") {
|
||||
if v.resolution.is_sd() {
|
||||
return DiscFormat::Dvd;
|
||||
}
|
||||
}
|
||||
@@ -685,8 +1160,7 @@ impl Disc {
|
||||
/// - At minimum batch + still failing: retries once, then skips + zero-fills
|
||||
pub struct ContentReader<'a> {
|
||||
session: &'a mut Drive,
|
||||
aacs: Option<&'a AacsState>,
|
||||
css: Option<&'a crate::css::CssState>,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
extents: Vec<Extent>,
|
||||
current_extent: usize,
|
||||
current_offset: u32,
|
||||
@@ -709,6 +1183,23 @@ pub struct ContentReader<'a> {
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Get the resolved decryption keys for this disc.
|
||||
/// Used by disc-to-ISO and other full-disc operations.
|
||||
pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
|
||||
if let Some(ref aacs) = self.aacs {
|
||||
crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: aacs.unit_keys.clone(),
|
||||
read_data_key: aacs.read_data_key,
|
||||
}
|
||||
} else if let Some(ref css) = self.css {
|
||||
crate::decrypt::DecryptKeys::Css {
|
||||
title_key: css.title_key,
|
||||
}
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a title for reading. Decryption is automatic -- if the disc
|
||||
/// is encrypted and keys were found during scan(), content is decrypted
|
||||
/// on the fly. Unencrypted discs pass through unchanged.
|
||||
@@ -730,10 +1221,22 @@ impl Disc {
|
||||
// Detect kernel max transfer size for this device
|
||||
let max_batch = detect_max_batch_sectors(session.device_path());
|
||||
|
||||
let decrypt_keys = if let Some(ref aacs) = self.aacs {
|
||||
crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: aacs.unit_keys.clone(),
|
||||
read_data_key: aacs.read_data_key,
|
||||
}
|
||||
} else if let Some(ref css) = self.css {
|
||||
crate::decrypt::DecryptKeys::Css {
|
||||
title_key: css.title_key,
|
||||
}
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
|
||||
Ok(ContentReader {
|
||||
session,
|
||||
aacs: self.aacs.as_ref(),
|
||||
css: self.css.as_ref(),
|
||||
decrypt_keys,
|
||||
extents: title.extents.clone(),
|
||||
current_extent: 0,
|
||||
current_offset: 0,
|
||||
@@ -754,7 +1257,7 @@ impl Disc {
|
||||
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
|
||||
/// For sg devices, resolves the corresponding block device via sysfs.
|
||||
/// Returns a value aligned to 3 sectors (one aligned unit).
|
||||
pub(crate) fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
let dev_name = device_path.rsplit('/').next().unwrap_or("");
|
||||
if dev_name.is_empty() {
|
||||
return DEFAULT_BATCH_SECTORS;
|
||||
@@ -822,7 +1325,7 @@ impl<'a> ContentReader<'a> {
|
||||
let mut unit = self.read_buf[start..end].to_vec();
|
||||
|
||||
// Decrypt if needed
|
||||
self.decrypt_unit(&mut unit);
|
||||
self.decrypt_unit(&mut unit)?;
|
||||
self.buf_pos += 1;
|
||||
Ok(Some(unit))
|
||||
}
|
||||
@@ -838,63 +1341,25 @@ impl<'a> ContentReader<'a> {
|
||||
|
||||
// Decrypt all units in the buffer in-place
|
||||
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
|
||||
if let Some(aacs) = &self.aacs {
|
||||
// AACS unit decryption (BD/UHD)
|
||||
let uk = aacs
|
||||
.unit_keys
|
||||
.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.ok_or(Error::AacsDataKey)?;
|
||||
let rdk = aacs.read_data_key.as_ref();
|
||||
|
||||
for i in 0..self.buf_len {
|
||||
let start = i * unit_len;
|
||||
let end = start + unit_len;
|
||||
let unit = &mut self.read_buf[start..end];
|
||||
if crate::aacs::is_unit_encrypted(unit) {
|
||||
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
|
||||
}
|
||||
}
|
||||
|
||||
let total_bytes = self.buf_len * unit_len;
|
||||
self.buf_pos = self.buf_len;
|
||||
Ok(Some(&self.read_buf[..total_bytes]))
|
||||
} else if let Some(css) = &self.css {
|
||||
// CSS per-sector descrambling (DVD)
|
||||
let total_bytes = self.buf_len * unit_len;
|
||||
for chunk in self.read_buf[..total_bytes].chunks_mut(2048) {
|
||||
crate::css::lfsr::descramble_sector(&css.title_key, chunk);
|
||||
}
|
||||
|
||||
self.buf_pos = self.buf_len;
|
||||
Ok(Some(&self.read_buf[..total_bytes]))
|
||||
} else {
|
||||
// No encryption
|
||||
let total_bytes = self.buf_len * unit_len;
|
||||
self.buf_pos = self.buf_len;
|
||||
Ok(Some(&self.read_buf[..total_bytes]))
|
||||
}
|
||||
let total_bytes = self.buf_len * unit_len;
|
||||
crate::decrypt::decrypt_sectors(
|
||||
&mut self.read_buf[..total_bytes],
|
||||
&self.decrypt_keys,
|
||||
self.unit_key_idx,
|
||||
)?;
|
||||
self.buf_pos = self.buf_len;
|
||||
Ok(Some(&self.read_buf[..total_bytes]))
|
||||
}
|
||||
|
||||
/// Decrypt a single aligned unit in-place if needed.
|
||||
fn decrypt_unit(&self, unit: &mut [u8]) {
|
||||
if let Some(aacs) = &self.aacs {
|
||||
if crate::aacs::is_unit_encrypted(unit) {
|
||||
let uk = aacs
|
||||
.unit_keys
|
||||
.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.unwrap_or([0u8; 16]);
|
||||
|
||||
crate::aacs::decrypt_unit_full(unit, &uk, aacs.read_data_key.as_ref());
|
||||
}
|
||||
}
|
||||
fn decrypt_unit(&self, unit: &mut [u8]) -> Result<()> {
|
||||
crate::decrypt::decrypt_sectors(unit, &self.decrypt_keys, self.unit_key_idx)
|
||||
}
|
||||
|
||||
/// Read sectors via standard READ(10) 0x00.
|
||||
/// calibration primers. Standard reads are faster on most drives.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<()> {
|
||||
self.session.read_content(lba, count, &mut self.read_buf)?;
|
||||
self.session.read(lba, count, &mut self.read_buf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1014,60 +1479,14 @@ impl<'a> ContentReader<'a> {
|
||||
|
||||
// ─── Format helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn format_resolution(video_format: u8, _video_rate: u8) -> String {
|
||||
match video_format {
|
||||
1 => "480i".into(),
|
||||
2 => "576i".into(),
|
||||
3 => "480p".into(),
|
||||
4 => "1080i".into(),
|
||||
5 => "720p".into(),
|
||||
6 => "1080p".into(),
|
||||
7 => "576p".into(),
|
||||
8 => "2160p".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_framerate(video_rate: u8) -> String {
|
||||
match video_rate {
|
||||
1 => "23.976".into(),
|
||||
2 => "24".into(),
|
||||
3 => "25".into(),
|
||||
4 => "29.97".into(),
|
||||
6 => "50".into(),
|
||||
7 => "59.94".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_channels(audio_format: u8) -> String {
|
||||
match audio_format {
|
||||
1 => "mono".into(),
|
||||
3 => "stereo".into(),
|
||||
6 => "5.1".into(),
|
||||
12 => "7.1".into(),
|
||||
_ if audio_format > 0 => format!("{audio_format}ch"),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_samplerate(audio_rate: u8) -> String {
|
||||
match audio_rate {
|
||||
1 => "48kHz".into(),
|
||||
4 => "96kHz".into(),
|
||||
5 => "192kHz".into(),
|
||||
12 => "48/192kHz".into(),
|
||||
14 => "48/96kHz".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: build a DiscTitle with a single video stream at the given resolution.
|
||||
fn title_with_video(codec: Codec, resolution: &str) -> DiscTitle {
|
||||
fn title_with_video(codec: Codec, resolution: Resolution) -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: "00800.mpls".into(),
|
||||
playlist_id: 800,
|
||||
@@ -1077,8 +1496,8 @@ mod tests {
|
||||
streams: vec![Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec,
|
||||
resolution: resolution.into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -1087,24 +1506,25 @@ mod tests {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_uhd() {
|
||||
let titles = vec![title_with_video(Codec::Hevc, "2160p")];
|
||||
let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
|
||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Uhd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_bluray() {
|
||||
let titles = vec![title_with_video(Codec::H264, "1080p")];
|
||||
let titles = vec![title_with_video(Codec::H264, Resolution::R1080p)];
|
||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::BluRay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_dvd() {
|
||||
let titles = vec![title_with_video(Codec::Mpeg2, "480i")];
|
||||
let titles = vec![title_with_video(Codec::Mpeg2, Resolution::R480i)];
|
||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Dvd);
|
||||
}
|
||||
|
||||
@@ -1116,7 +1536,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn content_format_default_bdts() {
|
||||
let t = title_with_video(Codec::H264, "1080p");
|
||||
let t = title_with_video(Codec::H264, Resolution::R1080p);
|
||||
assert_eq!(t.content_format, ContentFormat::BdTs);
|
||||
}
|
||||
|
||||
@@ -1124,7 +1544,7 @@ mod tests {
|
||||
fn content_format_dvd_mpegps() {
|
||||
let t = DiscTitle {
|
||||
content_format: ContentFormat::MpegPs,
|
||||
..title_with_video(Codec::Mpeg2, "480i")
|
||||
..title_with_video(Codec::Mpeg2, Resolution::R480i)
|
||||
};
|
||||
assert_eq!(t.content_format, ContentFormat::MpegPs);
|
||||
}
|
||||
|
||||
+24
-12
@@ -67,8 +67,8 @@ pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
|
||||
}
|
||||
|
||||
// Vendor-specific READ_BUFFER queries
|
||||
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
|
||||
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
|
||||
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
|
||||
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
|
||||
|
||||
// Standard queries
|
||||
let rpc_state = session.report_key_rpc_state();
|
||||
@@ -87,18 +87,30 @@ pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
|
||||
|
||||
/// Mask a string for privacy (letters->A, digits->0).
|
||||
pub fn mask_string(s: &str) -> String {
|
||||
s.chars().map(|c| {
|
||||
if c.is_ascii_alphabetic() { 'A' }
|
||||
else if c.is_ascii_digit() { '0' }
|
||||
else { c }
|
||||
}).collect()
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphabetic() {
|
||||
'A'
|
||||
} else if c.is_ascii_digit() {
|
||||
'0'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mask bytes for privacy.
|
||||
pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
|
||||
data.iter().map(|&b| {
|
||||
if b.is_ascii_alphabetic() { b'A' }
|
||||
else if b.is_ascii_digit() { b'0' }
|
||||
else { b }
|
||||
}).collect()
|
||||
data.iter()
|
||||
.map(|&b| {
|
||||
if b.is_ascii_alphabetic() {
|
||||
b'A'
|
||||
} else if b.is_ascii_digit() {
|
||||
b'0'
|
||||
} else {
|
||||
b
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+8
-3
@@ -10,6 +10,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
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(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
@@ -21,6 +26,7 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
drives
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
if path.contains("/sg") {
|
||||
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.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning = format!(
|
||||
"{path} is a block device (sr) — using {sg_path} (sg) for raw access"
|
||||
);
|
||||
let warning =
|
||||
format!("{path} is a block device (sr) — using {sg_path} (sg) for raw access");
|
||||
return Ok((sg_path, Some(warning)));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
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)) {
|
||||
Ok(mut transport) => {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
|
||||
+236
-69
@@ -39,6 +39,17 @@ pub enum DriveStatus {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// SCSI opcodes used in drive control
|
||||
const SCSI_TEST_UNIT_READY: u8 = 0x00;
|
||||
const SCSI_START_STOP_UNIT: u8 = 0x1B;
|
||||
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
|
||||
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
||||
const SCSI_MODE_SENSE: u8 = 0x5A;
|
||||
const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
|
||||
/// Recovery state after a read error — stay at min speed for N bytes.
|
||||
const RECOVERY_WINDOW: u64 = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||
pub struct Drive {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
@@ -47,6 +58,9 @@ pub struct Drive {
|
||||
pub platform: Option<profile::Platform>,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
/// Bytes remaining in the min-speed recovery window.
|
||||
/// After a read error, we stay at min speed for RECOVERY_WINDOW bytes.
|
||||
recovery_bytes_remaining: u64,
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
@@ -72,16 +86,33 @@ impl Drive {
|
||||
profile,
|
||||
drive_id,
|
||||
device_path: device.to_string_lossy().to_string(),
|
||||
recovery_bytes_remaining: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the drive cleanly. Unlocks tray, flushes SCSI state, closes fd.
|
||||
/// Also runs automatically on Drop as a safety net.
|
||||
pub fn close(self) {
|
||||
// cleanup() runs here via Drop
|
||||
}
|
||||
|
||||
/// Shared cleanup — called by Drop (and thus by close).
|
||||
fn cleanup(&mut self) {
|
||||
self.unlock_tray();
|
||||
}
|
||||
|
||||
// NOTE: Debug aid — remove after fd issue is resolved
|
||||
pub fn device_path_owned(&self) -> String {
|
||||
self.device_path.clone()
|
||||
}
|
||||
|
||||
/// Whether this drive has a known profile (unlock parameters available).
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
}
|
||||
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut tried_reset = false;
|
||||
|
||||
for _ in 0..60 {
|
||||
@@ -119,7 +150,7 @@ impl Drive {
|
||||
/// Uses GET EVENT STATUS NOTIFICATION which works regardless of firmware state.
|
||||
pub fn drive_status(&mut self) -> DriveStatus {
|
||||
// GET EVENT STATUS NOTIFICATION: polled, media event class (0x10)
|
||||
let cdb = [0x4Au8, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x08, 0x00];
|
||||
let cdb = [SCSI_GET_EVENT_STATUS, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x08, 0x00];
|
||||
let mut buf = [0u8; 8];
|
||||
match self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
@@ -132,8 +163,8 @@ impl Drive {
|
||||
// Bits 1-0: door/tray state
|
||||
// Bit 1: media present, Bit 0: tray open
|
||||
match media_status & 0x03 {
|
||||
0x00 => DriveStatus::NoDisc, // tray closed, no disc
|
||||
0x01 => DriveStatus::TrayOpen, // tray open
|
||||
0x00 => DriveStatus::NoDisc, // tray closed, no disc
|
||||
0x01 => DriveStatus::TrayOpen, // tray open
|
||||
0x02 => DriveStatus::DiscPresent, // tray closed, disc present
|
||||
0x03 => DriveStatus::DiscPresent, // tray closed, disc present
|
||||
_ => DriveStatus::Unknown,
|
||||
@@ -141,7 +172,7 @@ impl Drive {
|
||||
}
|
||||
_ => {
|
||||
// Fallback: try TUR
|
||||
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut empty = [0u8; 0];
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur,
|
||||
@@ -170,24 +201,29 @@ impl Drive {
|
||||
/// any step, even if the drive reports "tray open" (that's a valid state).
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
let mut buf = [0u8; 0];
|
||||
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
// 1. Unlock + stop/start
|
||||
self.unlock_tray();
|
||||
let stop = [0x1Bu8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&stop, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
let stop = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&stop, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let start = [0x1Bu8, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&start, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
let start = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&start, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
if self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
).is_ok() {
|
||||
if self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -195,15 +231,18 @@ impl Drive {
|
||||
// After eject, TUR returning "Not Ready — tray open" (sense key 2)
|
||||
// counts as success: the drive is functional, just needs disc reinserted.
|
||||
self.unlock_tray();
|
||||
let eject = [0x1Bu8, 0x00, 0x00, 0x00, 0x02, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&eject, crate::scsi::DataDirection::None, &mut buf, 30_000,
|
||||
);
|
||||
let eject = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x02, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&eject, crate::scsi::DataDirection::None, &mut buf, 30_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
) {
|
||||
match self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
{
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), // tray open = valid
|
||||
_ => {}
|
||||
@@ -214,7 +253,10 @@ impl Drive {
|
||||
self.init()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
&tur,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()),
|
||||
@@ -269,13 +311,28 @@ impl Drive {
|
||||
/// Returns the feature data (without the 8-byte header), or None if not available.
|
||||
pub fn get_config_feature(&mut self, feature_code: u16) -> Option<Vec<u8>> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_GET_CONFIGURATION, 0x02,
|
||||
(feature_code >> 8) as u8, feature_code as u8,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
|
||||
crate::scsi::SCSI_GET_CONFIGURATION,
|
||||
0x02,
|
||||
(feature_code >> 8) as u8,
|
||||
feature_code as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 256];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
let r = self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.ok()?;
|
||||
if r.bytes_transferred > 8 {
|
||||
Some(buf[8..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
@@ -285,29 +342,67 @@ impl Drive {
|
||||
|
||||
/// Read REPORT KEY RPC state (region playback control).
|
||||
pub fn report_key_rpc_state(&mut self) -> Option<Vec<u8>> {
|
||||
let cdb = [0xA4u8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00];
|
||||
let cdb = [
|
||||
SCSI_REPORT_KEY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 8];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
let r = self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.ok()?;
|
||||
if r.bytes_transferred > 0 {
|
||||
Some(buf[..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Read MODE SENSE page data.
|
||||
pub fn mode_sense_page(&mut self, page: u8) -> Option<Vec<u8>> {
|
||||
let cdb = [0x5Au8, 0x00, page, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00];
|
||||
let cdb = [SCSI_MODE_SENSE, 0x00, page, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00];
|
||||
let mut buf = vec![0u8; 252];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
let r = self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.ok()?;
|
||||
if r.bytes_transferred > 0 {
|
||||
Some(buf[..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Read vendor-specific READ BUFFER data.
|
||||
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
|
||||
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
let r = self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.ok()?;
|
||||
if r.bytes_transferred > 0 {
|
||||
Some(buf[..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
@@ -317,7 +412,16 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
/// Read sectors from the disc with automatic error recovery.
|
||||
///
|
||||
/// On failure: drops to min speed, waits with escalating patience
|
||||
/// (5s, 10s, 15s, 30s, 60s), resets drive between attempts.
|
||||
/// After recovery, stays at min speed for 500 MB before ramping up.
|
||||
///
|
||||
/// Returns Err only after all attempts exhausted — user should clean
|
||||
/// the disc and resume.
|
||||
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let timeout_ms = if self.recovery_bytes_remaining > 0 { 30_000 } else { 10_000 };
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10,
|
||||
0x00,
|
||||
@@ -330,33 +434,86 @@ impl Drive {
|
||||
count as u8,
|
||||
0x00,
|
||||
];
|
||||
let result =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
|
||||
// Normal read
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, timeout_ms,
|
||||
) {
|
||||
if self.recovery_bytes_remaining > 0 {
|
||||
let bytes_read = count as u64 * 2048;
|
||||
self.recovery_bytes_remaining =
|
||||
self.recovery_bytes_remaining.saturating_sub(bytes_read);
|
||||
if self.recovery_bytes_remaining == 0 {
|
||||
self.set_speed(0xFFFF);
|
||||
}
|
||||
}
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
|
||||
// Phase 1: gentle — sleep 30s, retry. 5 times.
|
||||
self.set_speed(0);
|
||||
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000,
|
||||
) {
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: fresh start — close, reset, open, init.
|
||||
let device = std::path::PathBuf::from(&self.device_path);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
let _ = crate::scsi::reset(&device);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
self.scsi = crate::scsi::open(&device)?;
|
||||
let _ = self.init();
|
||||
let _ = self.wait_ready();
|
||||
self.set_speed(0);
|
||||
|
||||
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000,
|
||||
) {
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
}
|
||||
|
||||
// Both phases failed.
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
Err(Error::DiscRead { sector: lba as u64 })
|
||||
}
|
||||
|
||||
pub fn read_content(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
/// Read the disc capacity in sectors (2048 bytes each).
|
||||
pub fn read_capacity(&mut self) -> Result<u32> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10,
|
||||
crate::scsi::SCSI_READ_CAPACITY,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
(lba >> 24) as u8,
|
||||
(lba >> 16) as u8,
|
||||
(lba >> 8) as u8,
|
||||
lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8,
|
||||
count as u8,
|
||||
0x00,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
let mut buf = [0u8; 8];
|
||||
self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
30_000,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)?;
|
||||
Ok(result.bytes_transferred)
|
||||
let last_lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
Ok(last_lba + 1)
|
||||
}
|
||||
|
||||
pub fn set_speed(&mut self, speed_kbs: u16) {
|
||||
@@ -367,26 +524,28 @@ impl Drive {
|
||||
|
||||
/// Lock the tray so the disc cannot be ejected during a rip.
|
||||
pub fn lock_tray(&mut self) {
|
||||
let prevent = [0x1Eu8, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let prevent = [SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
}
|
||||
|
||||
/// Unlock the tray so the user can manually eject the disc.
|
||||
pub fn unlock_tray(&mut self) {
|
||||
let allow = [0x1Eu8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let allow = [SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&allow, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&allow, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
}
|
||||
|
||||
/// Eject the disc tray. Unlocks first, then ejects.
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
self.unlock_tray();
|
||||
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
|
||||
let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
self.scsi.as_mut().execute(
|
||||
&eject_cdb,
|
||||
@@ -408,9 +567,16 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Drive {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
// SgIoTransport::drop() runs next, calling libc::close(fd)
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for Drive {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.read_disc(lba, count, buf)
|
||||
self.read(lba, count, buf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +612,7 @@ fn discover_drives() -> Vec<(String, DriveId)> {
|
||||
}
|
||||
|
||||
/// Resolve a device path to its raw SCSI device, with optional warning message.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
+17
-38
@@ -55,6 +55,7 @@ pub const E_AACS_VID_READ: u16 = 7009;
|
||||
pub const E_AACS_VID_MAC: u16 = 7010;
|
||||
pub const E_AACS_DATA_KEY: u16 = 7011;
|
||||
pub const E_AACS_VUK_DERIVE: u16 = 7012;
|
||||
pub const E_DECRYPT_FAILED: u16 = 7013;
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
pub const E_KEYDB_HTTP: u16 = 8001;
|
||||
@@ -72,13 +73,9 @@ pub const E_MUX_WRITE: u16 = 9001;
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// Device not found at the given path.
|
||||
DeviceNotFound {
|
||||
path: String,
|
||||
},
|
||||
DeviceNotFound { path: String },
|
||||
/// Insufficient permissions to open the device.
|
||||
DevicePermission {
|
||||
path: String,
|
||||
},
|
||||
DevicePermission { path: String },
|
||||
|
||||
/// Drive model is not in the profile database.
|
||||
UnsupportedDrive {
|
||||
@@ -98,10 +95,7 @@ pub enum Error {
|
||||
/// Drive unlock (firmware upload) failed.
|
||||
UnlockFailed,
|
||||
/// Firmware signature verification failed.
|
||||
SignatureMismatch {
|
||||
expected: [u8; 4],
|
||||
got: [u8; 4],
|
||||
},
|
||||
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
||||
/// Operation requires an unlocked drive.
|
||||
NotUnlocked,
|
||||
/// Operation requires a calibrated drive.
|
||||
@@ -114,36 +108,25 @@ pub enum Error {
|
||||
sense_key: u8,
|
||||
},
|
||||
/// SCSI command timed out.
|
||||
ScsiTimeout {
|
||||
opcode: u8,
|
||||
},
|
||||
ScsiTimeout { opcode: u8 },
|
||||
|
||||
/// Underlying I/O error.
|
||||
IoError {
|
||||
source: std::io::Error,
|
||||
},
|
||||
IoError { source: std::io::Error },
|
||||
/// Write operation failed.
|
||||
WriteError,
|
||||
|
||||
/// Failed to read disc sector.
|
||||
DiscRead {
|
||||
sector: u64,
|
||||
},
|
||||
DiscRead { sector: u64 },
|
||||
/// MPLS playlist parsing failed.
|
||||
MplsParse,
|
||||
/// CLPI clip info parsing failed.
|
||||
ClpiParse,
|
||||
/// File not found on the UDF filesystem.
|
||||
UdfNotFound {
|
||||
path: String,
|
||||
},
|
||||
UdfNotFound { path: String },
|
||||
/// Disc contains no playable titles.
|
||||
DiscNoTitles,
|
||||
/// Title index out of range.
|
||||
DiscTitleRange {
|
||||
index: usize,
|
||||
count: usize,
|
||||
},
|
||||
DiscTitleRange { index: usize, count: usize },
|
||||
/// Title has no sector extents to read.
|
||||
DiscNoExtents,
|
||||
/// DVD IFO file parsing failed.
|
||||
@@ -175,27 +158,21 @@ pub enum Error {
|
||||
AacsDataKey,
|
||||
/// Failed to derive the Volume Unique Key.
|
||||
AacsVukDerive,
|
||||
/// Decryption failed — missing or invalid keys.
|
||||
DecryptFailed { reason: String },
|
||||
|
||||
/// Failed to connect to the KEYDB server.
|
||||
KeydbConnect {
|
||||
host: String,
|
||||
},
|
||||
KeydbConnect { host: String },
|
||||
/// KEYDB server returned an HTTP error.
|
||||
KeydbHttp {
|
||||
status: u16,
|
||||
},
|
||||
KeydbHttp { status: u16 },
|
||||
/// Downloaded KEYDB file is invalid (no entries found).
|
||||
KeydbInvalid,
|
||||
/// Failed to write KEYDB to disk.
|
||||
KeydbWrite {
|
||||
path: String,
|
||||
},
|
||||
KeydbWrite { path: String },
|
||||
/// Failed to parse KEYDB file.
|
||||
KeydbParse,
|
||||
/// Failed to load KEYDB from disk.
|
||||
KeydbLoad {
|
||||
path: String,
|
||||
},
|
||||
KeydbLoad { path: String },
|
||||
|
||||
/// Lookahead buffer exhausted before codec headers found.
|
||||
MuxLookahead,
|
||||
@@ -240,6 +217,7 @@ impl Error {
|
||||
Error::AacsVidMac => E_AACS_VID_MAC,
|
||||
Error::AacsDataKey => E_AACS_DATA_KEY,
|
||||
Error::AacsVukDerive => E_AACS_VUK_DERIVE,
|
||||
Error::DecryptFailed { .. } => E_DECRYPT_FAILED,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
@@ -318,6 +296,7 @@ impl std::fmt::Display for Error {
|
||||
Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
|
||||
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::KeydbLoad { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DecryptFailed { reason } => write!(f, "E{}: {}", self.code(), reason),
|
||||
// Simple codes — no extra data
|
||||
_ => write!(f, "E{}", self.code()),
|
||||
}
|
||||
|
||||
@@ -25,6 +25,25 @@ pub struct Event {
|
||||
/// Types of events the lib can fire.
|
||||
#[derive(Debug)]
|
||||
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.
|
||||
BytesRead {
|
||||
/// Bytes written so far.
|
||||
|
||||
+26
-51
@@ -7,6 +7,7 @@
|
||||
//! The parser reads IFO files via UDF and extracts enough information
|
||||
//! to build DiscTitle structs (parallel to MPLS for Blu-ray).
|
||||
|
||||
use crate::disc::{Codec, Resolution};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
@@ -61,8 +62,8 @@ pub struct DvdCell {
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct DvdVideoAttr {
|
||||
pub codec: String,
|
||||
pub resolution: String,
|
||||
pub codec: Codec,
|
||||
pub resolution: Resolution,
|
||||
pub aspect: String,
|
||||
pub standard: String,
|
||||
}
|
||||
@@ -70,7 +71,7 @@ pub struct DvdVideoAttr {
|
||||
/// DVD audio stream attributes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DvdAudioAttr {
|
||||
pub codec: String,
|
||||
pub codec: Codec,
|
||||
pub channels: u8,
|
||||
pub sample_rate: u32,
|
||||
pub language: String,
|
||||
@@ -349,41 +350,15 @@ fn parse_video_attr(data: &[u8]) -> Result<DvdVideoAttr> {
|
||||
_ => "4:3",
|
||||
};
|
||||
|
||||
let resolution = match (b0 >> 4) & 0x03 {
|
||||
0 => {
|
||||
if standard == "PAL" {
|
||||
"720x576"
|
||||
} else {
|
||||
"720x480"
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
if standard == "PAL" {
|
||||
"704x576"
|
||||
} else {
|
||||
"704x480"
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if standard == "PAL" {
|
||||
"352x576"
|
||||
} else {
|
||||
"352x480"
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if standard == "PAL" {
|
||||
"352x288"
|
||||
} else {
|
||||
"352x240"
|
||||
}
|
||||
}
|
||||
_ => "720x480",
|
||||
let resolution = if standard == "PAL" {
|
||||
Resolution::R576i
|
||||
} else {
|
||||
Resolution::R480i
|
||||
};
|
||||
|
||||
Ok(DvdVideoAttr {
|
||||
codec: "mpeg2".to_string(),
|
||||
resolution: resolution.to_string(),
|
||||
codec: Codec::Mpeg2,
|
||||
resolution,
|
||||
aspect: aspect.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 codec = match coding_mode {
|
||||
0 => "ac3",
|
||||
2 => "mpeg1",
|
||||
3 => "mpeg2",
|
||||
4 => "lpcm",
|
||||
6 => "dts",
|
||||
_ => "unknown",
|
||||
0 => Codec::Ac3,
|
||||
2 => Codec::Mpeg1,
|
||||
3 => Codec::Mp2,
|
||||
4 => Codec::Lpcm,
|
||||
6 => Codec::Dts,
|
||||
_ => Codec::Unknown(coding_mode),
|
||||
};
|
||||
|
||||
let sample_rate_flag = (b0 >> 3) & 0x03;
|
||||
@@ -434,7 +409,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
|
||||
};
|
||||
|
||||
Ok(DvdAudioAttr {
|
||||
codec: codec.to_string(),
|
||||
codec,
|
||||
channels,
|
||||
sample_rate,
|
||||
language,
|
||||
@@ -688,15 +663,15 @@ mod tests {
|
||||
assert_eq!(title.cells.len(), 1);
|
||||
|
||||
let video = DvdVideoAttr {
|
||||
codec: "mpeg2".to_string(),
|
||||
resolution: "720x480".to_string(),
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R480i,
|
||||
aspect: "16:9".to_string(),
|
||||
standard: "NTSC".to_string(),
|
||||
};
|
||||
assert_eq!(video.codec, "mpeg2");
|
||||
assert_eq!(video.codec, Codec::Mpeg2);
|
||||
|
||||
let audio = DvdAudioAttr {
|
||||
codec: "ac3".to_string(),
|
||||
codec: Codec::Ac3,
|
||||
channels: 6,
|
||||
sample_rate: 48000,
|
||||
language: "en".to_string(),
|
||||
@@ -730,8 +705,8 @@ mod tests {
|
||||
let attr = parse_video_attr(&data).unwrap();
|
||||
assert_eq!(attr.standard, "NTSC");
|
||||
assert_eq!(attr.aspect, "16:9");
|
||||
assert_eq!(attr.resolution, "720x480");
|
||||
assert_eq!(attr.codec, "mpeg2");
|
||||
assert_eq!(attr.resolution, Resolution::R480i);
|
||||
assert_eq!(attr.codec, Codec::Mpeg2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -743,7 +718,7 @@ mod tests {
|
||||
let attr = parse_video_attr(&data).unwrap();
|
||||
assert_eq!(attr.standard, "PAL");
|
||||
assert_eq!(attr.aspect, "4:3");
|
||||
assert_eq!(attr.resolution, "720x576");
|
||||
assert_eq!(attr.resolution, Resolution::R576i);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -759,7 +734,7 @@ mod tests {
|
||||
data[3] = b'n';
|
||||
|
||||
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.channels, 6);
|
||||
assert_eq!(attr.language, "en");
|
||||
@@ -777,7 +752,7 @@ mod tests {
|
||||
data[3] = b'r';
|
||||
|
||||
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.channels, 2);
|
||||
assert_eq!(attr.language, "fr");
|
||||
|
||||
+11
-7
@@ -68,6 +68,7 @@
|
||||
pub mod aacs;
|
||||
pub(crate) mod clpi;
|
||||
pub mod css;
|
||||
pub mod decrypt;
|
||||
pub mod disc;
|
||||
pub mod drive;
|
||||
pub mod error;
|
||||
@@ -78,26 +79,29 @@ pub mod keydb;
|
||||
pub(crate) mod labels;
|
||||
pub(crate) mod mpls;
|
||||
pub mod mux;
|
||||
pub mod pes;
|
||||
pub(crate) mod platform;
|
||||
pub mod profile;
|
||||
pub mod scsi;
|
||||
pub(crate) mod sector;
|
||||
pub mod sector;
|
||||
pub(crate) mod speed;
|
||||
pub(crate) mod udf;
|
||||
|
||||
pub use drive::capture::{
|
||||
capture_drive_data, mask_bytes, mask_string, CapturedFeature, DriveCapture,
|
||||
};
|
||||
pub use drive::{find_drive, find_drives, Drive, DriveStatus};
|
||||
pub use drive::capture::{DriveCapture, CapturedFeature, capture_drive_data, mask_string, mask_bytes};
|
||||
pub use error::{Error, Result};
|
||||
pub use event::{Event, EventKind};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
|
||||
pub use decrypt::{decrypt_sectors, DecryptKeys};
|
||||
pub use disc::{
|
||||
AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc,
|
||||
DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream,
|
||||
VideoStream,
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat,
|
||||
Disc, DiscFormat, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
|
||||
ScanOptions, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
pub use mux::DiscOptions;
|
||||
pub use mux::DiscStream;
|
||||
pub use mux::IOStream;
|
||||
pub use mux::IsoStream;
|
||||
@@ -106,7 +110,7 @@ pub use mux::MkvStream;
|
||||
pub use mux::NetworkStream;
|
||||
pub use mux::NullStream;
|
||||
pub use mux::StdioStream;
|
||||
pub use mux::{open_input, open_output, parse_url, InputOptions, StreamUrl};
|
||||
pub use mux::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl};
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use sector::SectorReader;
|
||||
pub use speed::DriveSpeed;
|
||||
|
||||
@@ -99,7 +99,9 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
||||
/// bsid is at byte 5, bits 7..3.
|
||||
/// AC-3: bsid <= 10, E-AC-3: bsid >= 11 (typically 16).
|
||||
pub fn get_bsid(data: &[u8]) -> u8 {
|
||||
debug_assert!(data.len() >= 6);
|
||||
if data.len() < 6 {
|
||||
return 0;
|
||||
}
|
||||
(data[5] >> 3) & 0x1F
|
||||
}
|
||||
|
||||
@@ -107,7 +109,9 @@ pub fn get_bsid(data: &[u8]) -> u8 {
|
||||
/// frmsiz is at bits [2:0] of byte 2 concatenated with byte 3.
|
||||
/// Frame size = (frmsiz + 1) * 2 bytes.
|
||||
pub fn eac3_frame_size(data: &[u8]) -> usize {
|
||||
debug_assert!(data.len() >= 4);
|
||||
if data.len() < 4 {
|
||||
return 0;
|
||||
}
|
||||
let frmsiz = ((data[2] as usize & 0x07) << 8) | (data[3] as usize);
|
||||
(frmsiz + 1) * 2
|
||||
}
|
||||
|
||||
@@ -82,7 +82,9 @@ pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
|
||||
/// The size field is at bytes 6-8 of the extension:
|
||||
/// ((ext[6] & 0x1F) << 11) | (ext[7] << 3) | (ext[8] >> 5) + 1
|
||||
pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize {
|
||||
debug_assert!(ext.len() >= 9);
|
||||
if ext.len() < 9 {
|
||||
return 0;
|
||||
}
|
||||
let raw =
|
||||
((ext[6] as usize & 0x1F) << 11) | ((ext[7] as usize) << 3) | ((ext[8] as usize) >> 5);
|
||||
raw + 1
|
||||
|
||||
@@ -111,12 +111,12 @@ impl CodecParser for H264Parser {
|
||||
// pictureParameterSetNALUnit = pps
|
||||
|
||||
let mut record = vec![
|
||||
1, // configurationVersion
|
||||
sps[1], // profile
|
||||
sps[2], // compatibility
|
||||
sps[3], // level
|
||||
0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
|
||||
0xE1, // 3 bits reserved (111) + 5 bits numSPS (1)
|
||||
1, // configurationVersion
|
||||
sps[1], // profile
|
||||
sps[2], // compatibility
|
||||
sps[3], // level
|
||||
0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
|
||||
0xE1, // 3 bits reserved (111) + 5 bits numSPS (1)
|
||||
(sps.len() >> 8) as u8,
|
||||
sps.len() as u8,
|
||||
];
|
||||
@@ -180,6 +180,9 @@ pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
// Range excludes last 2 bytes since we read 3 bytes at each position.
|
||||
// data.len()-2 as exclusive upper bound means last checked index is data.len()-3,
|
||||
// which accesses data[len-3], data[len-2], data[len-1] — all valid.
|
||||
(from..data.len() - 2).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
|
||||
}
|
||||
|
||||
|
||||
+15
-37
@@ -52,8 +52,9 @@ impl CodecParser for HevcParser {
|
||||
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
let mut frame_data = Vec::new();
|
||||
|
||||
// Scan NAL units
|
||||
// Single-pass NAL scan: extract params, detect keyframes, build length-prefixed output
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(data, sc_pos) {
|
||||
@@ -68,45 +69,22 @@ impl CodecParser for HevcParser {
|
||||
let nal_type = (data[nal_start] >> 1) & 0x3F;
|
||||
|
||||
match nal_type {
|
||||
NAL_VPS => self.vps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_SPS => self.sps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_PPS => self.pps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_VPS => { self.vps = Some(data[nal_start..end].to_vec()); }
|
||||
NAL_SPS => { self.sps = Some(data[nal_start..end].to_vec()); }
|
||||
NAL_PPS => { self.pps = Some(data[nal_start..end].to_vec()); }
|
||||
NAL_AUD => {} // Skip access unit delimiters
|
||||
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
|
||||
keyframe = true;
|
||||
let nal = &data[nal_start..end];
|
||||
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
_ => {
|
||||
// All other NAL types (slices, SEI, DV RPU, etc.) pass through
|
||||
let nal = &data[nal_start..end];
|
||||
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B to length-prefixed NALUs.
|
||||
// Skip VPS/SPS/PPS/AUD — they're in codecPrivate.
|
||||
let mut frame_data = Vec::new();
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(&pes.data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(&pes.data, sc_pos) {
|
||||
let next = find_start_code(&pes.data, nal_start).unwrap_or(pes.data.len());
|
||||
let mut end = next;
|
||||
while end > nal_start && pes.data[end - 1] == 0x00 {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
if nal_start < pes.data.len() {
|
||||
let nal_type = (pes.data[nal_start] >> 1) & 0x3F;
|
||||
// Skip parameter sets and AUD
|
||||
if nal_type != NAL_VPS
|
||||
&& nal_type != NAL_SPS
|
||||
&& nal_type != NAL_PPS
|
||||
&& nal_type != NAL_AUD
|
||||
{
|
||||
let nal = &pes.data[nal_start..end];
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
|
||||
@@ -31,9 +31,11 @@ pub struct Frame {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Convert 90kHz PTS to nanoseconds.
|
||||
/// Convert 90kHz PTS to nanoseconds (round to nearest).
|
||||
pub fn pts_to_ns(pts: i64) -> i64 {
|
||||
pts * 100_000 / 9
|
||||
// pts * 1_000_000_000 / 90_000 = pts * 100_000 / 9
|
||||
// Add half-divisor for rounding: (pts * 100_000 + 4) / 9
|
||||
(pts * 100_000 + 4) / 9
|
||||
}
|
||||
|
||||
/// Trait for codec-specific elementary stream parsers.
|
||||
|
||||
@@ -172,7 +172,8 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> {
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
+417
-404
@@ -1,306 +1,469 @@
|
||||
//! DiscStream — read BD-TS data from an optical disc drive.
|
||||
//! DiscStream — read sectors from an optical disc drive.
|
||||
//!
|
||||
//! Read-only stream. Wraps Drive + Disc.
|
||||
//! Handles drive init, AACS decryption, and sector reading.
|
||||
//! `DiscStream::open()` does the full init sequence:
|
||||
//! drive open → wait_ready → init → probe_disc → scan
|
||||
//!
|
||||
//! Reading state (extent index, offset, batch size, error recovery) is stored
|
||||
//! directly on the struct so that successive `read()` calls advance through
|
||||
//! the disc instead of restarting from byte 0.
|
||||
//! Then reads title extents or full-disc sequentially.
|
||||
//! No decryption — that's a caller concern.
|
||||
|
||||
use super::IOStream;
|
||||
use crate::disc::{
|
||||
detect_max_batch_sectors, ContentFormat, Disc, DiscTitle, Extent, MIN_BATCH_SECTORS,
|
||||
RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER,
|
||||
detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions,
|
||||
};
|
||||
use crate::drive::Drive;
|
||||
use crate::error::Error;
|
||||
use crate::speed::DriveSpeed;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::event::{Event, EventKind};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// AACS decryption parameters needed at read time.
|
||||
/// Extracted from `AacsState` so we don't need `Clone` on the full struct.
|
||||
struct AacsDecrypt {
|
||||
unit_keys: Vec<(u32, [u8; 16])>,
|
||||
read_data_key: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
/// Options for opening a disc stream.
|
||||
#[derive(Default)]
|
||||
pub struct DiscOptions {
|
||||
/// Device path (e.g. "/dev/sg4"). None = auto-detect.
|
||||
pub device: Option<std::path::PathBuf>,
|
||||
/// KEYDB.cfg path. None = search standard locations.
|
||||
pub keydb_path: Option<std::path::PathBuf>,
|
||||
/// Which title to read (0-based). None = longest title.
|
||||
pub title_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Optical disc stream. Read-only — yields decrypted BD-TS bytes.
|
||||
/// Optical disc stream. Read-only — yields raw sector bytes.
|
||||
///
|
||||
/// Embeds the reading state that `ContentReader` would normally hold, so that
|
||||
/// successive `read()` calls advance through the disc correctly.
|
||||
/// Created from an initialized Drive + title extents or full-disc mode.
|
||||
/// Error recovery (batch reduction, retry, zero-fill) is handled internally.
|
||||
pub struct DiscStream {
|
||||
disc_title: DiscTitle,
|
||||
disc: Disc,
|
||||
session: Drive,
|
||||
// Read buffer: holds one decoded batch
|
||||
batch_buf: Vec<u8>,
|
||||
batch_pos: usize,
|
||||
eof: bool,
|
||||
drive: Drive,
|
||||
title: DiscTitle,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
|
||||
// ── Reading state (replaces ContentReader) ──
|
||||
extents: Vec<Extent>,
|
||||
// What to read
|
||||
mode: ReadMode,
|
||||
|
||||
// Position
|
||||
current_lba: u32,
|
||||
current_extent: usize,
|
||||
current_offset: u32,
|
||||
#[allow(dead_code)]
|
||||
content_format: ContentFormat,
|
||||
aacs: Option<AacsDecrypt>,
|
||||
css: Option<crate::css::CssState>,
|
||||
unit_key_idx: usize,
|
||||
|
||||
// Buffer
|
||||
read_buf: Vec<u8>,
|
||||
/// Current batch size in sectors (adapts on errors)
|
||||
buf_valid: usize,
|
||||
buf_cursor: usize,
|
||||
|
||||
// Batch size for reads
|
||||
batch_sectors: u16,
|
||||
/// Maximum batch size detected from kernel limits
|
||||
max_batch_sectors: u16,
|
||||
/// Consecutive successful batch reads
|
||||
ok_streak: u32,
|
||||
/// Consecutive errors at current position
|
||||
error_streak: u32,
|
||||
/// Total read errors encountered
|
||||
pub errors: u32,
|
||||
pub errors: u64,
|
||||
eof: bool,
|
||||
|
||||
// PES output
|
||||
ts_demuxer: Option<super::ts::TsDemuxer>,
|
||||
ps_demuxer: Option<super::ps::PsDemuxer>,
|
||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
}
|
||||
|
||||
enum ReadMode {
|
||||
/// Read title extents (for MKV, M2TS, etc.)
|
||||
Extents(Vec<Extent>),
|
||||
/// Read LBA 0 to capacity (for ISO)
|
||||
Sequential { capacity: u32 },
|
||||
}
|
||||
|
||||
/// Result of opening a DiscStream.
|
||||
pub struct DiscOpenResult {
|
||||
pub stream: DiscStream,
|
||||
pub disc: Disc,
|
||||
}
|
||||
|
||||
impl DiscStream {
|
||||
/// Open the disc drive and scan disc metadata.
|
||||
pub fn open(opts: DiscOptions) -> Result<Self, Error> {
|
||||
let mut session = match opts.device {
|
||||
Some(ref d) => Drive::open(d)?,
|
||||
/// Open a disc drive, init, scan, and prepare to read a title.
|
||||
///
|
||||
/// Steps (each does one thing):
|
||||
/// 1. Drive::open (or find_drive)
|
||||
/// 2. wait_ready
|
||||
/// 3. init (non-fatal)
|
||||
/// 4. probe_disc (non-fatal)
|
||||
/// 5. Disc::scan
|
||||
///
|
||||
/// Pass an event callback for status reporting, or None.
|
||||
pub fn open(
|
||||
device: Option<&Path>,
|
||||
keydb_path: Option<&str>,
|
||||
title_index: usize,
|
||||
on_event: Option<&dyn Fn(Event)>,
|
||||
) -> Result<DiscOpenResult> {
|
||||
let emit = |kind: EventKind| {
|
||||
if let Some(cb) = &on_event {
|
||||
cb(Event { kind });
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Open
|
||||
let mut drive = match device {
|
||||
Some(d) => Drive::open(d)?,
|
||||
None => crate::drive::find_drive().ok_or_else(|| Error::DeviceNotFound {
|
||||
path: String::new(),
|
||||
})?,
|
||||
};
|
||||
session.wait_ready()?;
|
||||
let _ = session.init();
|
||||
let _ = session.probe_disc();
|
||||
emit(EventKind::DriveOpened {
|
||||
device: drive.device_path().to_string(),
|
||||
});
|
||||
|
||||
let scan_opts = match opts.keydb_path {
|
||||
Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp.clone()),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
// 2. Wait
|
||||
let _ = drive.wait_ready();
|
||||
emit(EventKind::DriveReady);
|
||||
|
||||
// 3. Init
|
||||
let init_ok = drive.init().is_ok();
|
||||
emit(EventKind::InitComplete { success: init_ok });
|
||||
|
||||
// 4. Probe
|
||||
let probe_ok = drive.probe_disc().is_ok();
|
||||
emit(EventKind::ProbeComplete { success: probe_ok });
|
||||
|
||||
// 5. Scan
|
||||
let scan_opts = match keydb_path {
|
||||
Some(kp) => ScanOptions::with_keydb(kp),
|
||||
None => ScanOptions::default(),
|
||||
};
|
||||
let disc = Disc::scan(&mut session, &scan_opts)?;
|
||||
let disc = Disc::scan(&mut drive, &scan_opts)?;
|
||||
emit(EventKind::ScanComplete {
|
||||
titles: disc.titles.len(),
|
||||
});
|
||||
|
||||
let title_index = opts.title_index.unwrap_or(0);
|
||||
if title_index >= disc.titles.len() {
|
||||
return Err(Error::DiscTitleRange {
|
||||
index: title_index,
|
||||
count: disc.titles.len(),
|
||||
});
|
||||
}
|
||||
let disc_title = disc.titles[title_index].clone();
|
||||
let extents = disc_title.extents.clone();
|
||||
let content_format = disc_title.content_format;
|
||||
let aacs = disc.aacs.as_ref().map(|a| AacsDecrypt {
|
||||
unit_keys: a.unit_keys.clone(),
|
||||
read_data_key: a.read_data_key,
|
||||
});
|
||||
let css = disc.css.clone();
|
||||
|
||||
let max_batch = detect_max_batch_sectors(session.device_path());
|
||||
let title = disc.titles[title_index].clone();
|
||||
let keys = disc.decrypt_keys();
|
||||
let mut stream = Self::title(drive, title);
|
||||
stream.decrypt_keys = keys;
|
||||
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
disc,
|
||||
session,
|
||||
batch_buf: Vec::new(),
|
||||
batch_pos: 0,
|
||||
eof: false,
|
||||
extents,
|
||||
// DVD: use program stream demuxer instead of transport stream
|
||||
if disc.content_format == crate::disc::ContentFormat::MpegPs {
|
||||
stream.ts_demuxer = None;
|
||||
stream.ps_demuxer = Some(super::ps::PsDemuxer::new());
|
||||
}
|
||||
|
||||
Ok(DiscOpenResult { stream, disc })
|
||||
}
|
||||
|
||||
/// Create a stream that reads a title's extents.
|
||||
/// Use this when you already have an initialized Drive.
|
||||
pub fn title(drive: Drive, title: DiscTitle) -> Self {
|
||||
let max_batch = detect_max_batch_sectors(drive.device_path());
|
||||
let extents = title.extents.clone();
|
||||
Self::new(drive, title, ReadMode::Extents(extents), max_batch)
|
||||
}
|
||||
|
||||
/// Create a stream that reads the full disc sequentially (for ISO).
|
||||
pub fn full_disc(drive: Drive, title: DiscTitle, capacity: u32) -> Self {
|
||||
let max_batch = detect_max_batch_sectors(drive.device_path());
|
||||
Self::new(drive, title, ReadMode::Sequential { capacity }, max_batch)
|
||||
}
|
||||
|
||||
/// Resume a full disc read from a given LBA (for ISO resume).
|
||||
/// Use after checking an existing partial file:
|
||||
/// start_lba = (file_size / 2048) - safety_margin
|
||||
pub fn full_disc_resume(drive: Drive, title: DiscTitle, capacity: u32, start_lba: u32) -> Self {
|
||||
let max_batch = detect_max_batch_sectors(drive.device_path());
|
||||
let mut stream = Self::new(drive, title, ReadMode::Sequential { capacity }, max_batch);
|
||||
stream.current_lba = start_lba;
|
||||
stream
|
||||
}
|
||||
|
||||
/// Set SCSI read timeout (default 30s).
|
||||
fn new(drive: Drive, title: DiscTitle, mode: ReadMode, max_batch: u16) -> Self {
|
||||
// Set up PES demux from title stream PIDs
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (idx, s) in title.streams.iter().enumerate() {
|
||||
let (pid, codec) = match s {
|
||||
crate::disc::Stream::Video(v) => (v.pid, v.codec),
|
||||
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
|
||||
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, idx));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec)));
|
||||
}
|
||||
|
||||
Self {
|
||||
drive,
|
||||
title,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys::None,
|
||||
mode,
|
||||
current_lba: 0,
|
||||
current_extent: 0,
|
||||
current_offset: 0,
|
||||
content_format,
|
||||
aacs,
|
||||
css,
|
||||
unit_key_idx: 0,
|
||||
read_buf: Vec::with_capacity(max_batch as usize * 2048),
|
||||
buf_valid: 0,
|
||||
buf_cursor: 0,
|
||||
batch_sectors: max_batch,
|
||||
max_batch_sectors: max_batch,
|
||||
ok_streak: 0,
|
||||
error_streak: 0,
|
||||
errors: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the full Disc (for listing all titles, etc.)
|
||||
pub fn disc(&self) -> &Disc {
|
||||
&self.disc
|
||||
}
|
||||
|
||||
/// Read sectors from the drive into `self.read_buf`.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<(), Error> {
|
||||
self.session.read_content(lba, count, &mut self.read_buf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill the internal read buffer with the next batch of sectors,
|
||||
/// handling error recovery (halve batch, slow drive, retry, skip).
|
||||
///
|
||||
/// Returns `true` if data was read, `false` at end-of-title.
|
||||
fn fill_buffer(&mut self) -> Result<bool, Error> {
|
||||
loop {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let ext_start = self.extents[self.current_extent].start_lba;
|
||||
let ext_sectors = self.extents[self.current_extent].sector_count;
|
||||
let remaining = ext_sectors.saturating_sub(self.current_offset);
|
||||
|
||||
// Align to 3 sectors (one aligned unit)
|
||||
let sectors_to_read = remaining.min(self.batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let lba = ext_start + self.current_offset;
|
||||
let byte_count = sectors_to_read as usize * 2048;
|
||||
self.read_buf.resize(byte_count, 0);
|
||||
|
||||
match self.read_sectors(lba, sectors_to_read) {
|
||||
Ok(_) => {
|
||||
self.current_offset += sectors_to_read as u32;
|
||||
self.error_streak = 0;
|
||||
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
|
||||
// Ramp up batch size after consecutive successes
|
||||
self.ok_streak += 1;
|
||||
if self.batch_sectors < self.max_batch_sectors
|
||||
&& self.ok_streak >= RAMP_BATCH_AFTER
|
||||
{
|
||||
self.batch_sectors = (self.batch_sectors * 2).min(self.max_batch_sectors);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
|
||||
// Restore max speed after sustained success at full batch
|
||||
if self.batch_sectors == self.max_batch_sectors
|
||||
&& self.ok_streak >= RAMP_SPEED_AFTER
|
||||
{
|
||||
self.session.set_speed(0xFFFF);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
Err(_) => {
|
||||
self.errors += 1;
|
||||
self.error_streak += 1;
|
||||
self.ok_streak = 0;
|
||||
|
||||
// First error: re-init (drive may have re-locked)
|
||||
if self.error_streak == 1 {
|
||||
let _ = self.session.init();
|
||||
let _ = self.session.probe_disc();
|
||||
}
|
||||
|
||||
// Repeated errors: slow down
|
||||
if self.error_streak >= SLOW_SPEED_AFTER {
|
||||
self.session.set_speed(DriveSpeed::BD2x.to_kbps());
|
||||
self.error_streak = 0;
|
||||
}
|
||||
|
||||
if self.batch_sectors > MIN_BATCH_SECTORS {
|
||||
self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
} else {
|
||||
// At minimum batch -- retry once with longer pause
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
|
||||
if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() {
|
||||
self.error_streak = 0;
|
||||
self.current_offset += MIN_BATCH_SECTORS as u32;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// Still failing -- skip this unit (zero-fill)
|
||||
self.current_offset += 3;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
self.read_buf.resize(crate::aacs::ALIGNED_UNIT_LEN, 0);
|
||||
self.read_buf.fill(0);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
eof: false,
|
||||
ts_demuxer: if pids.is_empty() { None } else { Some(super::ts::TsDemuxer::new(&pids)) },
|
||||
ps_demuxer: None, // set by caller for DVD content
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt the contents of `self.read_buf` in-place and copy the
|
||||
/// decrypted data into `self.batch_buf`.
|
||||
fn decrypt_and_buffer(&mut self) {
|
||||
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
|
||||
let total_bytes = self.read_buf.len();
|
||||
|
||||
if let Some(ref aacs) = self.aacs {
|
||||
let uk = aacs
|
||||
.unit_keys
|
||||
.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.unwrap_or([0u8; 16]);
|
||||
let rdk = aacs.read_data_key.as_ref();
|
||||
|
||||
let num_units = total_bytes / unit_len;
|
||||
for i in 0..num_units {
|
||||
let start = i * unit_len;
|
||||
let end = start + unit_len;
|
||||
let unit = &mut self.read_buf[start..end];
|
||||
if crate::aacs::is_unit_encrypted(unit) {
|
||||
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
|
||||
}
|
||||
}
|
||||
} else if let Some(ref css) = self.css {
|
||||
for chunk in self.read_buf[..total_bytes].chunks_mut(2048) {
|
||||
crate::css::lfsr::descramble_sector(&css.title_key, chunk);
|
||||
}
|
||||
}
|
||||
// No encryption: read_buf is already plaintext
|
||||
|
||||
// Swap buffers instead of copying — the old batch_buf becomes
|
||||
// read_buf and will be overwritten on the next read.
|
||||
std::mem::swap(&mut self.batch_buf, &mut self.read_buf);
|
||||
self.batch_pos = 0;
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub fn set_raw(&mut self) {
|
||||
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
|
||||
}
|
||||
|
||||
/// Lock the tray.
|
||||
pub fn lock_tray(&mut self) {
|
||||
self.drive.lock_tray();
|
||||
}
|
||||
|
||||
/// Unlock the tray.
|
||||
pub fn unlock_tray(&mut self) {
|
||||
self.drive.unlock_tray();
|
||||
}
|
||||
|
||||
/// Recover the drive (for batch: switch to another title).
|
||||
pub fn into_drive(self) -> Drive {
|
||||
self.drive
|
||||
}
|
||||
|
||||
// ── Fill ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn fill(&mut self) -> bool {
|
||||
match &self.mode {
|
||||
ReadMode::Extents(_) => self.fill_extents(),
|
||||
ReadMode::Sequential { .. } => self.fill_sequential(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_extents(&mut self) -> bool {
|
||||
let (ext_start, ext_sectors) = match &self.mode {
|
||||
ReadMode::Extents(exts) => {
|
||||
if self.current_extent >= exts.len() {
|
||||
return false;
|
||||
}
|
||||
(
|
||||
exts[self.current_extent].start_lba,
|
||||
exts[self.current_extent].sector_count,
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let remaining = ext_sectors.saturating_sub(self.current_offset);
|
||||
let sectors = remaining.min(self.batch_sectors as u32) as u16;
|
||||
let sectors = sectors - (sectors % 3);
|
||||
if sectors == 0 {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
return self.fill_extents(); // next extent
|
||||
}
|
||||
|
||||
let lba = ext_start + self.current_offset;
|
||||
let bytes = sectors as usize * 2048;
|
||||
self.read_buf.resize(bytes, 0);
|
||||
|
||||
// Drive handles all error recovery internally.
|
||||
match self.drive.read(
|
||||
lba,
|
||||
sectors,
|
||||
&mut self.read_buf[..bytes],
|
||||
) {
|
||||
Ok(_) => {
|
||||
self.buf_valid = bytes;
|
||||
self.buf_cursor = 0;
|
||||
self.current_offset += sectors as u32;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(_) => false, // drive gone — EOF
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_sequential(&mut self) -> bool {
|
||||
let capacity = match &self.mode {
|
||||
ReadMode::Sequential { capacity } => *capacity,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if self.current_lba >= capacity {
|
||||
return false;
|
||||
}
|
||||
|
||||
let remaining = capacity - self.current_lba;
|
||||
let count = remaining.min(self.batch_sectors as u32) as u16;
|
||||
let bytes = count as usize * 2048;
|
||||
self.read_buf.resize(bytes, 0);
|
||||
|
||||
// Drive handles all error recovery internally —
|
||||
// retries, speed changes, zero-fill on unreadable sectors.
|
||||
match self.drive.read(
|
||||
self.current_lba,
|
||||
count,
|
||||
&mut self.read_buf[..bytes],
|
||||
) {
|
||||
Ok(_) => {
|
||||
self.buf_valid = bytes;
|
||||
self.buf_cursor = 0;
|
||||
self.current_lba += count as u32;
|
||||
true
|
||||
}
|
||||
Err(_) => false, // drive gone — EOF
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── IOStream ─────────────────────────────────────────────────────────────────
|
||||
|
||||
impl IOStream for DiscStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
&self.title
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.drive.unlock_tray();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn total_bytes(&self) -> Option<u64> {
|
||||
Some(self.disc_title.size_bytes)
|
||||
match &self.mode {
|
||||
ReadMode::Extents(extents) => {
|
||||
Some(extents.iter().map(|e| e.sector_count as u64 * 2048).sum())
|
||||
}
|
||||
ReadMode::Sequential { capacity } => Some(*capacity as u64 * 2048),
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(&self) -> crate::decrypt::DecryptKeys {
|
||||
self.decrypt_keys.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::pes::Stream for DiscStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
// Return buffered frame if available
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Read sectors until we produce at least one frame
|
||||
loop {
|
||||
// Fill the read buffer with next batch of sectors
|
||||
let got_data = match &self.mode {
|
||||
ReadMode::Extents(_) => self.fill_extents(),
|
||||
ReadMode::Sequential { .. } => self.fill_sequential(),
|
||||
};
|
||||
|
||||
if !got_data {
|
||||
self.eof = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
let bytes = self.buf_valid;
|
||||
if let Err(e) = crate::decrypt::decrypt_sectors(
|
||||
&mut self.read_buf[..bytes],
|
||||
&self.decrypt_keys,
|
||||
0,
|
||||
) {
|
||||
return Err(io::Error::other(e.to_string()));
|
||||
}
|
||||
|
||||
// Demux into packets, parse into frames
|
||||
if let Some(ref mut demuxer) = self.ts_demuxer {
|
||||
// BD: transport stream demux
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||
// DVD: program stream demux
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
for ps in &packets {
|
||||
// Map PS stream_id to track index
|
||||
let track = match ps.stream_id {
|
||||
0xE0..=0xEF => 0, // video
|
||||
0xC0..=0xDF => 1, // audio
|
||||
0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1),
|
||||
_ => continue,
|
||||
};
|
||||
if track < self.title.streams.len() {
|
||||
let pts_ns = ps.pts.map(|p| (p as i64) * 1_000_000_000 / 90_000).unwrap_or(0);
|
||||
self.pending_frames.push_back(crate::pes::PesFrame {
|
||||
track,
|
||||
pts: pts_ns,
|
||||
keyframe: true, // PS doesn't have keyframe flag easily
|
||||
data: ps.data.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset buffer for next read
|
||||
self.buf_valid = 0;
|
||||
self.buf_cursor = 0;
|
||||
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
// No frames produced — read more data
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
&self.title
|
||||
}
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self.pid_to_track.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
for (idx, s) in self.title.streams.iter().enumerate() {
|
||||
if let crate::disc::Stream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for DiscStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
// Drain buffer first
|
||||
if self.batch_pos < self.batch_buf.len() {
|
||||
let n = (self.batch_buf.len() - self.batch_pos).min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.batch_buf[self.batch_pos..self.batch_pos + n]);
|
||||
self.batch_pos += n;
|
||||
// Drain current buffer
|
||||
if self.buf_cursor < self.buf_valid {
|
||||
let n = (self.buf_valid - self.buf_cursor).min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.read_buf[self.buf_cursor..self.buf_cursor + n]);
|
||||
self.buf_cursor += n;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
@@ -308,24 +471,16 @@ impl Read for DiscStream {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Fill the read buffer with the next batch of sectors
|
||||
let has_data = self
|
||||
.fill_buffer()
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
|
||||
if !has_data {
|
||||
// Fill next batch
|
||||
if self.fill() {
|
||||
let n = self.buf_valid.min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.read_buf[..n]);
|
||||
self.buf_cursor = n;
|
||||
Ok(n)
|
||||
} else {
|
||||
self.eof = true;
|
||||
return Ok(0);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
// Decrypt in-place and move to batch_buf
|
||||
self.decrypt_and_buffer();
|
||||
|
||||
// Now drain into the caller's buffer
|
||||
let n = self.batch_buf.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.batch_buf[..n]);
|
||||
self.batch_pos = n;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,145 +495,3 @@ impl Write for DiscStream {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::disc::Extent;
|
||||
|
||||
/// Build a minimal DiscStream with fake extents for testing state advancement.
|
||||
/// We cannot call `DiscStream::open()` without a real drive, so we construct
|
||||
/// one manually and then call the internal `fill_buffer` / `read` path
|
||||
/// through a helper that simulates the session reads.
|
||||
///
|
||||
/// Instead we test the state-machine logic directly: given a set of extents
|
||||
/// and a current_extent/current_offset, verify that repeated reads advance
|
||||
/// through the extents correctly.
|
||||
#[test]
|
||||
fn state_advances_across_extents() {
|
||||
// Simulate two extents of 6 sectors each (2 aligned units each).
|
||||
let extents = [
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 6,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 200,
|
||||
sector_count: 6,
|
||||
},
|
||||
];
|
||||
|
||||
// Walk through the extents manually using the same arithmetic
|
||||
// that fill_buffer uses, and verify we visit every sector.
|
||||
let batch_sectors: u16 = 6;
|
||||
let mut current_extent: usize = 0;
|
||||
let mut current_offset: u32 = 0;
|
||||
let mut lbas_read = Vec::new();
|
||||
|
||||
while current_extent < extents.len() {
|
||||
let ext_start = extents[current_extent].start_lba;
|
||||
let ext_sectors = extents[current_extent].sector_count;
|
||||
let remaining = ext_sectors.saturating_sub(current_offset);
|
||||
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
let lba = ext_start + current_offset;
|
||||
lbas_read.push((lba, sectors_to_read));
|
||||
current_offset += sectors_to_read as u32;
|
||||
if current_offset >= ext_sectors {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(lbas_read.len(), 2, "should read two batches");
|
||||
assert_eq!(lbas_read[0], (100, 6), "first batch starts at LBA 100");
|
||||
assert_eq!(lbas_read[1], (200, 6), "second batch starts at LBA 200");
|
||||
}
|
||||
|
||||
/// Verify that small extents that are not aligned to 3 sectors are skipped
|
||||
/// (moved past) rather than causing an infinite loop.
|
||||
#[test]
|
||||
fn unaligned_extent_is_skipped() {
|
||||
let extents = [
|
||||
Extent {
|
||||
start_lba: 50,
|
||||
sector_count: 2, // < 3, cannot form an aligned unit
|
||||
},
|
||||
Extent {
|
||||
start_lba: 300,
|
||||
sector_count: 9,
|
||||
},
|
||||
];
|
||||
|
||||
let batch_sectors: u16 = 9;
|
||||
let mut current_extent: usize = 0;
|
||||
let mut current_offset: u32 = 0;
|
||||
let mut lbas_read = Vec::new();
|
||||
|
||||
while current_extent < extents.len() {
|
||||
let ext_start = extents[current_extent].start_lba;
|
||||
let ext_sectors = extents[current_extent].sector_count;
|
||||
let remaining = ext_sectors.saturating_sub(current_offset);
|
||||
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
let lba = ext_start + current_offset;
|
||||
lbas_read.push((lba, sectors_to_read));
|
||||
current_offset += sectors_to_read as u32;
|
||||
if current_offset >= ext_sectors {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(lbas_read.len(), 1, "only second extent is readable");
|
||||
assert_eq!(lbas_read[0], (300, 9));
|
||||
}
|
||||
|
||||
/// Verify that multiple reads from the same extent produce advancing offsets.
|
||||
#[test]
|
||||
fn multiple_batches_within_one_extent() {
|
||||
let extents = [Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 18, // 6 aligned units = 3 batches of 6 sectors
|
||||
}];
|
||||
|
||||
let batch_sectors: u16 = 6;
|
||||
let mut current_extent: usize = 0;
|
||||
let mut current_offset: u32 = 0;
|
||||
let mut lbas_read = Vec::new();
|
||||
|
||||
while current_extent < extents.len() {
|
||||
let ext_start = extents[current_extent].start_lba;
|
||||
let ext_sectors = extents[current_extent].sector_count;
|
||||
let remaining = ext_sectors.saturating_sub(current_offset);
|
||||
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
let lba = ext_start + current_offset;
|
||||
lbas_read.push((lba, sectors_to_read));
|
||||
current_offset += sectors_to_read as u32;
|
||||
if current_offset >= ext_sectors {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(lbas_read.len(), 3, "three batches from one extent");
|
||||
assert_eq!(lbas_read[0], (1000, 6));
|
||||
assert_eq!(lbas_read[1], (1006, 6));
|
||||
assert_eq!(lbas_read[2], (1012, 6));
|
||||
}
|
||||
}
|
||||
|
||||
+148
-10
@@ -9,6 +9,7 @@
|
||||
|
||||
use super::isowriter::IsoWriter;
|
||||
use super::IOStream;
|
||||
use crate::decrypt::{decrypt_sectors, DecryptKeys};
|
||||
use crate::disc::{Disc, DiscTitle, ScanOptions};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
@@ -32,7 +33,14 @@ impl IsoSectorReader {
|
||||
let file = File::open(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
|
||||
let size = file.metadata()?.len();
|
||||
let capacity = (size / SECTOR_SIZE) as u32;
|
||||
let sectors = size / SECTOR_SIZE;
|
||||
if sectors > u32::MAX as u64 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("iso://{path}: image too large ({} TB, max ~8 TB)", size / (1024 * 1024 * 1024 * 1024)),
|
||||
));
|
||||
}
|
||||
let capacity = sectors as u32;
|
||||
Ok(Self { file, capacity })
|
||||
}
|
||||
|
||||
@@ -71,9 +79,16 @@ pub struct IsoStream {
|
||||
buf_pos: usize,
|
||||
buf_len: usize,
|
||||
eof: bool,
|
||||
/// Decrypt on read — auto-detected from disc scan.
|
||||
decrypt_keys: DecryptKeys,
|
||||
// Write side
|
||||
iso_writer: Option<IsoWriter<io::BufWriter<File>>>,
|
||||
write_started: bool,
|
||||
// PES output (for InputStream impl)
|
||||
demuxer: Option<super::ts::TsDemuxer>,
|
||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
}
|
||||
|
||||
impl IsoStream {
|
||||
@@ -85,17 +100,26 @@ impl IsoStream {
|
||||
let disc = Disc::scan_image(&mut reader, capacity, opts)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
|
||||
let idx = title_index
|
||||
.unwrap_or(0)
|
||||
.min(disc.titles.len().saturating_sub(1));
|
||||
let disc_title = if disc.titles.is_empty() {
|
||||
if disc.titles.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"no titles found in ISO image",
|
||||
));
|
||||
} else {
|
||||
disc.titles[idx].clone()
|
||||
};
|
||||
}
|
||||
let idx = title_index.unwrap_or(0);
|
||||
if idx >= disc.titles.len() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"title {} out of range (disc has {})",
|
||||
idx + 1,
|
||||
disc.titles.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
let disc_title = disc.titles[idx].clone();
|
||||
|
||||
let decrypt_keys = disc.decrypt_keys();
|
||||
|
||||
let extents: Vec<(u32, u32)> = disc_title
|
||||
.extents
|
||||
@@ -104,6 +128,21 @@ impl IsoStream {
|
||||
.collect();
|
||||
let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0);
|
||||
|
||||
// Set up PES demux from title stream PIDs
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in disc_title.streams.iter().enumerate() {
|
||||
let (pid, codec) = match s {
|
||||
crate::disc::Stream::Video(v) => (v.pid, v.codec),
|
||||
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
|
||||
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec)));
|
||||
}
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title,
|
||||
disc: Some(disc),
|
||||
@@ -115,8 +154,13 @@ impl IsoStream {
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
decrypt_keys,
|
||||
iso_writer: None,
|
||||
write_started: false,
|
||||
demuxer: if pids.is_empty() { None } else { Some(super::ts::TsDemuxer::new(&pids)) },
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,6 +174,7 @@ impl IsoStream {
|
||||
Ok(IsoStream {
|
||||
disc_title: DiscTitle::empty(),
|
||||
disc: None,
|
||||
decrypt_keys: DecryptKeys::None,
|
||||
reader: None,
|
||||
extents: Vec::new(),
|
||||
extent_idx: 0,
|
||||
@@ -140,6 +185,10 @@ impl IsoStream {
|
||||
eof: false,
|
||||
iso_writer: Some(iso_writer),
|
||||
write_started: false,
|
||||
demuxer: None,
|
||||
parsers: Vec::new(),
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,8 +237,14 @@ impl IsoStream {
|
||||
reader
|
||||
.read_sectors(lba, count, &mut self.batch_buf)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
|
||||
// Decrypt after read — stream handles its own decryption
|
||||
let bytes = count as usize * SECTOR_SIZE as usize;
|
||||
decrypt_sectors(&mut self.batch_buf[..bytes], &self.decrypt_keys, 0)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
|
||||
self.buf_pos = 0;
|
||||
self.buf_len = count as usize * SECTOR_SIZE as usize;
|
||||
self.buf_len = bytes;
|
||||
|
||||
self.sectors_remaining -= count as u32;
|
||||
if self.sectors_remaining == 0 {
|
||||
@@ -201,6 +256,87 @@ impl IsoStream {
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub fn set_raw(&mut self) {
|
||||
self.decrypt_keys = DecryptKeys::None;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::pes::Stream for IsoStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
// Return buffered frame
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Read until we produce at least one frame
|
||||
loop {
|
||||
if !self.read_next_batch()? {
|
||||
self.eof = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Data in batch_buf is already decrypted by fill_next_batch
|
||||
if let Some(ref mut demuxer) = self.demuxer {
|
||||
let packets = demuxer.feed(&self.batch_buf[..self.buf_len]);
|
||||
for pes in &packets {
|
||||
if let Some((pid_idx, _)) = self.pid_to_track.iter().enumerate()
|
||||
.find(|(_, (pid, _))| *pid == pes.pid)
|
||||
{
|
||||
let track_idx = self.pid_to_track[pid_idx].1;
|
||||
if let Some((_, parser)) = self.parsers.iter_mut()
|
||||
.find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(track_idx, frame)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
&self.disc_title
|
||||
}
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self.pid_to_track.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
for (idx, s) in self.disc_title.streams.iter().enumerate() {
|
||||
if let crate::disc::Stream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for IsoStream {
|
||||
@@ -214,13 +350,15 @@ impl IOStream for IsoStream {
|
||||
Ok(())
|
||||
}
|
||||
fn total_bytes(&self) -> Option<u64> {
|
||||
// Read mode: size is known from disc scan
|
||||
if self.reader.is_some() {
|
||||
Some(self.disc_title.size_bytes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn keys(&self) -> DecryptKeys {
|
||||
self.decrypt_keys.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for IsoStream {
|
||||
|
||||
@@ -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 + 4..ad_offset + 8].copy_from_slice(§or_pos.to_le_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;
|
||||
remaining -= extent_len;
|
||||
}
|
||||
|
||||
+164
-37
@@ -3,9 +3,11 @@
|
||||
//! Write: prepends FMKV metadata header, then passes through BD-TS bytes.
|
||||
//! Read: extracts metadata header (or scans PMT), then yields BD-TS bytes.
|
||||
|
||||
use super::{meta, ts, IOStream, ReadSeek};
|
||||
use super::{meta, ts, IOStream};
|
||||
use crate::disc::{DiscTitle, Stream as DiscStream};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
type PesSetup = (Vec<u16>, Vec<(u16, Box<dyn super::codec::CodecParser>)>, Vec<(u16, usize)>);
|
||||
|
||||
/// Size of initial scan buffer for PMT/stream detection.
|
||||
const SCAN_SIZE: usize = 1024 * 1024;
|
||||
@@ -16,10 +18,25 @@ enum Mode {
|
||||
header_written: bool,
|
||||
},
|
||||
Read {
|
||||
reader: Box<dyn ReadSeek>,
|
||||
reader: Box<dyn Read>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Read as many bytes as possible into buf (multiple read calls if needed).
|
||||
/// Bounded by buf.len() — caller controls max bytes read.
|
||||
fn read_fill(r: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let mut total = 0;
|
||||
while total < buf.len() {
|
||||
match r.read(&mut buf[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// BD transport stream with embedded metadata.
|
||||
pub struct M2tsStream {
|
||||
disc_title: DiscTitle,
|
||||
@@ -27,6 +44,14 @@ pub struct M2tsStream {
|
||||
finished: bool,
|
||||
/// Content size in bytes (file size minus header), set for read mode.
|
||||
content_size: Option<u64>,
|
||||
// PES support
|
||||
demuxer: Option<ts::TsDemuxer>,
|
||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
pes_eof: bool,
|
||||
/// Codec private data per stream (from FMKV header).
|
||||
stored_codec_privates: Vec<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl M2tsStream {
|
||||
@@ -40,70 +65,172 @@ impl M2tsStream {
|
||||
},
|
||||
finished: false,
|
||||
content_size: None,
|
||||
demuxer: None,
|
||||
parsers: Vec::new(),
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track: Vec::new(),
|
||||
pes_eof: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_pes(streams: &[DiscStream]) -> PesSetup {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
let (pid, codec) = match s {
|
||||
DiscStream::Video(v) => (v.pid, v.codec),
|
||||
DiscStream::Audio(a) => (a.pid, a.codec),
|
||||
DiscStream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec)));
|
||||
}
|
||||
(pids, parsers, pid_to_track)
|
||||
}
|
||||
|
||||
/// Set stream metadata. Returns self for chaining.
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
/// Open an m2ts file for reading.
|
||||
/// Open an M2TS stream for reading. Takes any Read source — file, pipe, socket.
|
||||
///
|
||||
/// Tries FMKV metadata header first. Falls back to PMT scan + PTS duration.
|
||||
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
|
||||
// Get total file size for progress tracking
|
||||
let file_size = reader.seek(SeekFrom::End(0))?;
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
/// Tries FMKV metadata header first. Falls back to PMT scan of first 1 MB.
|
||||
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
|
||||
// Read first chunk — enough for FMKV header or PMT scan
|
||||
let mut head = vec![0u8; SCAN_SIZE];
|
||||
let head_len = read_fill(&mut reader, &mut head)?;
|
||||
head.truncate(head_len);
|
||||
|
||||
// Try FMKV metadata header
|
||||
if let Ok(Some(m)) = meta::read_header(&mut reader) {
|
||||
let header_end = reader.stream_position()?;
|
||||
let content_size = file_size.saturating_sub(header_end);
|
||||
// Try FMKV metadata header from the buffered head
|
||||
let mut cursor = io::Cursor::new(&head);
|
||||
if let Ok(Some(m)) = meta::read_header(&mut cursor) {
|
||||
let header_end = cursor.position() as usize;
|
||||
let title = m.to_title();
|
||||
let (pids, parsers, pid_to_track) = Self::setup_pes(&title.streams);
|
||||
// Chain: remaining head bytes + rest of reader
|
||||
let remaining_head = &head[header_end..];
|
||||
let chain: Box<dyn Read> = Box::new(io::Cursor::new(remaining_head.to_vec()).chain(reader));
|
||||
return Ok(Self {
|
||||
disc_title: m.to_title(),
|
||||
mode: Mode::Read {
|
||||
reader: Box::new(reader),
|
||||
},
|
||||
disc_title: title.clone(),
|
||||
mode: Mode::Read { reader: chain },
|
||||
finished: false,
|
||||
content_size: Some(content_size),
|
||||
content_size: None,
|
||||
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
pes_eof: false,
|
||||
stored_codec_privates: title.codec_privates,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: scan PMT for streams, PTS for duration
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
let mut buf = vec![0u8; SCAN_SIZE];
|
||||
let n = reader.read(&mut buf)?;
|
||||
|
||||
let streams = ts::scan_streams(&buf[..n])
|
||||
// No FMKV header — scan head for PMT
|
||||
let streams = ts::scan_streams(&head)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no streams found"))?;
|
||||
|
||||
let video_pid = streams.iter().find_map(|s| match s {
|
||||
DiscStream::Video(v) => Some(v.pid),
|
||||
_ => None,
|
||||
});
|
||||
let duration = video_pid
|
||||
.and_then(|pid| ts::scan_duration(&mut reader, pid))
|
||||
.unwrap_or(0.0);
|
||||
let (pids, parsers, pid_to_track) = Self::setup_pes(&streams);
|
||||
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
// Chain: full head (it's all TS data) + rest of reader
|
||||
let chain: Box<dyn Read> = Box::new(io::Cursor::new(head).chain(reader));
|
||||
|
||||
Ok(Self {
|
||||
disc_title: DiscTitle {
|
||||
duration_secs: duration,
|
||||
duration_secs: 0.0, // unknown without seeking
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
},
|
||||
mode: Mode::Read {
|
||||
reader: Box::new(reader),
|
||||
},
|
||||
mode: Mode::Read { reader: chain },
|
||||
finished: false,
|
||||
content_size: Some(file_size),
|
||||
content_size: None,
|
||||
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
pes_eof: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.pes_eof { return Ok(None); }
|
||||
|
||||
loop {
|
||||
let reader = match &mut self.mode {
|
||||
Mode::Read { reader } => reader,
|
||||
_ => return Err(io::Error::new(io::ErrorKind::Unsupported, "not in read mode")),
|
||||
};
|
||||
let mut buf = vec![0u8; 192 * 1024];
|
||||
let n = reader.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
self.pes_eof = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(ref mut demuxer) = self.demuxer {
|
||||
let packets = demuxer.feed(&buf[..n]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "use M2tsOutputStream for writing"))
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> { Ok(()) }
|
||||
|
||||
fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title }
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
// First check stored codec_privates from FMKV header
|
||||
if let Some(Some(cp)) = self.stored_codec_privates.get(track) {
|
||||
return Some(cp.clone());
|
||||
}
|
||||
// Fall back to parser-extracted codec_private
|
||||
let pid = self.pid_to_track.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
for (idx, s) in self.disc_title.streams.iter().enumerate() {
|
||||
if let crate::disc::Stream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for M2tsStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
|
||||
+55
-117
@@ -3,11 +3,9 @@
|
||||
//! 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).
|
||||
|
||||
use crate::disc::{
|
||||
AudioStream, Codec, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
|
||||
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
|
||||
@@ -48,6 +46,9 @@ pub enum MetaStream {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
secondary: bool,
|
||||
/// Base64-encoded codec initialization data (HEVCDecoderConfigurationRecord, etc.)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
codec_private: Option<String>,
|
||||
},
|
||||
#[serde(rename = "audio")]
|
||||
Audio {
|
||||
@@ -76,33 +77,38 @@ pub enum MetaStream {
|
||||
}
|
||||
|
||||
impl M2tsMeta {
|
||||
/// Build metadata from a disc Title.
|
||||
/// Build metadata from a DiscTitle. Codec privates come from title.codec_privates.
|
||||
pub fn from_title(title: &DiscTitle) -> Self {
|
||||
use base64::Engine;
|
||||
let streams = title
|
||||
.streams
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
.enumerate()
|
||||
.map(|(i, s)| match s {
|
||||
Stream::Video(v) => MetaStream::Video {
|
||||
pid: v.pid,
|
||||
codec: codec_to_str(v.codec),
|
||||
resolution: v.resolution.clone(),
|
||||
frame_rate: v.frame_rate.clone(),
|
||||
hdr: hdr_to_str(v.hdr),
|
||||
codec: v.codec.id().into(),
|
||||
resolution: v.resolution.to_string(),
|
||||
frame_rate: v.frame_rate.to_string(),
|
||||
hdr: v.hdr.id().into(),
|
||||
label: v.label.clone(),
|
||||
secondary: v.secondary,
|
||||
codec_private: title.codec_privates.get(i)
|
||||
.and_then(|cp| cp.as_ref())
|
||||
.map(|cp| base64::engine::general_purpose::STANDARD.encode(cp)),
|
||||
},
|
||||
Stream::Audio(a) => MetaStream::Audio {
|
||||
pid: a.pid,
|
||||
codec: codec_to_str(a.codec),
|
||||
channels: a.channels.clone(),
|
||||
codec: a.codec.id().into(),
|
||||
channels: a.channels.to_string(),
|
||||
language: a.language.clone(),
|
||||
sample_rate: a.sample_rate.clone(),
|
||||
sample_rate: a.sample_rate.to_string(),
|
||||
label: a.label.clone(),
|
||||
secondary: a.secondary,
|
||||
},
|
||||
Stream::Subtitle(s) => MetaStream::Subtitle {
|
||||
pid: s.pid,
|
||||
codec: codec_to_str(s.codec),
|
||||
codec: s.codec.id().into(),
|
||||
language: s.language.clone(),
|
||||
forced: s.forced,
|
||||
},
|
||||
@@ -131,12 +137,13 @@ impl M2tsMeta {
|
||||
hdr,
|
||||
label,
|
||||
secondary,
|
||||
codec_private: _,
|
||||
} => Stream::Video(VideoStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
resolution: resolution.clone(),
|
||||
frame_rate: frame_rate.clone(),
|
||||
hdr: str_to_hdr(hdr),
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
resolution: resolution.parse().unwrap_or(crate::disc::Resolution::Unknown),
|
||||
frame_rate: frame_rate.parse().unwrap_or(crate::disc::FrameRate::Unknown),
|
||||
hdr: hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr),
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
@@ -151,10 +158,10 @@ impl M2tsMeta {
|
||||
secondary,
|
||||
} => Stream::Audio(AudioStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
channels: channels.clone(),
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
channels: channels.parse().unwrap_or(crate::disc::AudioChannels::Unknown),
|
||||
language: language.clone(),
|
||||
sample_rate: sample_rate.clone(),
|
||||
sample_rate: sample_rate.parse().unwrap_or(crate::disc::SampleRate::Unknown),
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
}),
|
||||
@@ -165,7 +172,7 @@ impl M2tsMeta {
|
||||
forced,
|
||||
} => Stream::Subtitle(SubtitleStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
language: language.clone(),
|
||||
forced: *forced,
|
||||
codec_data: None,
|
||||
@@ -183,8 +190,24 @@ impl M2tsMeta {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: crate::disc::ContentFormat::BdTs,
|
||||
codec_privates: self.codec_privates(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract codec_private data per stream (from FMKV header).
|
||||
/// Returns a Vec matching stream order — None for streams without codec_private.
|
||||
pub fn codec_privates(&self) -> Vec<Option<Vec<u8>>> {
|
||||
self.streams.iter().map(|s| {
|
||||
if let MetaStream::Video { codec_private: Some(ref b64), .. } = s {
|
||||
{
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.decode(b64).ok()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the metadata header to a writer. Padded to 192-byte boundary.
|
||||
@@ -205,28 +228,27 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to read a metadata header from the start of an m2ts file.
|
||||
/// Returns None for bare m2ts files (no header).
|
||||
/// On success, leaves reader positioned at the first TS packet.
|
||||
/// On failure, seeks back to the start.
|
||||
pub fn read_header<R: Read + Seek>(r: &mut R) -> io::Result<Option<M2tsMeta>> {
|
||||
let start = r.stream_position()?;
|
||||
/// Try to read an FMKV metadata header.
|
||||
/// Returns None if magic bytes don't match. Consumes header bytes on success.
|
||||
/// Caller handles seek-back on failure if needed (e.g. for fallback PMT scan).
|
||||
pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
const MAX_JSON_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
let mut magic = [0u8; 8];
|
||||
if r.read_exact(&mut magic).is_err() {
|
||||
r.seek(SeekFrom::Start(start))?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if magic[..4] != MAGIC[..4] {
|
||||
// Not a freemkv m2ts — seek back
|
||||
r.seek(SeekFrom::Start(start))?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
r.read_exact(&mut len_buf)?;
|
||||
let json_len = u32::from_be_bytes(len_buf) as usize;
|
||||
if json_len > MAX_JSON_SIZE {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "FMKV JSON too large"));
|
||||
}
|
||||
|
||||
let mut json_buf = vec![0u8; json_len];
|
||||
r.read_exact(&mut json_buf)?;
|
||||
@@ -238,37 +260,6 @@ pub fn read_header<R: Read + Seek>(r: &mut R) -> io::Result<Option<M2tsMeta>> {
|
||||
let raw_len = 8 + 4 + json_len;
|
||||
let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
if padding > 0 {
|
||||
r.seek(SeekFrom::Current(padding as i64))?;
|
||||
}
|
||||
|
||||
Ok(Some(meta))
|
||||
}
|
||||
|
||||
/// Read a metadata header from a forward-only stream (no Seek required).
|
||||
/// Returns None if the magic bytes don't match. Consumes the header bytes.
|
||||
pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
let mut magic = [0u8; 8];
|
||||
r.read_exact(&mut magic)?;
|
||||
|
||||
if magic[..4] != MAGIC[..4] {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
r.read_exact(&mut len_buf)?;
|
||||
let json_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut json_buf = vec![0u8; json_len];
|
||||
r.read_exact(&mut json_buf)?;
|
||||
|
||||
let meta: M2tsMeta = serde_json::from_slice(&json_buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Skip padding
|
||||
let raw_len = 8 + 4 + json_len;
|
||||
let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
if padding > 0 {
|
||||
let mut skip = vec![0u8; padding];
|
||||
r.read_exact(&mut skip)?;
|
||||
@@ -277,58 +268,5 @@ pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>
|
||||
Ok(Some(meta))
|
||||
}
|
||||
|
||||
// Codec string conversion (compact, no English — just codec identifiers)
|
||||
fn codec_to_str(c: Codec) -> String {
|
||||
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,
|
||||
}
|
||||
}
|
||||
// Serialization uses Codec::id() / HdrFormat::id() and Display impls.
|
||||
// Deserialization uses FromStr impls (.parse()) on each enum.
|
||||
|
||||
+5
-42
@@ -35,7 +35,7 @@ impl MkvTrack {
|
||||
Codec::Mpeg2 => "V_MPEG2",
|
||||
_ => "V_MPEG2",
|
||||
};
|
||||
let (w, h) = parse_resolution(&v.resolution);
|
||||
let (w, h) = v.resolution.pixels();
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_VIDEO,
|
||||
codec_id,
|
||||
@@ -61,8 +61,8 @@ impl MkvTrack {
|
||||
Codec::Lpcm => "A_PCM/INT/BIG",
|
||||
_ => "A_AC3",
|
||||
};
|
||||
let sr = parse_sample_rate(&a.sample_rate);
|
||||
let ch = parse_channels(&a.channels);
|
||||
let sr = a.sample_rate.hz();
|
||||
let ch = a.channels.count();
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||||
codec_id,
|
||||
@@ -414,45 +414,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
fn parse_resolution(s: &str) -> (u32, u32) {
|
||||
if s.contains("2160") {
|
||||
(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
|
||||
}
|
||||
}
|
||||
// Old parse_resolution/parse_sample_rate/parse_channels removed —
|
||||
// Resolution::pixels(), SampleRate::hz(), AudioChannels::count() replace them.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! MKV output stream — accepts PES frames, writes Matroska container.
|
||||
//!
|
||||
//! Implements OutputStream. Takes PES frames directly — no TS demuxing needed.
|
||||
//! Creates the MKV muxer once codec_private is provided for all tracks.
|
||||
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::WriteSeek;
|
||||
use crate::disc::DiscTitle;
|
||||
use crate::pes::PesFrame;
|
||||
use std::io;
|
||||
|
||||
pub struct MkvOutputStream {
|
||||
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
|
||||
title: DiscTitle,
|
||||
}
|
||||
|
||||
impl MkvOutputStream {
|
||||
/// Create an MKV output stream. Codec privates come from title.codec_privates.
|
||||
pub fn create(
|
||||
writer: Box<dyn WriteSeek>,
|
||||
title: &DiscTitle,
|
||||
) -> io::Result<Self> {
|
||||
let mut tracks = Vec::new();
|
||||
for (idx, s) in title.streams.iter().enumerate() {
|
||||
let mut track = match s {
|
||||
crate::disc::Stream::Video(v) => MkvTrack::video(v),
|
||||
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
|
||||
crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s),
|
||||
};
|
||||
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
|
||||
track.codec_private = Some(cp.clone());
|
||||
}
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
let muxer = MkvMuxer::new_with_chapters(
|
||||
writer,
|
||||
&tracks,
|
||||
Some(&title.playlist),
|
||||
title.duration_secs,
|
||||
&title.chapters,
|
||||
)?;
|
||||
|
||||
Ok(Self { muxer: Some(muxer), title: title.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::pes::Stream for MkvOutputStream {
|
||||
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 {
|
||||
muxer.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(muxer) = self.muxer.take() {
|
||||
muxer.finish()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn info(&self) -> &DiscTitle { &self.title }
|
||||
}
|
||||
+264
-70
@@ -7,9 +7,19 @@ use super::codec::{self, CodecParser};
|
||||
use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE};
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::ts::TsDemuxer;
|
||||
use super::{ebml, IOStream, ReadSeek, WriteSeek};
|
||||
use super::{ebml, IOStream, WriteSeek};
|
||||
use std::io::Seek;
|
||||
|
||||
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
|
||||
|
||||
/// Skip `n` bytes on a forward-only reader (no Seek required).
|
||||
fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> {
|
||||
io::copy(&mut r.take(n), &mut io::sink())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use crate::disc::*;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// Lookahead buffer for codec header detection (5 MB default).
|
||||
const DEFAULT_MAX_BUFFER: usize = DEFAULT_LOOKAHEAD_SIZE;
|
||||
@@ -33,11 +43,16 @@ struct WriteState {
|
||||
}
|
||||
|
||||
struct ReadState {
|
||||
reader: Box<dyn ReadSeek>,
|
||||
reader: Box<dyn Read>,
|
||||
buf: Vec<u8>,
|
||||
pos: usize,
|
||||
len: usize,
|
||||
cluster_ts_ms: i64,
|
||||
/// Codec private data per track (track_number, hvcC/avcC bytes).
|
||||
/// Emitted as Annex B NALs before first frame of each video track.
|
||||
codec_privates: Vec<(u16, Vec<u8>)>,
|
||||
/// Tracks that have already had their codec_private emitted.
|
||||
initialized_tracks: Vec<u16>,
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
@@ -122,10 +137,8 @@ impl MkvStream {
|
||||
}
|
||||
|
||||
/// Open an MKV file for reading.
|
||||
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
|
||||
let file_size = reader.seek(SeekFrom::End(0))?;
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
let disc_title = parse_mkv_header(&mut reader)?;
|
||||
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
|
||||
let (disc_title, codec_privates) = parse_mkv_header(&mut reader)?;
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mode: Mode::Read(ReadState {
|
||||
@@ -134,14 +147,93 @@ impl MkvStream {
|
||||
pos: 0,
|
||||
len: 0,
|
||||
cluster_ts_ms: 0,
|
||||
codec_privates,
|
||||
initialized_tracks: Vec::new(),
|
||||
}),
|
||||
max_buffer: 0,
|
||||
finished: false,
|
||||
file_size: Some(file_size),
|
||||
file_size: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::pes::Stream for MkvStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
let rs = match self.mode {
|
||||
Mode::Read(ref mut rs) => rs,
|
||||
Mode::Write(_) => return Err(io::Error::new(io::ErrorKind::Unsupported, "write-only")),
|
||||
};
|
||||
|
||||
loop {
|
||||
let (id, size, _) = match ebml::read_element_header(&mut rs.reader) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
match id {
|
||||
ebml::CLUSTER => continue,
|
||||
ebml::CLUSTER_TIMESTAMP => {
|
||||
rs.cluster_ts_ms = ebml::read_uint_val(&mut rs.reader, size as usize)? as i64;
|
||||
continue;
|
||||
}
|
||||
ebml::SIMPLE_BLOCK => {
|
||||
let block = ebml::read_binary_val(&mut rs.reader, size as usize)?;
|
||||
if block.len() < 4 { continue; }
|
||||
|
||||
let (track, vl) = block_vint(&block);
|
||||
if vl + 3 > block.len() { continue; }
|
||||
|
||||
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
|
||||
let keyframe = block[vl + 2] & 0x80 != 0;
|
||||
let data = block[vl + 3..].to_vec();
|
||||
let pts_ms = rs.cluster_ts_ms + rel_ts as i64;
|
||||
let track_idx = (track as usize).saturating_sub(1); // MKV tracks are 1-based
|
||||
|
||||
// Skip blocks for non-existent tracks
|
||||
if track_idx >= self.disc_title.streams.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(Some(crate::pes::PesFrame {
|
||||
track: track_idx,
|
||||
pts: pts_ms * 1_000_000, // ms → ns
|
||||
keyframe,
|
||||
data,
|
||||
}));
|
||||
}
|
||||
_ => {
|
||||
skip_bytes(&mut rs.reader, size)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "use MkvOutputStream for writing"))
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> { Ok(()) }
|
||||
|
||||
fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title }
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let track_num = (track + 1) as u16; // MKV tracks are 1-based
|
||||
if let Mode::Read(ref rs) = self.mode {
|
||||
rs.codec_privates
|
||||
.iter()
|
||||
.find(|(tn, _)| *tn == track_num)
|
||||
.map(|(_, data)| data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
true // MKV has all headers upfront in the EBML header
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for MkvStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
@@ -280,9 +372,22 @@ impl Read for MkvStream {
|
||||
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
|
||||
let frame = &block[vl + 3..];
|
||||
let pts_ms = rs.cluster_ts_ms + rel_ts as i64;
|
||||
let tnum = track as u16;
|
||||
|
||||
rs.buf.clear();
|
||||
frame_to_ts(&mut rs.buf, track as u16, pts_ms, frame);
|
||||
|
||||
// First frame of a track: emit codec_private as Annex B NALs
|
||||
if !rs.initialized_tracks.contains(&tnum) {
|
||||
rs.initialized_tracks.push(tnum);
|
||||
if let Some((_, cp)) = rs.codec_privates.iter().find(|(t, _)| *t == tnum) {
|
||||
let annex_b = hvcc_to_annex_b(cp);
|
||||
if !annex_b.is_empty() {
|
||||
frame_to_ts(&mut rs.buf, tnum, pts_ms, &annex_b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frame_to_ts(&mut rs.buf, tnum, pts_ms, frame);
|
||||
rs.pos = 0;
|
||||
rs.len = rs.buf.len();
|
||||
|
||||
@@ -295,7 +400,7 @@ impl Read for MkvStream {
|
||||
}
|
||||
_ => {
|
||||
if size != u64::MAX && size > 0 {
|
||||
rs.reader.seek(SeekFrom::Current(size as i64))?;
|
||||
skip_bytes(&mut rs.reader, size)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +456,11 @@ fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Transfer remainder bytes from old demuxer to new one.
|
||||
// Preserves 192-byte packet alignment across the reset.
|
||||
let remainder = ws.demuxer.take_remainder();
|
||||
ws.demuxer = TsDemuxer::new(&pids);
|
||||
ws.demuxer.set_remainder(remainder);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -377,11 +486,15 @@ fn write_pes(
|
||||
|
||||
// ── MKV header parsing (read side) ────────────────────────────
|
||||
|
||||
fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
|
||||
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
|
||||
fn parse_mkv_header(
|
||||
r: &mut impl Read,
|
||||
) -> MkvHeaderResult {
|
||||
let mut title = String::new();
|
||||
let mut duration_ms = 0.0f64;
|
||||
let mut ts_scale: u64 = 1_000_000;
|
||||
let mut streams: Vec<crate::disc::Stream> = Vec::new();
|
||||
let mut codec_privates: Vec<(u16, Vec<u8>)> = Vec::new();
|
||||
|
||||
let (id, size, _) = ebml::read_element_header(r)?;
|
||||
if id != ebml::EBML {
|
||||
@@ -393,7 +506,7 @@ fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
|
||||
"EBML header too large",
|
||||
));
|
||||
}
|
||||
r.seek(SeekFrom::Current(size as i64))?;
|
||||
skip_bytes(r, size)?;
|
||||
|
||||
let (id, _, _) = ebml::read_element_header(r)?;
|
||||
if id != ebml::SEGMENT {
|
||||
@@ -413,92 +526,102 @@ fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
|
||||
|
||||
match id {
|
||||
ebml::INFO => {
|
||||
let end = r.stream_position()? + size;
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::TIMESTAMP_SCALE => ts_scale = ebml::read_uint_val(r, cs as usize)?,
|
||||
ebml::DURATION => duration_ms = ebml::read_float_val(r, cs as usize)?,
|
||||
ebml::TITLE => title = ebml::read_string_val(r, cs as usize)?,
|
||||
_ => {
|
||||
r.seek(SeekFrom::Current(cs as i64))?;
|
||||
}
|
||||
_ => { skip_bytes(r, cs)?; }
|
||||
}
|
||||
}
|
||||
got_info = true;
|
||||
}
|
||||
ebml::TRACKS => {
|
||||
let end = r.stream_position()? + size;
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
if cid == ebml::TRACK_ENTRY {
|
||||
if let Some(s) = parse_track(r, cs)? {
|
||||
let (stream, tnum, cp) = parse_track(r, cs)?;
|
||||
if let Some(s) = stream {
|
||||
streams.push(s);
|
||||
}
|
||||
if let Some(cp) = cp {
|
||||
codec_privates.push((tnum, cp));
|
||||
}
|
||||
} else {
|
||||
r.seek(SeekFrom::Current(cs as i64))?;
|
||||
skip_bytes(r, cs)?;
|
||||
}
|
||||
}
|
||||
got_tracks = true;
|
||||
}
|
||||
ebml::CLUSTER => break,
|
||||
_ if size != u64::MAX => {
|
||||
r.seek(SeekFrom::Current(size as i64))?;
|
||||
skip_bytes(r, size)?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DiscTitle {
|
||||
let disc_title = DiscTitle {
|
||||
playlist: title,
|
||||
duration_secs: duration_ms * (ts_scale as f64) / 1_000_000_000.0,
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
})
|
||||
};
|
||||
Ok((disc_title, codec_privates))
|
||||
}
|
||||
|
||||
fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate::disc::Stream>> {
|
||||
let end = r.stream_position()? + size;
|
||||
/// Returns (stream, track_number, codec_private_bytes)
|
||||
fn parse_track(
|
||||
r: &mut impl Read,
|
||||
size: u64,
|
||||
) -> io::Result<(Option<crate::disc::Stream>, u16, Option<Vec<u8>>)> {
|
||||
let (mut ttype, mut tnum) = (0u64, 0u16);
|
||||
let (mut codec_id, mut lang, mut name) = (String::new(), String::from("und"), String::new());
|
||||
let (mut ph, mut sr, mut ch, mut forced) = (0u32, 0.0f64, 0u8, false);
|
||||
let mut codec_priv: Option<Vec<u8>> = None;
|
||||
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::TRACK_NUMBER => tnum = ebml::read_uint_val(r, cs as usize)? as u16,
|
||||
ebml::TRACK_TYPE => ttype = ebml::read_uint_val(r, cs as usize)?,
|
||||
ebml::CODEC_ID => codec_id = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::CODEC_PRIVATE => codec_priv = Some(ebml::read_binary_val(r, cs as usize)?),
|
||||
ebml::LANGUAGE => lang = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::TRACK_NAME => name = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::FLAG_FORCED => forced = ebml::read_uint_val(r, cs as usize)? != 0,
|
||||
ebml::VIDEO => {
|
||||
let ve = r.stream_position()? + cs;
|
||||
while r.stream_position()? < ve {
|
||||
let (vid, vs, _) = ebml::read_element_header(r)?;
|
||||
let mut vrem = cs;
|
||||
while vrem > 0 {
|
||||
let (vid, vs, vhlen) = ebml::read_element_header(r)?;
|
||||
vrem = vrem.saturating_sub(vhlen as u64 + vs);
|
||||
if vid == ebml::PIXEL_HEIGHT {
|
||||
ph = ebml::read_uint_val(r, vs as usize)? as u32;
|
||||
} else {
|
||||
r.seek(SeekFrom::Current(vs as i64))?;
|
||||
skip_bytes(r, vs)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
ebml::AUDIO => {
|
||||
let ae = r.stream_position()? + cs;
|
||||
while r.stream_position()? < ae {
|
||||
let (aid, as_, _) = ebml::read_element_header(r)?;
|
||||
let mut arem = cs;
|
||||
while arem > 0 {
|
||||
let (aid, as_, ahlen) = ebml::read_element_header(r)?;
|
||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||
match aid {
|
||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
||||
ebml::CHANNELS => ch = ebml::read_uint_val(r, as_ as usize)? as u8,
|
||||
_ => {
|
||||
r.seek(SeekFrom::Current(as_ as i64))?;
|
||||
}
|
||||
_ => { skip_bytes(r, as_)?; }
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
r.seek(SeekFrom::Current(cs as i64))?;
|
||||
}
|
||||
_ => { skip_bytes(r, cs)?; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,30 +639,33 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
|
||||
"S_VOBSUB" => Codec::DvdSub,
|
||||
_ => Codec::Unknown(0),
|
||||
};
|
||||
let res = format!("{ph}p");
|
||||
let chs: String = match ch {
|
||||
8 => "7.1",
|
||||
6 => "5.1",
|
||||
2 => "stereo",
|
||||
1 => "mono",
|
||||
_ => "5.1",
|
||||
}
|
||||
.into();
|
||||
let srs: String = if sr >= 96000.0 { "96kHz" } else { "48kHz" }.into();
|
||||
let res = Resolution::from_height(ph);
|
||||
let chs = AudioChannels::from_count(ch);
|
||||
let srs = if sr >= 96000.0 {
|
||||
SampleRate::S96
|
||||
} else {
|
||||
SampleRate::S48
|
||||
};
|
||||
|
||||
Ok(match ttype {
|
||||
1 => Some(crate::disc::Stream::Video(VideoStream {
|
||||
pid: tnum,
|
||||
codec,
|
||||
resolution: res,
|
||||
frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
label: name,
|
||||
})),
|
||||
// Map MKV track numbers to BD-TS PIDs (same mapping as frame_to_ts)
|
||||
let ts_pid = if tnum == 1 { 0x1011 } else { 0x1100 + (tnum - 2) };
|
||||
|
||||
let stream = match ttype {
|
||||
1 => {
|
||||
let is_secondary = name.contains("Dolby Vision EL") || name.contains("DV EL");
|
||||
Some(crate::disc::Stream::Video(VideoStream {
|
||||
pid: ts_pid,
|
||||
codec,
|
||||
resolution: res,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: is_secondary,
|
||||
label: name,
|
||||
}))
|
||||
}
|
||||
2 => Some(crate::disc::Stream::Audio(AudioStream {
|
||||
pid: tnum,
|
||||
pid: ts_pid,
|
||||
codec,
|
||||
channels: chs,
|
||||
language: lang,
|
||||
@@ -548,14 +674,15 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
|
||||
label: name,
|
||||
})),
|
||||
17 => Some(crate::disc::Stream::Subtitle(SubtitleStream {
|
||||
pid: tnum,
|
||||
pid: ts_pid,
|
||||
codec,
|
||||
language: lang,
|
||||
forced,
|
||||
codec_data: None,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
Ok((stream, tnum, codec_priv))
|
||||
}
|
||||
|
||||
// ── BD-TS frame wrapping (read side) ──────────────────────────
|
||||
@@ -570,7 +697,57 @@ fn block_vint(d: &[u8]) -> (u64, usize) {
|
||||
if d[0] & 0x40 != 0 && d.len() >= 2 {
|
||||
return ((((d[0] & 0x3F) as u64) << 8) | d[1] as u64, 2);
|
||||
}
|
||||
(0, 1)
|
||||
if d[0] & 0x20 != 0 && d.len() >= 3 {
|
||||
return ((((d[0] & 0x1F) as u64) << 16) | ((d[1] as u64) << 8) | d[2] as u64, 3);
|
||||
}
|
||||
if d[0] & 0x10 != 0 && d.len() >= 4 {
|
||||
return ((((d[0] & 0x0F) as u64) << 24) | ((d[1] as u64) << 16) | ((d[2] as u64) << 8) | d[3] as u64, 4);
|
||||
}
|
||||
(0, 1) // Unsupported 5+ byte VINT — treat as track 0
|
||||
}
|
||||
|
||||
/// Convert HEVCDecoderConfigurationRecord (hvcC) to Annex B NAL units.
|
||||
/// Extracts VPS, SPS, PPS arrays and prefixes each with 0x00000001.
|
||||
fn hvcc_to_annex_b(hvcc: &[u8]) -> Vec<u8> {
|
||||
// hvcC format (ISO 14496-15):
|
||||
// byte 0: configurationVersion (1)
|
||||
// bytes 1-21: profile/level info
|
||||
// byte 22: numOfArrays
|
||||
// For each array:
|
||||
// byte 0: array_completeness(1) + reserved(1) + NAL_unit_type(6)
|
||||
// bytes 1-2: numNalus (big-endian u16)
|
||||
// For each NAL:
|
||||
// bytes 0-1: nalUnitLength (big-endian u16)
|
||||
// bytes 2..: NAL data
|
||||
if hvcc.len() < 23 {
|
||||
return Vec::new();
|
||||
}
|
||||
let num_arrays = hvcc[22] as usize;
|
||||
let mut pos = 23;
|
||||
let mut out = Vec::new();
|
||||
|
||||
for _ in 0..num_arrays {
|
||||
if pos + 3 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
pos += 1; // skip array_completeness + NAL type byte
|
||||
let num_nalus = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
for _ in 0..num_nalus {
|
||||
if pos + 2 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
let nal_len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
if pos + nal_len > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
out.extend_from_slice(&hvcc[pos..pos + nal_len]);
|
||||
pos += nal_len;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn frame_to_ts(out: &mut Vec<u8>, track: u16, pts_ms: i64, data: &[u8]) {
|
||||
@@ -579,14 +756,31 @@ fn frame_to_ts(out: &mut Vec<u8>, track: u16, pts_ms: i64, data: &[u8]) {
|
||||
} else {
|
||||
0x1100 + (track - 2)
|
||||
};
|
||||
let stream_id: u8 = if track == 1 { 0xE0 } else { 0xBD };
|
||||
let is_video = track <= 1 || pid == 0x1011;
|
||||
let stream_id: u8 = if is_video { 0xE0 } else { 0xBD };
|
||||
let pts = encode_pts(pts_ms * 90);
|
||||
let hdr = [0x00, 0x00, 0x01, stream_id, 0x00, 0x00, 0x80, 0x80, 0x05];
|
||||
|
||||
let mut pes = Vec::with_capacity(hdr.len() + pts.len() + data.len());
|
||||
pes.extend_from_slice(&hdr);
|
||||
pes.extend_from_slice(&pts);
|
||||
pes.extend_from_slice(data);
|
||||
|
||||
// Video: convert MKV length-prefixed NALs to Annex B start codes
|
||||
if is_video && data.len() > 4 {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= data.len() {
|
||||
let nal_len = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
|
||||
pos += 4;
|
||||
if nal_len == 0 || pos + nal_len > data.len() {
|
||||
break;
|
||||
}
|
||||
pes.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
pes.extend_from_slice(&data[pos..pos + nal_len]);
|
||||
pos += nal_len;
|
||||
}
|
||||
} else {
|
||||
pes.extend_from_slice(data);
|
||||
}
|
||||
|
||||
let mut off = 0;
|
||||
let mut pusi = true;
|
||||
|
||||
+15
-8
@@ -28,6 +28,10 @@ pub mod lookahead;
|
||||
mod m2ts;
|
||||
pub mod meta;
|
||||
pub mod mkv;
|
||||
pub mod mkvout;
|
||||
pub mod pesout;
|
||||
pub mod tsmux;
|
||||
pub mod tsreader;
|
||||
mod mkvstream;
|
||||
pub mod network;
|
||||
pub mod null;
|
||||
@@ -36,13 +40,13 @@ pub mod resolve;
|
||||
pub mod stdio;
|
||||
pub mod ts;
|
||||
|
||||
pub use disc::{DiscOptions, DiscStream};
|
||||
pub use iso::IsoStream;
|
||||
pub use disc::{DiscOpenResult, DiscStream};
|
||||
pub use iso::{IsoSectorReader, IsoStream};
|
||||
pub use m2ts::M2tsStream;
|
||||
pub use mkvstream::MkvStream;
|
||||
pub use network::NetworkStream;
|
||||
pub use null::NullStream;
|
||||
pub use resolve::{open_input, open_output, parse_url, InputOptions, StreamUrl};
|
||||
pub use resolve::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl};
|
||||
pub use stdio::StdioStream;
|
||||
|
||||
use crate::disc::DiscTitle;
|
||||
@@ -63,11 +67,14 @@ pub trait IOStream: Read + Write {
|
||||
fn total_bytes(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Decryption keys for this stream. Default: no encryption.
|
||||
/// Overridden by DiscStream and IsoStream for AACS/CSS.
|
||||
fn keys(&self) -> crate::decrypt::DecryptKeys {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
}
|
||||
}
|
||||
|
||||
// Combined traits for internal trait objects.
|
||||
pub(crate) trait ReadSeek: Read + Seek {}
|
||||
impl<T: Read + Seek> ReadSeek for T {}
|
||||
|
||||
pub(crate) trait WriteSeek: Write + Seek {}
|
||||
// WriteSeek — used internally by MKV muxer (container format requires seeking).
|
||||
pub trait WriteSeek: Write + Seek {}
|
||||
impl<T: Write + Seek> WriteSeek for T {}
|
||||
|
||||
+50
-123
@@ -1,18 +1,14 @@
|
||||
//! NetworkStream — BD-TS over TCP with embedded metadata.
|
||||
//! NetworkStream — PES frames over TCP with embedded metadata.
|
||||
//!
|
||||
//! **Security:** Data is transmitted over plain TCP with no encryption.
|
||||
//! Use only on trusted networks (LAN). TLS support is planned.
|
||||
//! Use only on trusted networks (LAN).
|
||||
//!
|
||||
//! Write side (sender): connects to a listener, sends FMKV header + BD-TS data.
|
||||
//! Read side (receiver): listens for a connection, reads FMKV header + BD-TS data.
|
||||
//!
|
||||
//! The FMKV metadata header is the same format as M2tsStream uses — so a
|
||||
//! NetworkStream reader can hand off to any output stream (MKV, M2TS, etc.)
|
||||
//! with full metadata (labels, languages, duration).
|
||||
//! Write side (sender): connects to a listener, sends FMKV header + PES frames.
|
||||
//! Read side (receiver): listens for a connection, reads FMKV header + PES frames.
|
||||
|
||||
use super::{meta, IOStream};
|
||||
use super::meta;
|
||||
use crate::disc::DiscTitle;
|
||||
use std::io::{self, BufReader, BufWriter, Read, Write};
|
||||
use std::io::{self, BufReader, BufWriter, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// I/O buffer size for network reads/writes.
|
||||
@@ -32,7 +28,6 @@ enum Mode {
|
||||
pub struct NetworkStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl NetworkStream {
|
||||
@@ -46,7 +41,6 @@ impl NetworkStream {
|
||||
writer: BufWriter::with_capacity(NET_BUF_SIZE, stream),
|
||||
header_written: false,
|
||||
},
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,8 +58,8 @@ impl NetworkStream {
|
||||
stream.set_nodelay(true)?;
|
||||
let mut reader = BufReader::with_capacity(NET_BUF_SIZE, stream);
|
||||
|
||||
// Read FMKV metadata header (inline, since TcpStream doesn't impl Seek)
|
||||
let disc_title = meta::read_header_from_stream(&mut reader)?
|
||||
// Read FMKV metadata header
|
||||
let disc_title = meta::read_header(&mut reader)?
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
@@ -77,36 +71,20 @@ impl NetworkStream {
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mode: Mode::Read { reader },
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for NetworkStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if self.finished {
|
||||
return Ok(());
|
||||
impl crate::pes::Stream for NetworkStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
match &mut self.mode {
|
||||
Mode::Read { reader } => crate::pes::PesFrame::deserialize(reader),
|
||||
_ => Err(io::Error::new(io::ErrorKind::Unsupported, "network opened for writing")),
|
||||
}
|
||||
self.finished = true;
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()?;
|
||||
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for NetworkStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
Mode::Write {
|
||||
ref mut writer,
|
||||
ref mut header_written,
|
||||
} => {
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Write { writer, ref mut header_written, .. } => {
|
||||
if !*header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
@@ -114,46 +92,32 @@ impl Write for NetworkStream {
|
||||
}
|
||||
*header_written = true;
|
||||
}
|
||||
writer.write(buf)
|
||||
frame.serialize(writer)
|
||||
}
|
||||
Mode::Read { .. } => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stream opened for reading",
|
||||
)),
|
||||
_ => Err(io::Error::new(io::ErrorKind::Unsupported, "network opened for reading")),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { writer, .. } = &mut self.mode {
|
||||
writer.flush()?;
|
||||
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
}
|
||||
|
||||
impl Read for NetworkStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
Mode::Read { ref mut reader } => reader.read(buf),
|
||||
Mode::Write { .. } => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stream opened for writing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
// NetworkStream is PES-only — no IOStream/Read/Write byte interface.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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::net::TcpListener;
|
||||
|
||||
/// Build a DiscTitle with streams for metadata tests.
|
||||
fn sample_title() -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: "NetworkTest".into(),
|
||||
@@ -165,8 +129,8 @@ mod tests {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
secondary: false,
|
||||
@@ -175,9 +139,9 @@ mod tests {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "7.1".into(),
|
||||
channels: AudioChannels::Surround71,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "English".into(),
|
||||
}),
|
||||
@@ -185,57 +149,15 @@ mod tests {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires TCP; may be flaky in CI environments
|
||||
fn network_listen_connect_roundtrip() {
|
||||
// Bind to OS-assigned port
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener); // Release so NetworkStream::listen can bind
|
||||
fn network_pes_roundtrip() {
|
||||
use crate::pes;
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let addr_clone = addr.clone();
|
||||
|
||||
// Spawn listener in a thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut received = Vec::new();
|
||||
loop {
|
||||
match ns.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => received.extend_from_slice(&buf[..n]),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
received
|
||||
});
|
||||
|
||||
// Small delay to let the listener thread bind
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// Connect and write data
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
|
||||
let payload = b"Hello from the write side of the network stream!";
|
||||
writer.write_all(payload).unwrap();
|
||||
writer.finish().unwrap();
|
||||
|
||||
let received = handle.join().unwrap();
|
||||
// The received data should end with our payload (after the FMKV header)
|
||||
assert!(
|
||||
received.windows(payload.len()).any(|w| w == payload),
|
||||
"payload not found in received data (got {} bytes)",
|
||||
received.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires TCP; may be flaky in CI environments
|
||||
fn network_metadata_flows() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
@@ -244,35 +166,40 @@ mod tests {
|
||||
let addr_clone = addr.clone();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
let info = ns.info().clone();
|
||||
info
|
||||
let mut ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
let info = pes::Stream::info(&ns).clone();
|
||||
let mut frames = Vec::new();
|
||||
while let Ok(Some(f)) = pes::Stream::read(&mut ns) {
|
||||
frames.push(f);
|
||||
}
|
||||
(info, frames)
|
||||
});
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
|
||||
// Must write at least one byte to trigger header send
|
||||
writer.write_all(&[0u8; 192]).unwrap();
|
||||
writer.finish().unwrap();
|
||||
let frame = pes::PesFrame { track: 0, pts: 90000, keyframe: true, data: vec![0x47; 192] };
|
||||
pes::Stream::write(&mut writer, &frame).unwrap();
|
||||
pes::Stream::finish(&mut writer).unwrap();
|
||||
|
||||
let info = handle.join().unwrap();
|
||||
let (info, frames) = handle.join().unwrap();
|
||||
assert_eq!(info.playlist, "NetworkTest");
|
||||
assert_eq!(info.duration_secs, 3600.0);
|
||||
assert_eq!(info.streams.len(), 2);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].track, 0);
|
||||
assert_eq!(frames[0].pts, 90000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_empty_addr_errors() {
|
||||
let result = NetworkStream::connect("");
|
||||
assert!(result.is_err(), "empty address should fail");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_no_port_errors() {
|
||||
// Connecting to an address without a port should fail
|
||||
let result = NetworkStream::connect("127.0.0.1");
|
||||
assert!(result.is_err(), "address without port should fail");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! PES output streams — each writes its own format from PES frames.
|
||||
|
||||
use super::tsmux::TsMuxer;
|
||||
use crate::disc::DiscTitle;
|
||||
use crate::pes::PesFrame;
|
||||
use std::io::{self, Write};
|
||||
|
||||
// ── M2TS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct M2tsOutputStream {
|
||||
muxer: TsMuxer<io::BufWriter<std::fs::File>>,
|
||||
title: DiscTitle,
|
||||
}
|
||||
|
||||
impl M2tsOutputStream {
|
||||
pub fn create(path: &str, title: &DiscTitle) -> io::Result<Self> {
|
||||
let file = std::fs::File::create(path)
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path, e)))?;
|
||||
let mut writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
if !title.streams.is_empty() {
|
||||
let m = super::meta::M2tsMeta::from_title(title);
|
||||
super::meta::write_header(&mut writer, &m)?;
|
||||
}
|
||||
let pids = extract_pids(title);
|
||||
let mut muxer = TsMuxer::new(writer, &pids);
|
||||
for (i, cp) in title.codec_privates.iter().enumerate() {
|
||||
if let Some(data) = cp {
|
||||
muxer.set_codec_private(i, data.clone());
|
||||
}
|
||||
}
|
||||
Ok(Self { muxer, title: title.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
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 — serializes PES frames directly ──────────────────────────────────
|
||||
|
||||
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<()> {
|
||||
frame.serialize(&mut self.writer)
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> { self.writer.flush() }
|
||||
fn info(&self) -> &DiscTitle { &self.title }
|
||||
}
|
||||
|
||||
// ── Network — serializes PES frames over TCP ────────────────────────────────
|
||||
|
||||
pub struct NetworkOutputStream {
|
||||
writer: 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 mut writer = io::BufWriter::with_capacity(256 * 1024, stream);
|
||||
if !title.streams.is_empty() {
|
||||
let m = super::meta::M2tsMeta::from_title(title);
|
||||
super::meta::write_header(&mut writer, &m)?;
|
||||
writer.flush()?;
|
||||
}
|
||||
Ok(Self { writer, 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<()> {
|
||||
frame.serialize(&mut self.writer)
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> { self.writer.flush() }
|
||||
fn info(&self) -> &DiscTitle { &self.title }
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_pids(title: &DiscTitle) -> Vec<u16> {
|
||||
title.streams.iter().map(|s| match s {
|
||||
crate::disc::Stream::Video(v) => v.pid,
|
||||
crate::disc::Stream::Audio(a) => a.pid,
|
||||
crate::disc::Stream::Subtitle(s) => s.pid,
|
||||
}).collect()
|
||||
}
|
||||
@@ -82,7 +82,6 @@ impl PsDemuxer {
|
||||
let mut pos = 0;
|
||||
|
||||
while let Some(sc) = find_start_code(&self.buffer, pos) {
|
||||
|
||||
if sc + 3 >= self.buffer.len() {
|
||||
// Not enough bytes to read the start code ID.
|
||||
break;
|
||||
|
||||
+154
-22
@@ -14,7 +14,7 @@
|
||||
//!
|
||||
//! Bare paths without a scheme are rejected.
|
||||
|
||||
use super::disc::{DiscOptions, DiscStream};
|
||||
use super::disc::DiscStream;
|
||||
use super::iso::IsoStream;
|
||||
use super::network::NetworkStream;
|
||||
use super::null::NullStream;
|
||||
@@ -104,17 +104,25 @@ pub fn parse_url(url: &str) -> StreamUrl {
|
||||
return if rest.is_empty() {
|
||||
StreamUrl::Disc { device: None }
|
||||
} else {
|
||||
StreamUrl::Disc { device: Some(PathBuf::from(rest)) }
|
||||
StreamUrl::Disc {
|
||||
device: Some(PathBuf::from(rest)),
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("m2ts://") {
|
||||
return StreamUrl::M2ts { path: PathBuf::from(rest) };
|
||||
return StreamUrl::M2ts {
|
||||
path: PathBuf::from(rest),
|
||||
};
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("mkv://") {
|
||||
return StreamUrl::Mkv { path: PathBuf::from(rest) };
|
||||
return StreamUrl::Mkv {
|
||||
path: PathBuf::from(rest),
|
||||
};
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("network://") {
|
||||
return StreamUrl::Network { addr: rest.to_string() };
|
||||
return StreamUrl::Network {
|
||||
addr: rest.to_string(),
|
||||
};
|
||||
}
|
||||
if url == "null://" || url.starts_with("null://") {
|
||||
return StreamUrl::Null;
|
||||
@@ -123,9 +131,13 @@ pub fn parse_url(url: &str) -> StreamUrl {
|
||||
return StreamUrl::Stdio;
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("iso://") {
|
||||
return StreamUrl::Iso { path: PathBuf::from(rest) };
|
||||
return StreamUrl::Iso {
|
||||
path: PathBuf::from(rest),
|
||||
};
|
||||
}
|
||||
StreamUrl::Unknown {
|
||||
raw: url.to_string(),
|
||||
}
|
||||
StreamUrl::Unknown { raw: url.to_string() }
|
||||
}
|
||||
|
||||
/// Validate that a file path is non-empty and has a filename component.
|
||||
@@ -139,7 +151,10 @@ fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
|
||||
if path.file_name().is_none() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("{scheme}://{} is not a valid file path — must include a filename", path.display()),
|
||||
format!(
|
||||
"{scheme}://{} is not a valid file path — must include a filename",
|
||||
path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -168,13 +183,17 @@ pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream
|
||||
|
||||
match parsed {
|
||||
StreamUrl::Disc { device } => {
|
||||
let disc_opts = DiscOptions {
|
||||
device,
|
||||
keydb_path: opts.keydb_path.as_ref().map(|p| p.into()),
|
||||
title_index: opts.title_index,
|
||||
};
|
||||
let stream = DiscStream::open(disc_opts)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
let result = DiscStream::open(
|
||||
device.as_deref(),
|
||||
opts.keydb_path.as_deref(),
|
||||
opts.title_index.unwrap_or(0),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
let mut stream = result.stream;
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
@@ -193,9 +212,9 @@ pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream
|
||||
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::open(reader)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::listen(addr)?))
|
||||
StreamUrl::Network { .. } => {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"network:// requires PES pipeline — use input() instead of open_input()"))
|
||||
}
|
||||
StreamUrl::Stdio => {
|
||||
Ok(Box::new(StdioStream::input()))
|
||||
@@ -206,7 +225,11 @@ pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream
|
||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
Ok(Box::new(IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?))
|
||||
let mut stream = IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?;
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::Null => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
@@ -259,9 +282,9 @@ pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>>
|
||||
};
|
||||
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead)))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::connect(addr)?.meta(meta)))
|
||||
StreamUrl::Network { .. } => {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"network:// output requires PES pipeline — use pipe() instead of open_output()"))
|
||||
}
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
@@ -275,4 +298,113 @@ pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>>
|
||||
pub struct InputOptions {
|
||||
pub keydb_path: Option<String>,
|
||||
pub title_index: Option<usize>,
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub raw: bool,
|
||||
}
|
||||
|
||||
// ── PES-based open ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Open a PES input stream (produces PES frames).
|
||||
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||
let parsed = parse_url(url);
|
||||
match parsed {
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
let scan_opts = match &opts.keydb_path {
|
||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
let mut stream = IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?;
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::Disc { device } => {
|
||||
let result = DiscStream::open(
|
||||
device.as_deref(),
|
||||
opts.keydb_path.as_deref(),
|
||||
opts.title_index.unwrap_or(0),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
let mut stream = result.stream;
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::Null => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
"null:// is write-only — cannot use as input"))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e)))?;
|
||||
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::open(reader)?))
|
||||
}
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
|
||||
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::open(reader)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::listen(addr)?))
|
||||
}
|
||||
StreamUrl::Stdio => {
|
||||
Ok(Box::new(StdioStream::input()))
|
||||
}
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a PES output stream (consumes PES frames).
|
||||
pub fn output(
|
||||
url: &str,
|
||||
title: &crate::disc::DiscTitle,
|
||||
) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||
let parsed = parse_url(url);
|
||||
match parsed {
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::create(path)
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
|
||||
let writer: Box<dyn super::WriteSeek> =
|
||||
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
|
||||
Ok(Box::new(super::mkvout::MkvOutputStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
Ok(Box::new(super::pesout::M2tsOutputStream::create(&path.to_string_lossy(), title)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(super::pesout::NetworkOutputStream::connect(addr, title)?))
|
||||
}
|
||||
StreamUrl::Stdio => {
|
||||
Ok(Box::new(super::pesout::StdioOutputStream::new(title)))
|
||||
}
|
||||
StreamUrl::Null => {
|
||||
Ok(Box::new(super::pesout::NullOutputStream::new(title)))
|
||||
}
|
||||
StreamUrl::Disc { .. } => {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only"))
|
||||
}
|
||||
StreamUrl::Iso { .. } => {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"ISO output from PES not supported — use disc.copy() for raw ISO"))
|
||||
}
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("'{}' is not a valid stream URL", raw)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,26 @@ impl StdioStream {
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::pes::Stream for StdioStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
match &mut self.reader {
|
||||
Some(r) => crate::pes::PesFrame::deserialize(r),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported, "stdio opened for writing")),
|
||||
}
|
||||
}
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.writer {
|
||||
Some(w) => frame.serialize(w),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported, "stdio opened for reading")),
|
||||
}
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(w) = &mut self.writer { w.flush()?; }
|
||||
Ok(())
|
||||
}
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
}
|
||||
|
||||
impl IOStream for StdioStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
|
||||
+65
-31
@@ -104,6 +104,18 @@ pub struct TsDemuxer {
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
/// Take the remainder bytes (leftover from last feed() that didn't
|
||||
/// align to a 192-byte packet boundary).
|
||||
pub fn take_remainder(&mut self) -> Vec<u8> {
|
||||
std::mem::take(&mut self.remainder)
|
||||
}
|
||||
|
||||
/// Set the remainder bytes. Used to transfer alignment state from
|
||||
/// one demuxer to another without losing sync.
|
||||
pub fn set_remainder(&mut self, data: Vec<u8>) {
|
||||
self.remainder = data;
|
||||
}
|
||||
|
||||
/// Create a new demuxer tracking the given PIDs.
|
||||
pub fn new(pids: &[u16]) -> Self {
|
||||
let mut pid_index = [-1i16; 8192];
|
||||
@@ -165,6 +177,9 @@ impl TsDemuxer {
|
||||
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
|
||||
// Adaptation field present
|
||||
let af_len = ts[4] as usize;
|
||||
if af_len > 183 {
|
||||
continue; // Malformed: AF length exceeds TS payload
|
||||
}
|
||||
5 + af_len
|
||||
} else {
|
||||
4
|
||||
@@ -196,9 +211,15 @@ impl TsDemuxer {
|
||||
}
|
||||
}
|
||||
|
||||
// Save leftover bytes for next call
|
||||
// Save leftover bytes for next call (cap at one packet to prevent unbounded growth)
|
||||
if offset < work.len() {
|
||||
self.remainder.extend_from_slice(&work[offset..]);
|
||||
let leftover = &work[offset..];
|
||||
if leftover.len() < BD_TS_PACKET_SIZE {
|
||||
self.remainder.extend_from_slice(leftover);
|
||||
} else {
|
||||
// More than one full packet leftover — something is wrong, discard
|
||||
self.remainder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
completed
|
||||
@@ -245,30 +266,38 @@ fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
|
||||
let pts_dts_flags = (data[7] >> 6) & 0x03;
|
||||
let header_data_len = data[8] as usize;
|
||||
let data_start = 9 + header_data_len;
|
||||
let data_start = (9 + header_data_len).min(data.len());
|
||||
|
||||
let mut pts = None;
|
||||
let mut dts = None;
|
||||
|
||||
if pts_dts_flags >= 2 && data.len() >= 14 {
|
||||
pts = Some(parse_timestamp(&data[9..14]));
|
||||
if pts_dts_flags >= 2 && header_data_len >= 5 && data.len() >= 14 {
|
||||
pts = parse_timestamp(&data[9..14]);
|
||||
}
|
||||
if pts_dts_flags == 3 && data.len() >= 19 {
|
||||
dts = Some(parse_timestamp(&data[14..19]));
|
||||
if pts_dts_flags == 3 && header_data_len >= 10 && data.len() >= 19 {
|
||||
dts = parse_timestamp(&data[14..19]);
|
||||
}
|
||||
|
||||
(pts, dts, data_start)
|
||||
}
|
||||
|
||||
/// Parse a 5-byte PTS/DTS timestamp (33 bits in 90kHz).
|
||||
fn parse_timestamp(data: &[u8]) -> i64 {
|
||||
/// Validates marker bits per MPEG-2 spec. Returns None on invalid encoding.
|
||||
fn parse_timestamp(data: &[u8]) -> Option<i64> {
|
||||
if data.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
// Validate marker bits: byte 2 bit 0 and byte 4 bit 0 must be 1
|
||||
if (data[2] & 0x01) == 0 || (data[4] & 0x01) == 0 {
|
||||
return None;
|
||||
}
|
||||
let b0 = data[0] as i64;
|
||||
let b1 = data[1] as i64;
|
||||
let b2 = data[2] as i64;
|
||||
let b3 = data[3] as i64;
|
||||
let b4 = data[4] as i64;
|
||||
|
||||
((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1
|
||||
Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -300,6 +329,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
let section_len = (((data[pat_start + 1] & 0x0F) as usize) << 8)
|
||||
| data[pat_start + 2] as usize;
|
||||
let entries_start = pat_start + 8;
|
||||
if section_len < 4 { offset += BD_TS_PACKET_SIZE; continue; }
|
||||
let entries_end = pat_start + 3 + section_len - 4;
|
||||
let mut e = entries_start;
|
||||
while e + 4 <= data.len() && e < entries_end {
|
||||
@@ -363,8 +393,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0x1B => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::H264,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: String::new(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -373,8 +403,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0x24 => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: String::new(),
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -383,8 +413,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0xEA => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Vc1,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: String::new(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -393,8 +423,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0x02 => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: "1080i".into(),
|
||||
frame_rate: String::new(),
|
||||
resolution: Resolution::R1080i,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -403,45 +433,45 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0x81 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Ac3,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x83 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Ac3Plus,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::DtsHdMa,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x82 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Dts,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
@@ -495,7 +525,7 @@ pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
return Some(parse_timestamp(&payload[9..14]));
|
||||
return parse_timestamp(&payload[9..14]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,7 +555,7 @@ pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
last_pts = Some(parse_timestamp(&payload[9..14]));
|
||||
last_pts = parse_timestamp(&payload[9..14]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,14 +607,18 @@ mod tests {
|
||||
fn test_parse_timestamp() {
|
||||
// Example: PTS = 0 → encoded as 21 00 01 00 01
|
||||
let data = [0x21, 0x00, 0x01, 0x00, 0x01];
|
||||
assert_eq!(parse_timestamp(&data), 0);
|
||||
assert_eq!(parse_timestamp(&data), Some(0));
|
||||
|
||||
// Example: PTS = 90000 (1 second at 90kHz)
|
||||
// Manual encoding: 33 bits = 0x00015F90
|
||||
// This is just a sanity check that the parser doesn't crash
|
||||
let data2 = [0x21, 0x00, 0x07, 0xE9, 0x01]; // approximate
|
||||
let pts = parse_timestamp(&data2);
|
||||
assert!(pts >= 0);
|
||||
assert!(pts.is_some() && pts.unwrap() >= 0);
|
||||
|
||||
// Invalid marker bits → returns None
|
||||
let bad = [0x00, 0x00, 0x00, 0x00, 0x00]; // marker bits wrong
|
||||
assert_eq!(parse_timestamp(&bad), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! BD Transport Stream muxer — PES frames → 192-byte BD-TS packets.
|
||||
//!
|
||||
//! Takes PES frames and writes them as BD-TS (Blu-ray transport stream)
|
||||
//! packets. Each frame is wrapped in a PES header, split into TS packets,
|
||||
//! and prepended with the 4-byte TP_extra_header.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
const TS_PAYLOAD: usize = 184;
|
||||
|
||||
pub struct TsMuxer<W: Write> {
|
||||
writer: W,
|
||||
pids: Vec<u16>,
|
||||
continuity: Vec<u8>, // per-PID continuity counter (0-15)
|
||||
codec_privates: Vec<Option<Vec<u8>>>, // per-track codec_private (for video parameter sets)
|
||||
params_written: Vec<bool>, // per-track: have we written parameter sets?
|
||||
}
|
||||
|
||||
impl<W: Write> TsMuxer<W> {
|
||||
pub fn new(writer: W, pids: &[u16]) -> Self {
|
||||
let n = pids.len();
|
||||
Self {
|
||||
writer,
|
||||
pids: pids.to_vec(),
|
||||
continuity: vec![0u8; n],
|
||||
codec_privates: vec![None; n],
|
||||
params_written: vec![false; n],
|
||||
}
|
||||
}
|
||||
|
||||
/// Set codec_private data for a track. Used to prepend VPS/SPS/PPS
|
||||
/// as Annex B NALs before the first keyframe in the transport stream.
|
||||
pub fn set_codec_private(&mut self, track: usize, data: Vec<u8>) {
|
||||
if track < self.codec_privates.len() {
|
||||
self.codec_privates[track] = Some(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a PES frame as BD-TS packets.
|
||||
/// Video frame data is expected as length-prefixed NALUs (MKV/PES format)
|
||||
/// and is converted to Annex B for transport stream.
|
||||
pub fn write_frame(
|
||||
&mut self,
|
||||
track: usize,
|
||||
pts_ns: i64,
|
||||
data: &[u8],
|
||||
) -> io::Result<()> {
|
||||
if track >= self.pids.len() {
|
||||
return Ok(()); // unknown track, skip
|
||||
}
|
||||
let pid = self.pids[track];
|
||||
let is_video = (0x1011..=0x101F).contains(&pid);
|
||||
|
||||
// For video: convert length-prefixed NALUs to Annex B (start codes)
|
||||
// On first keyframe, prepend parameter sets from codec_private
|
||||
let es_data = if is_video && !data.is_empty() {
|
||||
let mut annex_b = Vec::new();
|
||||
// Prepend codec_private parameter sets on first keyframe
|
||||
if !self.params_written[track] {
|
||||
if let Some(ref cp) = self.codec_privates[track] {
|
||||
if let Some(params) = hvcc_to_annex_b(cp) {
|
||||
annex_b.extend_from_slice(¶ms);
|
||||
self.params_written[track] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
annex_b.extend_from_slice(&length_prefixed_to_annex_b(data));
|
||||
annex_b
|
||||
} else {
|
||||
data.to_vec()
|
||||
};
|
||||
|
||||
// Build PES packet: header + data
|
||||
let pts_90k = if pts_ns >= 0 {
|
||||
(pts_ns as u64).saturating_mul(9) / 100_000
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let pes_header = build_pes_header(pid, pts_90k, es_data.len());
|
||||
let pes_packet = [&pes_header[..], &es_data[..]].concat();
|
||||
|
||||
// Split into TS packets
|
||||
let mut offset = 0;
|
||||
let mut first = true;
|
||||
while offset < pes_packet.len() {
|
||||
let remaining = pes_packet.len() - offset;
|
||||
let payload_len = remaining.min(TS_PAYLOAD);
|
||||
let need_stuffing = payload_len < TS_PAYLOAD;
|
||||
|
||||
// TP_extra_header (4 bytes — arrival time, set to 0)
|
||||
let tp_extra = [0u8; 4];
|
||||
|
||||
// TS header (4 bytes)
|
||||
let cc = self.continuity[track];
|
||||
self.continuity[track] = (cc + 1) & 0x0F;
|
||||
|
||||
let mut ts_header = [0u8; 4];
|
||||
ts_header[0] = SYNC_BYTE;
|
||||
ts_header[1] = ((pid >> 8) as u8) & 0x1F;
|
||||
if first {
|
||||
ts_header[1] |= 0x40; // PUSI
|
||||
}
|
||||
ts_header[2] = pid as u8;
|
||||
ts_header[3] = 0x10 | cc; // no adaptation, has payload
|
||||
|
||||
if need_stuffing {
|
||||
// Adaptation field for stuffing
|
||||
let stuff_len = TS_PAYLOAD - payload_len;
|
||||
ts_header[3] = 0x30 | cc; // adaptation + payload
|
||||
|
||||
self.writer.write_all(&tp_extra)?;
|
||||
self.writer.write_all(&ts_header)?;
|
||||
|
||||
// Write adaptation field: length byte + flags byte + 0xFF padding
|
||||
// stuff_len == 1: AF length = 0 (just the length byte, no flags)
|
||||
// stuff_len >= 2: AF length = stuff_len-1, flags = 0, rest 0xFF
|
||||
static STUFF_FF: [u8; 184] = [0xFF; 184];
|
||||
if stuff_len == 1 {
|
||||
self.writer.write_all(&[0u8])?; // adaptation_field_length = 0
|
||||
} else {
|
||||
self.writer.write_all(&[(stuff_len - 1) as u8])?; // AF length
|
||||
self.writer.write_all(&[0u8])?; // flags
|
||||
if stuff_len > 2 {
|
||||
self.writer.write_all(&STUFF_FF[..stuff_len - 2])?;
|
||||
}
|
||||
}
|
||||
self.writer.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||
} else {
|
||||
self.writer.write_all(&tp_extra)?;
|
||||
self.writer.write_all(&ts_header)?;
|
||||
self.writer.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||
}
|
||||
|
||||
offset += payload_len;
|
||||
first = false;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> io::Result<()> {
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a PES packet header for a BD stream.
|
||||
fn build_pes_header(pid: u16, pts_90k: u64, data_len: usize) -> Vec<u8> {
|
||||
// Determine stream_id from PID range
|
||||
let stream_id: u8 = if (0x1011..=0x101F).contains(&pid) {
|
||||
0xE0 // video
|
||||
} else {
|
||||
0xBD // audio, PGS subtitle, or default (private stream 1)
|
||||
};
|
||||
|
||||
let pes_data_len = data_len + 8; // 3 header bytes + 5 PTS bytes + data
|
||||
let mut header = Vec::with_capacity(14);
|
||||
|
||||
// Start code: 00 00 01 stream_id
|
||||
header.push(0x00);
|
||||
header.push(0x00);
|
||||
header.push(0x01);
|
||||
header.push(stream_id);
|
||||
|
||||
// PES packet length (0 = unbounded for video or if too large for u16)
|
||||
if stream_id == 0xE0 || pes_data_len > 65535 {
|
||||
header.push(0x00);
|
||||
header.push(0x00);
|
||||
} else {
|
||||
let len = pes_data_len as u16;
|
||||
header.push((len >> 8) as u8);
|
||||
header.push(len as u8);
|
||||
}
|
||||
|
||||
// Flags: 10xx xxxx — MPEG-2, PTS present
|
||||
header.push(0x80); // marker bits
|
||||
header.push(0x80); // PTS present
|
||||
|
||||
// PES header data length
|
||||
header.push(5); // 5 bytes of PTS
|
||||
|
||||
// PTS (5 bytes, 33-bit timestamp with markers)
|
||||
let pts = pts_90k & 0x1_FFFF_FFFF;
|
||||
header.push(0x21 | (((pts >> 29) & 0x0E) as u8));
|
||||
header.push(((pts >> 22) & 0xFF) as u8);
|
||||
header.push(0x01 | (((pts >> 14) & 0xFE) as u8));
|
||||
header.push(((pts >> 7) & 0xFF) as u8);
|
||||
header.push(0x01 | (((pts << 1) & 0xFE) as u8));
|
||||
|
||||
header
|
||||
}
|
||||
|
||||
/// Extract NAL arrays from HEVCDecoderConfigurationRecord and convert to Annex B.
|
||||
/// Returns VPS + SPS + PPS as Annex B NAL units (00 00 00 01 + NAL).
|
||||
fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
// HEVCDecoderConfigurationRecord: 22 bytes header, then NAL arrays
|
||||
if hvcc.len() < 23 {
|
||||
return None;
|
||||
}
|
||||
let num_arrays = hvcc[22] as usize;
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 23;
|
||||
|
||||
for _ in 0..num_arrays {
|
||||
if offset + 3 > hvcc.len() { break; }
|
||||
// array: 1 byte (completeness + NAL type), 2 bytes (numNalus)
|
||||
let _nal_type = hvcc[offset] & 0x3F;
|
||||
let num_nalus = u16::from_be_bytes([hvcc[offset + 1], hvcc[offset + 2]]) as usize;
|
||||
offset += 3;
|
||||
|
||||
for _ in 0..num_nalus {
|
||||
if offset + 2 > hvcc.len() { break; }
|
||||
let nal_len = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
|
||||
offset += 2;
|
||||
if offset + nal_len > hvcc.len() { break; }
|
||||
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
out.extend_from_slice(&hvcc[offset..offset + nal_len]);
|
||||
offset += nal_len;
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
/// Convert length-prefixed NALUs (4-byte BE length + NAL) to Annex B
|
||||
/// (00 00 00 01 + NAL). Used for video elementary streams in TS.
|
||||
fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut offset = 0;
|
||||
while offset + 4 <= data.len() {
|
||||
let len = u32::from_be_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3],
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
if offset + len > data.len() {
|
||||
break;
|
||||
}
|
||||
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
out.extend_from_slice(&data[offset..offset + len]);
|
||||
offset += len;
|
||||
}
|
||||
// If data doesn't look like length-prefixed NALs (no valid parse),
|
||||
// return original data unchanged — it may already be Annex B.
|
||||
if out.is_empty() && !data.is_empty() {
|
||||
return data.to_vec();
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! TsDemuxReader — reads from any source, demuxes BD-TS, produces PES frames.
|
||||
//!
|
||||
//! Wraps any Read source with a TsDemuxer + CodecParsers.
|
||||
//! One implementation used by M2TS, Network, Stdio, and any other BD-TS input.
|
||||
|
||||
use super::codec::{self, CodecParser};
|
||||
use super::ts::TsDemuxer;
|
||||
use crate::disc::Stream as DiscStream;
|
||||
use crate::pes::PesFrame;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read};
|
||||
|
||||
const READ_BUF_SIZE: usize = 192 * 1024; // 1024 BD-TS packets
|
||||
|
||||
/// Generic BD-TS → PES frame reader.
|
||||
pub struct TsDemuxReader<R: Read> {
|
||||
reader: R,
|
||||
demuxer: TsDemuxer,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
pending: VecDeque<PesFrame>,
|
||||
buf: Vec<u8>,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl<R: Read> TsDemuxReader<R> {
|
||||
/// Create from a reader and stream metadata.
|
||||
pub fn new(reader: R, streams: &[DiscStream]) -> Self {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
let (pid, c) = match s {
|
||||
DiscStream::Video(v) => (v.pid, v.codec),
|
||||
DiscStream::Audio(a) => (a.pid, a.codec),
|
||||
DiscStream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, codec::parser_for_codec(c)));
|
||||
}
|
||||
|
||||
Self {
|
||||
reader,
|
||||
demuxer: TsDemuxer::new(&pids),
|
||||
parsers,
|
||||
pid_to_track,
|
||||
pending: VecDeque::new(),
|
||||
buf: vec![0u8; READ_BUF_SIZE],
|
||||
eof: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next PES frame. Returns None at EOF.
|
||||
pub fn next_frame(&mut self) -> io::Result<Option<PesFrame>> {
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
loop {
|
||||
let n = self.reader.read(&mut self.buf)?;
|
||||
if n == 0 {
|
||||
self.eof = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let packets = self.demuxer.feed(&self.buf[..n]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending.push_back(PesFrame::from_codec_frame(*track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec private data for a track.
|
||||
pub fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self.pid_to_track.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
/// True when all primary video tracks have codec_private.
|
||||
pub fn headers_ready(&self, streams: &[DiscStream]) -> bool {
|
||||
for (idx, s) in streams.iter().enumerate() {
|
||||
if let DiscStream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
//! Stream — read PES frames in, write PES frames out.
|
||||
//!
|
||||
//! A stream is a stream. You read() from it or write() to it.
|
||||
//! The stream handles its own format internally.
|
||||
//!
|
||||
//! disc.read() → PES frame (sectors → decrypt → demux internally)
|
||||
//! mkv.write(frame) → MKV file (mux internally)
|
||||
|
||||
/// One frame of elementary stream data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PesFrame {
|
||||
/// Track index (0-based, matches stream info track order).
|
||||
pub track: usize,
|
||||
/// Presentation timestamp in nanoseconds.
|
||||
pub pts: i64,
|
||||
/// True if this is a keyframe (IDR for video).
|
||||
pub keyframe: bool,
|
||||
/// Raw elementary stream data (NAL units, audio samples, etc).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PesFrame {
|
||||
/// Serialize to bytes: track(1) | pts(8) | keyframe(1) | len(4) | data
|
||||
pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
|
||||
if self.track > 255 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"track index exceeds 255",
|
||||
));
|
||||
}
|
||||
if self.data.len() > u32::MAX as usize {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"frame data exceeds 4 GB",
|
||||
));
|
||||
}
|
||||
w.write_all(&[self.track as u8])?;
|
||||
w.write_all(&self.pts.to_le_bytes())?;
|
||||
w.write_all(&[if self.keyframe { 1 } else { 0 }])?;
|
||||
w.write_all(&(self.data.len() as u32).to_le_bytes())?;
|
||||
w.write_all(&self.data)
|
||||
}
|
||||
|
||||
/// Deserialize from bytes. Returns None at EOF.
|
||||
pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result<Option<Self>> {
|
||||
const MAX_FRAME_SIZE: usize = 256 * 1024 * 1024; // 256 MB
|
||||
|
||||
let mut header = [0u8; 14]; // 1 + 8 + 1 + 4
|
||||
match r.read_exact(&mut header) {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let track = header[0] as usize;
|
||||
let pts = i64::from_le_bytes([
|
||||
header[1], header[2], header[3], header[4],
|
||||
header[5], header[6], header[7], header[8],
|
||||
]);
|
||||
let keyframe = header[9] != 0;
|
||||
let len = u32::from_le_bytes([header[10], header[11], header[12], header[13]]) as usize;
|
||||
if len > MAX_FRAME_SIZE {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("frame size {} exceeds maximum {}", len, MAX_FRAME_SIZE),
|
||||
));
|
||||
}
|
||||
let mut data = vec![0u8; len];
|
||||
r.read_exact(&mut data)?;
|
||||
Ok(Some(Self { track, pts, keyframe, data }))
|
||||
}
|
||||
|
||||
/// Create from a codec::Frame with a track index.
|
||||
pub fn from_codec_frame(track: usize, frame: crate::mux::codec::Frame) -> Self {
|
||||
Self {
|
||||
track,
|
||||
pts: frame.pts_ns,
|
||||
keyframe: frame.keyframe,
|
||||
data: frame.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream. Read from it or write to it. Not both.
|
||||
pub trait Stream {
|
||||
/// Read the next frame. Returns None at end of stream.
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
|
||||
|
||||
/// Write a frame.
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
|
||||
|
||||
/// Finalize (flush, write index, close).
|
||||
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 }
|
||||
}
|
||||
@@ -178,7 +178,7 @@ impl Mt1959 {
|
||||
|
||||
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let mut unlocked = false;
|
||||
for _attempt in 0..6 {
|
||||
for _attempt in 0..3 {
|
||||
match self.do_unlock(scsi) {
|
||||
Ok(_) => {
|
||||
unlocked = true;
|
||||
@@ -188,15 +188,17 @@ impl Mt1959 {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Err(_) => {
|
||||
let ok = if self.mode == MODE_A {
|
||||
let loaded = if self.mode == MODE_A {
|
||||
variant_a::load_firmware(self, scsi).is_ok()
|
||||
} else {
|
||||
variant_b::load_firmware(self, scsi).is_ok()
|
||||
};
|
||||
if ok {
|
||||
unlocked = true;
|
||||
break;
|
||||
if !loaded {
|
||||
continue;
|
||||
}
|
||||
// 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<()> {
|
||||
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 {
|
||||
return Ok(());
|
||||
|
||||
+194
-22
@@ -5,9 +5,13 @@ use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
const SG_IO: u32 = 0x2285;
|
||||
const SG_SCSI_RESET: u32 = 0x2284;
|
||||
const SG_SCSI_RESET_DEVICE: i32 = 1;
|
||||
const SG_DXFER_NONE: i32 = -1;
|
||||
const SG_DXFER_TO_DEV: i32 = -2;
|
||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||
const SG_FLAG_DIRECT_IO: u32 = 1;
|
||||
const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
@@ -36,18 +40,24 @@ struct sg_io_hdr {
|
||||
info: u32,
|
||||
}
|
||||
|
||||
// Compile-time validation: sg_io_hdr must match the kernel's layout.
|
||||
// 64 bytes on 64-bit, 44 bytes on 32-bit (pointer-size dependent).
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 88);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
|
||||
|
||||
pub struct SgIoTransport {
|
||||
fd: i32,
|
||||
}
|
||||
|
||||
impl SgIoTransport {
|
||||
/// Open a SCSI device for use. Resets the drive first to ensure
|
||||
/// a known good state, then opens a fresh fd for commands.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_bytes = device.as_os_str().as_bytes();
|
||||
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
|
||||
c_path.extend_from_slice(path_bytes);
|
||||
c_path.push(0);
|
||||
|
||||
let device = Self::resolve_to_sg(device);
|
||||
Self::reset(&device)?;
|
||||
let c_path = Self::to_c_path(&device);
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
@@ -55,29 +65,183 @@ impl SgIoTransport {
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::DevicePermission {
|
||||
path: format!(
|
||||
"{}: permission denied (try running as root)",
|
||||
device.display()
|
||||
),
|
||||
}
|
||||
} else {
|
||||
Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
}
|
||||
});
|
||||
return Self::open_error(&device);
|
||||
}
|
||||
Ok(SgIoTransport { fd })
|
||||
}
|
||||
|
||||
/// Reset the drive to a known good state — equivalent to unplug/replug.
|
||||
/// After reset, the drive is clean and no fd is held open.
|
||||
///
|
||||
/// ## Why each step exists
|
||||
///
|
||||
/// When a process is killed (SIGKILL/kill -9) mid-SG_IO ioctl, two things
|
||||
/// go wrong: (1) the kernel's SG driver may have stale pending commands
|
||||
/// queued for the dead process's fd, and (2) the drive firmware may still
|
||||
/// be mid-operation (seeking, reading, processing a vendor command).
|
||||
///
|
||||
/// A new process opening the same /dev/sg* device gets a fresh fd, but the
|
||||
/// kernel doesn't automatically abort the dead process's commands — the
|
||||
/// drive can appear hung on the first SCSI command.
|
||||
///
|
||||
/// Additionally, killed processes skip Drop, so the tray may be locked
|
||||
/// via PREVENT MEDIUM REMOVAL with no process alive to unlock it.
|
||||
///
|
||||
/// ## Sequence
|
||||
///
|
||||
/// 1. **open** — allocates kernel SG state for this fd
|
||||
/// 2. **close** — triggers kernel cleanup: aborts any pending SG_IO
|
||||
/// commands associated with this fd. The key operation —
|
||||
/// the kernel's sg_release() cancels queued commands.
|
||||
/// 3. **sleep 2s** — the drive firmware needs time to finish/abort whatever
|
||||
/// it was doing when the previous process died. Without
|
||||
/// this, the next command may block on drive-internal state.
|
||||
/// 4. **open** — fresh fd with no stale commands in the kernel queue
|
||||
/// 5. **unlock** — ALLOW MEDIUM REMOVAL (CDB 0x1E, prevent=0). Clears
|
||||
/// any tray lock left by a killed process that never
|
||||
/// ran its Drop/cleanup.
|
||||
/// 6. **TUR** — TEST UNIT READY (CDB 0x00) with 3s timeout. If the
|
||||
/// drive responds, it's in a good state.
|
||||
/// 7. **escalate** — if TUR fails:
|
||||
/// - SG_SCSI_RESET (device level) — kernel sends a SCSI
|
||||
/// bus reset to the device, clearing all firmware state.
|
||||
/// - STOP + START UNIT (CDB 0x1B) — power-cycles the
|
||||
/// drive's logical unit, like pressing the eject button
|
||||
/// and reinserting.
|
||||
/// 8. **close** — release the fd. Drive is clean, nobody holds it.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
let c_path = Self::to_c_path(device);
|
||||
|
||||
// Step 1-2: open + close — flush stale kernel SG_IO state
|
||||
let probe_fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if probe_fd >= 0 {
|
||||
unsafe { libc::close(probe_fd) };
|
||||
}
|
||||
|
||||
// Step 3: let drive settle
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
|
||||
// Step 4: open clean fd
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Self::open_error(device);
|
||||
}
|
||||
|
||||
// Step 5: unlock tray
|
||||
let _ = Self::raw_command(fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
||||
|
||||
// Step 6: TUR — if drive responds, we're done
|
||||
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
|
||||
// Step 7: escalate — SG_SCSI_RESET
|
||||
let mut reset_type: i32 = SG_SCSI_RESET_DEVICE;
|
||||
unsafe { libc::ioctl(fd, SG_SCSI_RESET as _, &mut reset_type) };
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
|
||||
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
|
||||
// STOP + START
|
||||
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x00, 0], 3_000);
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x01, 0], 3_000);
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
let _ = Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: close — drive is clean
|
||||
unsafe { libc::close(fd) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_error<T>(device: &Path) -> Result<T> {
|
||||
let err = std::io::Error::last_os_error();
|
||||
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::DevicePermission {
|
||||
path: format!("{}: permission denied (try running as root)", device.display()),
|
||||
}
|
||||
} else {
|
||||
Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a raw SCSI command on an fd. Used by reset() before the
|
||||
/// transport is constructed.
|
||||
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> std::result::Result<(), ()> {
|
||||
let mut sense = [0u8; 32];
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = SG_DXFER_NONE;
|
||||
hdr.cmd_len = cdb.len().min(16) as u8;
|
||||
hdr.mx_sb_len = sense.len() as u8;
|
||||
hdr.dxfer_len = 0;
|
||||
hdr.dxferp = std::ptr::null_mut();
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
if ret < 0 || hdr.status != 0 {
|
||||
Err(())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_c_path(device: &Path) -> Vec<u8> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_bytes = device.as_os_str().as_bytes();
|
||||
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
|
||||
c_path.extend_from_slice(path_bytes);
|
||||
c_path.push(0);
|
||||
c_path
|
||||
}
|
||||
|
||||
/// Resolve /dev/sr* -> /dev/sg* via sysfs. If already sg, returns as-is.
|
||||
/// Falls back to the original path if resolution fails.
|
||||
fn resolve_to_sg(device: &Path) -> std::path::PathBuf {
|
||||
let dev_name = match device.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => return device.to_path_buf(),
|
||||
};
|
||||
|
||||
if dev_name.starts_with("sg") {
|
||||
return device.to_path_buf();
|
||||
}
|
||||
|
||||
if dev_name.starts_with("sr") {
|
||||
let sg_dir = format!("/sys/class/block/{}/device/scsi_generic", dev_name);
|
||||
if let Ok(mut entries) = std::fs::read_dir(&sg_dir) {
|
||||
if let Some(Ok(entry)) = entries.next() {
|
||||
let sg_name = entry.file_name();
|
||||
return std::path::PathBuf::from(format!(
|
||||
"/dev/{}",
|
||||
sg_name.to_string_lossy()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
device.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
libc::close(self.fd);
|
||||
}
|
||||
// Unlock tray before closing — don't leave it locked
|
||||
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +281,14 @@ impl ScsiTransport for SgIoTransport {
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
if dxfer_direction == SG_DXFER_FROM_DEV
|
||||
&& data.len() >= 4096
|
||||
&& (data.as_ptr() as usize) % 4096 == 0
|
||||
{
|
||||
hdr.flags = SG_FLAG_DIRECT_IO | SG_FLAG_Q_AT_HEAD;
|
||||
} else {
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
}
|
||||
|
||||
let ret = unsafe { libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
|
||||
|
||||
@@ -248,6 +248,24 @@ impl MacScsiTransport {
|
||||
exclusive: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reset the drive to a known good state.
|
||||
/// On macOS, we open the device, release exclusive access, wait for
|
||||
/// the system to reclaim it, then the next open() re-acquires.
|
||||
/// IOKit's USB layer handles device-level resets internally when the
|
||||
/// exclusive access is released and re-acquired.
|
||||
///
|
||||
/// NOTE: untested — macOS reset may need IOUSBDeviceInterface::ResetDevice()
|
||||
/// for USB drives. This is a best-effort implementation.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
// Opening and immediately dropping triggers release of exclusive access
|
||||
// which forces IOKit to reset the device state.
|
||||
if let Ok(transport) = Self::open(device) {
|
||||
drop(transport); // Drop releases exclusive access + closes plugin
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MacScsiTransport {
|
||||
|
||||
+26
-1
@@ -60,7 +60,7 @@ pub trait ScsiTransport: Send {
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
|
||||
// ── Platform-agnostic open ──────────────────────────────────────────────────
|
||||
// ── Platform-agnostic open / reset ──────────────────────────────────────────
|
||||
|
||||
/// Open a SCSI transport for the given device path.
|
||||
/// Selects the right backend for the current platform.
|
||||
@@ -88,6 +88,31 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset a SCSI device to a known good state. Platform-specific.
|
||||
/// On Linux: open/close fd cycle + TUR + SG_SCSI_RESET escalation.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::SgIoTransport::reset(device)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::MacScsiTransport::reset(device)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::SptiTransport::reset(device)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = device;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
|
||||
|
||||
/// SCSI INQUIRY response.
|
||||
|
||||
@@ -136,6 +136,55 @@ impl SptiTransport {
|
||||
|
||||
Ok(SptiTransport { handle })
|
||||
}
|
||||
|
||||
/// Reset the drive to a known good state.
|
||||
/// Opens the device, sends IOCTL_STORAGE_RESET_DEVICE to reset
|
||||
/// the USB/SCSI bus, then closes. Same concept as SG_SCSI_RESET on Linux.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
const IOCTL_STORAGE_RESET_DEVICE: u32 = 0x002D1004;
|
||||
|
||||
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
let win_path = normalize_device_path(dev_str);
|
||||
let wide: Vec<u16> = win_path.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// Open
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Ok(()); // can't open — skip reset, not fatal
|
||||
}
|
||||
|
||||
// Send device reset
|
||||
let mut returned: u32 = 0;
|
||||
unsafe {
|
||||
DeviceIoControl(
|
||||
handle,
|
||||
IOCTL_STORAGE_RESET_DEVICE,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
&mut returned,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
|
||||
// Close and wait for drive to settle
|
||||
unsafe { CloseHandle(handle) };
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SptiTransport {
|
||||
|
||||
+47
-1
@@ -7,8 +7,54 @@
|
||||
use crate::error::Result;
|
||||
|
||||
/// Read 2048-byte sectors from a disc or disc image.
|
||||
pub trait SectorReader {
|
||||
pub trait SectorReader: Send {
|
||||
/// Read `count` sectors starting at `lba` into `buf`.
|
||||
/// `buf` must be at least `count * 2048` bytes.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
|
||||
|
||||
/// Total capacity in sectors, if known.
|
||||
fn capacity(&self) -> u32 { 0 }
|
||||
}
|
||||
|
||||
/// SectorReader backed by a file (ISO image).
|
||||
/// Seeks to lba * 2048, reads count * 2048 bytes.
|
||||
pub struct FileSectorReader {
|
||||
file: std::io::BufReader<std::fs::File>,
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl FileSectorReader {
|
||||
pub fn open(path: &str) -> std::io::Result<Self> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let len = file.metadata()?.len();
|
||||
let sectors = len / 2048;
|
||||
if sectors > u32::MAX as u64 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("{path}: image too large, max ~8 TB"),
|
||||
));
|
||||
}
|
||||
let capacity = sectors as u32;
|
||||
Ok(Self {
|
||||
file: std::io::BufReader::with_capacity(4 * 1024 * 1024, file),
|
||||
capacity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for FileSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let offset = lba as u64 * 2048;
|
||||
let bytes = count as usize * 2048;
|
||||
self.file.seek(SeekFrom::Start(offset))
|
||||
.map_err(|e| crate::error::Error::IoError { source: e })?;
|
||||
self.file.read_exact(&mut buf[..bytes])
|
||||
.map_err(|e| crate::error::Error::IoError { source: e })?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -187,6 +187,50 @@ impl UdfFs {
|
||||
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(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
|
||||
+57
-20
@@ -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 library's constant — this IS the cross-validation reference value.
|
||||
const CROSS_AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3,
|
||||
0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
/// Build a plaintext aligned unit with TS sync markers and recognisable
|
||||
@@ -373,8 +372,8 @@ const CROSS_AACS_IV: [u8; 16] = [
|
||||
#[test]
|
||||
fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
let unit_key: [u8; 16] = [
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
|
||||
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32,
|
||||
0x10,
|
||||
];
|
||||
|
||||
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 {
|
||||
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
|
||||
assert_ne!(
|
||||
@@ -414,7 +417,10 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
|
||||
// -- Decrypt with the library --
|
||||
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");
|
||||
|
||||
// Compare (byte 0 flag was cleared)
|
||||
@@ -432,8 +438,8 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
#[test]
|
||||
fn aacs_cross_validation_alternate_key() {
|
||||
let unit_key: [u8; 16] = [
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||
0x08,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
|
||||
@@ -452,7 +458,11 @@ fn aacs_cross_validation_alternate_key() {
|
||||
for i in 0..16 {
|
||||
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));
|
||||
|
||||
@@ -469,8 +479,8 @@ fn aacs_cross_validation_alternate_key() {
|
||||
#[test]
|
||||
fn aacs_bus_decrypt_cross_validation() {
|
||||
let read_data_key: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00,
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
||||
0x00,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
@@ -552,10 +562,22 @@ fn css_roundtrip_with_snapshot() {
|
||||
#[test]
|
||||
fn css_roundtrip_multiple_keys() {
|
||||
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]),
|
||||
([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]),
|
||||
([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]),
|
||||
(
|
||||
[0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
[0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
),
|
||||
(
|
||||
[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() {
|
||||
@@ -594,11 +616,26 @@ fn css_roundtrip_multiple_keys() {
|
||||
#[test]
|
||||
fn css_stevenson_attack_validates_cracked_key() {
|
||||
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]),
|
||||
([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]),
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[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;
|
||||
|
||||
+132
-6
@@ -143,9 +143,15 @@ fn scan_options_with_keydb_pathbuf() {
|
||||
|
||||
// ── 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 {
|
||||
playlist: "00800.mpls".into(),
|
||||
playlist_id: 800,
|
||||
@@ -155,8 +161,8 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma
|
||||
streams: vec![Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec,
|
||||
resolution: resolution.into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -165,6 +171,7 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,13 +198,13 @@ fn disc_title_duration_display_edge_cases() {
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -368,6 +375,125 @@ fn resolve_encryption_no_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]
|
||||
fn resolve_encryption_no_keydb() {
|
||||
// A UDF image with /AACS directory but no keydb path -> aacs is None
|
||||
|
||||
+46
-39
@@ -15,8 +15,8 @@ fn sample_disc_title() -> DiscTitle {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -25,18 +25,18 @@ fn sample_disc_title() -> DiscTitle {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "7.1".into(),
|
||||
channels: AudioChannels::Surround71,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "English Atmos".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1101,
|
||||
codec: Codec::Ac3,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "fra".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "French".into(),
|
||||
}),
|
||||
@@ -51,6 +51,7 @@ fn sample_disc_title() -> DiscTitle {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +177,7 @@ fn open_input_network_no_port_errors() {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("expected error"),
|
||||
};
|
||||
assert!(msg.contains("missing port"), "got: {}", msg);
|
||||
assert!(msg.contains("PES pipeline") || msg.contains("missing port"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,7 +202,7 @@ fn m2ts_meta_roundtrip() {
|
||||
// Check video
|
||||
if let Stream::Video(v) = &restored.streams[0] {
|
||||
assert_eq!(v.codec, Codec::Hevc);
|
||||
assert_eq!(v.resolution, "2160p");
|
||||
assert_eq!(v.resolution, Resolution::R2160p);
|
||||
assert_eq!(v.label, "Main");
|
||||
} else {
|
||||
panic!("expected video");
|
||||
@@ -413,8 +414,8 @@ fn meta_codec_roundtrip() {
|
||||
streams.push(Stream::Video(VideoStream {
|
||||
pid: (0x1011 + i) as u16,
|
||||
codec,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -425,9 +426,9 @@ fn meta_codec_roundtrip() {
|
||||
streams.push(Stream::Audio(AudioStream {
|
||||
pid: (0x1100 + i) as u16,
|
||||
codec,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}));
|
||||
@@ -452,6 +453,7 @@ fn meta_codec_roundtrip() {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
@@ -486,6 +488,7 @@ fn meta_empty_streams() {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
@@ -505,12 +508,13 @@ fn meta_all_stream_types() {
|
||||
clips: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
streams: vec![
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -519,9 +523,9 @@ fn meta_all_stream_types() {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "7.1".into(),
|
||||
channels: AudioChannels::Surround71,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "Primary Audio".into(),
|
||||
}),
|
||||
@@ -535,9 +539,9 @@ fn meta_all_stream_types() {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1110,
|
||||
codec: Codec::Ac3,
|
||||
channels: "stereo".into(),
|
||||
channels: AudioChannels::Stereo,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: true,
|
||||
label: "Commentary".into(),
|
||||
}),
|
||||
@@ -553,7 +557,7 @@ fn meta_all_stream_types() {
|
||||
// Video preserved
|
||||
if let Stream::Video(v) = &restored.streams[0] {
|
||||
assert_eq!(v.codec, Codec::Hevc);
|
||||
assert_eq!(v.resolution, "2160p");
|
||||
assert_eq!(v.resolution, Resolution::R2160p);
|
||||
assert_eq!(v.label, "Primary");
|
||||
assert!(!v.secondary);
|
||||
} else {
|
||||
@@ -563,7 +567,7 @@ fn meta_all_stream_types() {
|
||||
// Primary audio preserved
|
||||
if let Stream::Audio(a) = &restored.streams[1] {
|
||||
assert_eq!(a.codec, Codec::TrueHd);
|
||||
assert_eq!(a.channels, "7.1");
|
||||
assert_eq!(a.channels, AudioChannels::Surround71);
|
||||
assert!(!a.secondary);
|
||||
} else {
|
||||
panic!("expected audio");
|
||||
@@ -642,15 +646,16 @@ fn mkvstream_roundtrip_bdts() {
|
||||
streams: vec![Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::Ac3,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "English".into(),
|
||||
})],
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let output = Cursor::new(Vec::new());
|
||||
@@ -688,8 +693,8 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::H264,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -698,18 +703,18 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::Ac3,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "English".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1101,
|
||||
codec: Codec::DtsHdMa,
|
||||
channels: "7.1".into(),
|
||||
channels: AudioChannels::Surround71,
|
||||
language: "fra".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "French".into(),
|
||||
}),
|
||||
@@ -731,6 +736,7 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let output = Cursor::new(Vec::new());
|
||||
@@ -781,8 +787,8 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
streams: vec![Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::H264,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -791,6 +797,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
// Build synthetic BD-TS packets containing valid H.264 NALs
|
||||
@@ -803,7 +810,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
// SPS NAL (type 7): minimal valid SPS
|
||||
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
|
||||
es_data.push(0x67); // NAL type 7 (SPS), nal_ref_idc=3
|
||||
// Minimal SPS payload: profile_idc=66 (Baseline), constraint flags, level_idc=30
|
||||
// Minimal SPS payload: profile_idc=66 (Baseline), constraint flags, level_idc=30
|
||||
es_data.extend_from_slice(&[
|
||||
0x42, 0xC0, 0x1E, // profile=66, constraint_set0=1, level=30
|
||||
0xD9, 0x00, 0xA0, 0x47, 0xFE, 0x88, // minimal SPS rbsp
|
||||
@@ -817,7 +824,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
// IDR NAL (type 5): keyframe
|
||||
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
|
||||
es_data.push(0x65); // NAL type 5 (IDR), nal_ref_idc=3
|
||||
// Some IDR slice data
|
||||
// Some IDR slice data
|
||||
es_data.extend_from_slice(&[0x88, 0x84, 0x00, 0x21, 0xFF, 0xFE, 0xF6, 0xE2]);
|
||||
// Pad to reasonable size
|
||||
es_data.extend_from_slice(&[0x00; 64]);
|
||||
@@ -961,7 +968,11 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
let data = output2.lock().unwrap().clone().into_inner();
|
||||
|
||||
// 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!(
|
||||
&data[0..4],
|
||||
&[0x1A, 0x45, 0xDF, 0xA3],
|
||||
@@ -970,17 +981,13 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
|
||||
// Verify output contains a Tracks element (0x1654AE6B)
|
||||
let tracks_needle = [0x16, 0x54, 0xAE, 0x6B];
|
||||
let has_tracks = data
|
||||
.windows(4)
|
||||
.any(|w| w == tracks_needle);
|
||||
let has_tracks = data.windows(4).any(|w| w == tracks_needle);
|
||||
assert!(has_tracks, "output should contain Tracks element");
|
||||
|
||||
// Verify codecPrivate is non-empty (not all zeros)
|
||||
// CodecPrivate element ID is 0x63A2
|
||||
let cp_needle = [0x63, 0xA2];
|
||||
let cp_pos = data
|
||||
.windows(2)
|
||||
.position(|w| w == cp_needle);
|
||||
let cp_pos = data.windows(2).position(|w| w == cp_needle);
|
||||
if let Some(pos) = cp_pos {
|
||||
// After the ID, there's a size VINT, then the data
|
||||
let after_id = pos + 2;
|
||||
|
||||
Reference in New Issue
Block a user