Compare commits

...
31 Commits
Author SHA1 Message Date
MattJackson bd7220c62b v0.10.0: PES pipeline audit, codec_privates on DiscTitle, streams not files 2026-04-15 17:22:52 +00:00
MattJackson 404c005e0b Remove Seek/File dependencies from stream readers
Streams are streams — they take impl Read, not Read+Seek or File.

- MkvStream::open takes impl Read (was Read+Seek)
  - EBML element skipping uses skip_bytes() instead of seek(Current)
  - Byte position tracking uses remaining-bytes counter, not stream_position()
  - Removed file_size from open (progress is CLI concern)
- M2tsStream::open takes impl Read (was Read+Seek)
  - Buffers first 1MB for FMKV header / PMT scan
  - Uses chain reader (buffered head + rest) for sequential reading
  - Duration unknown without seeking (0.0) — CLI can set from metadata
- Removed ReadSeek trait (no longer needed)
- WriteSeek kept (MKV muxer container format requires seeking internally)
2026-04-15 17:05:50 +00:00
MattJackson 87342290c5 Move codec_privates onto DiscTitle, eliminate duplicate methods
Design fix: codec_privates are now a field on DiscTitle, not a separate
parameter passed through the pipeline. This eliminates the root cause of
the network codec_private bug (forgot to pass the separate param).

API changes:
- output() takes (url, &DiscTitle) — no separate codec_privates param
- MkvOutputStream::create, M2tsOutputStream::create, NetworkOutputStream::connect
  all read codec_privates from title.codec_privates
- M2tsMeta::from_title() takes only &DiscTitle — reads privates from title
- Deleted from_title_with_privates (was the wrong-name duplicate)
- Merged read_header + read_header_from_stream into one read_header(impl Read)
- Deleted finish(self) from TsMuxer, keep only finish(&mut self)

Rule: ONE public method per action. No _with_X, _from_Y, _ref variants.
2026-04-15 16:52:06 +00:00
MattJackson 8bbf630d82 Fix all PES pipeline audit findings (20 issues)
Critical:
- C1: PES serialize validates track < 256 and data < 4GB
- C2: PES deserialize caps frame size at 256MB (OOM protection)
- C3: TsMuxer stuffing uses static buffer, no per-packet alloc
- C4: PES length uses unbounded (0x0000) for audio >65535 bytes
- C6: TsDemuxer validates AF length <= 183

Warning:
- W1: parse_timestamp validates marker bits, returns Option
- W2: PES header data_start clamped to data.len()
- W3: TsMuxer PTS conversion uses saturating_mul, rejects negative
- W4: AC3/DTS replace debug_assert with runtime bounds check
- W6: MKV block_vint handles 3-4 byte VINTs
- W7: meta.rs to_title() uses unwrap_or fallbacks instead of panic
- W8: MKV reader skips frames for non-existent tracks
- W9: DVD PTS uses higher-precision conversion (1e9/90000)
- FMKV read_header caps JSON at 10MB
- PAT section_len underflow guard

Suggestion:
- S2: TsMuxer uses static STUFF_FF buffer
- S3: HEVC parser single-pass NAL scan (was duplicated)
- S4: TsDemuxer caps remainder at one packet
- S5: PTS 90kHz→ns uses round-to-nearest
2026-04-15 16:22:28 +00:00
MattJackson 45dddc1810 Fix M2TS/MKV roundtrip: codec_private in FMKV header, Annex B conversion
Bug 1: M2TS roundtrip dropped frames — TsMuxer converts length-prefixed
NALs to Annex B, prepends VPS/SPS/PPS from HEVCDecoderConfigurationRecord.

Bug 2: MKV remux lost codec_private — MkvStream.codec_private() now returns
data from EBML header.

FMKV header carries codec_private (base64) per video stream for lossless
M2TS roundtrip.
2026-04-15 16:09:34 +00:00
MattJackson cfa80cb881 Fix test for network open_input error message change 2026-04-15 04:54:19 +00:00
MattJackson b510ae9b29 Fix M2TS/Network output: write FMKV header for roundtrip compatibility
- M2tsOutputStream writes FMKV metadata header before TS data
- NetworkOutputStream sends FMKV header on connect
- Enables M2TS→MKV and network roundtrip to work correctly
2026-04-15 04:50:19 +00:00
MattJackson 9a1c3cc218 NetworkStream PES-only: remove old IOStream/Read/Write interface
- Remove IOStream, Read, Write impls from NetworkStream
- PES write sends FMKV header before first frame (protocol fix)
- PES finish sends TCP shutdown for clean EOF
- Update tests to use PES roundtrip instead of byte-level
- Remove NetworkStream from open_input/open_output (use input/output)
2026-04-15 04:36:47 +00:00
MattJackson 9ae7d6b38a Fix audit findings: SCSI constants, sg_io_hdr assert, handshake cap, sector overflow check
- Replace magic SCSI opcodes with named constants (S2)
- Add compile-time sg_io_hdr size assertion — 88 bytes on 64-bit (W2)
- Cap handshake cert attempts at 16 (W8)
- Validate IsoSectorReader/FileSectorReader against u32 overflow for >8TB (S8)
- encrypt.rs: limit host cert loop iterations
2026-04-15 04:29:45 +00:00
MattJackson d983985faa Fix all clippy warnings: dead code, match patterns, type complexity, docs
- Remove unused pes_buf field from M2tsStream and unused TS_PACKET/BD_TS_PACKET constants
- Replace match-with-single-pattern with if let (3 instances in drive/mod.rs)
- Replace match-can-be-? with ? operator for scsi::open call
- Add type aliases PesSetup and MkvHeaderResult to reduce type complexity
- Collapse identical if/else branches in tsmux.rs build_pes_header
- Use RangeInclusive::contains instead of manual range checks
- Make WriteSeek trait pub (was pub(crate) but leaked through pub fn)
- Remove empty line after doc comment in disc.rs
- Fix doc list item indentation in scsi/linux.rs (12 instances)
2026-04-15 04:09:56 +00:00
MattJackson 56fe26c9b8 All streams complete — DVD PS demux, network/stdio PES, MKV input
- DiscStream: BD (TsDemuxer) or DVD (PsDemuxer) auto-detected
- NetworkStream: Stream impl with PES serialize/deserialize
- StdioStream: Stream impl with PES serialize/deserialize
- MkvStream: Stream read returns PesFrame from EBML blocks
- M2tsStream: Stream read via TsDemuxReader
- PesFrame: serialize/deserialize for wire format
- TsDemuxReader: shared BD-TS demux helper
- All inputs and outputs support PES
2026-04-15 04:03:56 +00:00
MattJackson 7a83f3244d Complete PES pipeline — all streams, clean API
- Unified Stream trait: read() and write() on one type
- PesFrame serialize/deserialize for wire format
- TsDemuxReader: shared BD-TS demux for any Read source
- MkvStream: PES read from MKV (EBML → PesFrame)
- M2tsStream: PES read via TsDemuxReader
- Network/Stdio output: PES serialization directly (no BD-TS wrap)
- Network/Stdio input: deferred (needs PES deserialization protocol)
- TsMuxer for M2TS output from PES frames
- input() and output() functions return Box<dyn Stream>
2026-04-15 03:56:15 +00:00
MattJackson 15f5b49fc3 M2TS implements Stream (read PES frames via TsDemux)
M2tsStream::read() → TsDemux → CodecParser → PesFrame.
M2TS input now works through PES pipeline.
MKV input deferred (needs EBML → PES extraction).
2026-04-15 03:41:03 +00:00
MattJackson ff6004a567 Unified Stream trait: read() and write() on one type
Stream trait: read() returns PesFrame, write() accepts PesFrame.
A stream is a stream — you read from it or write to it.
No separate Input/Output traits.

API: libfreemkv::input(url) and libfreemkv::output(url, title, codecs)
Returns Box<dyn Stream>.
2026-04-15 03:33:29 +00:00
MattJackson bd644d2f60 100% PES pipeline — all streams produce/consume PES frames
- TsMuxer: PES frames → BD-TS packets (new, reverse of TsDemuxer)
- M2tsOutputStream: PES → TsMuxer → file
- NetworkOutputStream: PES → TsMuxer → TCP
- StdioOutputStream: PES frames → stdout
- NullOutputStream: discard
- MkvOutputStream: PES → MKV mux
- All outputs via open_pes_output()
- All inputs via open_pes_input() (ISO, disc)
- No byte-level fallback — everything is PES
2026-04-15 03:19:03 +00:00
MattJackson ab63950ef1 PES pipeline: InputStream on DiscStream + IsoStream, MkvOutputStream
- DiscStream: next_frame() reads sectors → decrypts → demuxes → returns PesFrame
- IsoStream: same pattern, reads from ISO file
- MkvOutputStream: accepts PesFrame, writes MKV via MkvMuxer
- InputStream trait: next_frame(), info(), codec_private(), headers_ready()
- OutputStream trait: write_frame(), finish()
- FileSectorReader: SectorReader backed by a file
- PesFrame: track + pts + keyframe + data

Old Read/Write IOStream impls preserved for backward compatibility.
Next: replace pipe() in CLI to use PES pipeline.
2026-04-15 03:03:45 +00:00
MattJackson cab87ffdf4 DiscStream implements InputStream — produces PES frames
DiscStream now has TsDemuxer + CodecParsers internally.
next_frame() reads sectors → decrypts → demuxes → parses → returns PesFrame.
Old Read/Write impls preserved alongside for backward compatibility.
2026-04-15 02:54:08 +00:00
MattJackson ccb1fadedf Add PES frame types and FileSectorReader — foundation for stream refactor
- pes.rs: PesFrame, InputStream, OutputStream traits
- sector.rs: SectorReader now public, added FileSectorReader (ISO = file)
- Foundation for unified DiscStream that handles both disc and ISO
2026-04-15 02:37:54 +00:00
MattJackson 610f1b62b6 Fix ISO → MKV producing empty files (TsDemuxer remainder lost)
When the MKV muxer transitions from Scanning to Streaming phase,
it creates a fresh TsDemuxer. The old demuxer's remainder bytes
(partial 192-byte BD-TS packets) were lost, causing the new demuxer
to lose sync. All subsequent feed() calls found 0 packets.

Fix: transfer remainder via take_remainder/set_remainder so the
new demuxer maintains packet alignment.
2026-04-15 02:17:34 +00:00
MattJackson f400e4fc72 Remove eprintln from library — library code should not print to stderr
Drive recovery is silent. Results communicated through return values.
2026-04-15 02:00:45 +00:00
MattJackson 5a9980cb6b Fix unused variable warning from handshake fix 2026-04-15 01:56:57 +00:00
MattJackson 2f48877992 Fix handshake returning fake success on failure
Previously returned HandshakeResult with zeros when all host certs
failed. Now returns None. Also propagates volume_id read failure
instead of silently using zeros.
2026-04-15 01:54:58 +00:00
MattJackson 7c59d063ee decrypt_sectors returns Result — fail instead of silent corruption
Previously used all-zeros AES key when unit_key_idx was out of range,
producing silently corrupted output. Now returns DecryptFailed error.
Also wired --raw flag through InputOptions → set_raw() on streams.
2026-04-15 01:52:36 +00:00
MattJackson e4ffe7cc91 Wire --raw through InputOptions to streams
set_raw() on IsoStream and DiscStream sets keys to None.
open_input passes raw flag to streams via InputOptions.
Streams skip decrypt when raw=true.
2026-04-15 01:38:18 +00:00
MattJackson 2e23e848df Decrypt back in streams — streams handle their own decryption
IsoStream decrypts in its read path using keys from scan.
IOStream trait gets keys() method with default DecryptKeys::None.
Pipeline no longer handles decrypt — just reads decrypted bytes.
2026-04-15 01:34:30 +00:00
MattJackson fbdfccdd83 Add keys() to IOStream trait — streams know their own decrypt keys
- IOStream::keys() default returns DecryptKeys::None
- IsoStream and DiscStream override with real AACS/CSS keys
- Pipeline calls input.keys() instead of separate scan_keys()
- Streams are self-contained: read bytes + provide keys
2026-04-14 23:42:14 +00:00
MattJackson 2f52188f77 Drive recovery, reset on open, simplified DiscStream
- SgIoTransport::reset() — open/close/TUR/escalate on every open
- Drive::read() — single read method with error recovery (min speed,
  sleep 30s, retry, phase 1/2/3 escalation)
- Removed read_timeout, read_sectors, read_range — one read() method
- DiscStream simplified — no on_error/on_success/Recovery, delegates
  all error handling to Drive::read()
- IsoStream no longer decrypts — streams return raw bytes, pipeline
  handles decryption
- reset() on all platforms (Linux real, Windows/macOS stubs)
- Watchdog thread removed — kernel handles USB timeouts
2026-04-14 23:32:22 +00:00
MattJackson 252e58cab0 Bump to 0.9.0 2026-04-13 02:13:15 +00:00
MattJackson 3b96976a7a Add decrypt module, merge to one drive.read(), Disc::decrypt_keys()
- New decrypt.rs: DecryptKeys enum (AACS/CSS/None) + decrypt_sectors()
- Single drive.read() replaces read_disc/read_content (same SCSI READ(10))
- ContentReader and DiscStream use decrypt_sectors() (no duplicated crypto)
- Disc::decrypt_keys() exposes resolved keys for disc-to-ISO
2026-04-13 02:08:58 +00:00
MattJackson 3385de5704 Add Drive::read_capacity() for raw sector dump 2026-04-13 01:45:32 +00:00
MattJackson e9c907a2ca Pin Rust 1.86 MSRV in Cargo.toml and CI workflows 2026-04-13 00:35:35 +00:00
54 changed files with 3849 additions and 1389 deletions
+13 -3
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
// Mimics ISO dump exactly — read + write + progress
use libfreemkv::Drive;
use std::io::Write;
use std::path::Path;
use std::time::Instant;
fn main() {
let device = std::env::args()
.skip(1)
.find(|a| !a.starts_with('-'))
.unwrap_or_else(|| {
let drives = libfreemkv::find_drives();
if drives.is_empty() {
eprintln!("No drives found");
std::process::exit(1);
}
drives[0].device_path().to_string()
});
let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| {
eprintln!("Cannot open {}: {}", device, e);
std::process::exit(1);
});
eprintln!("wait_ready...");
let _ = drive.wait_ready();
eprintln!("read_capacity...");
let cap = drive.read_capacity().unwrap();
eprintln!("capacity: {} sectors", cap);
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
let mut buf = vec![0u8; batch as usize * 2048];
// Open /dev/null writer like ISO dump does
let file = std::fs::File::create("/dev/null").unwrap();
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
eprintln!("Reading 1000 batches ({:.1} MB) with write + progress...",
1000.0 * batch as f64 * 2048.0 / 1_048_576.0);
let start = Instant::now();
let mut ok = 0u32;
let mut fail = 0u32;
let mut bytes: u64 = 0;
for i in 0..1000u32 {
let lba = i * batch as u32;
match drive.read(lba, batch, &mut buf) {
Ok(_) => {
writer.write_all(&buf).unwrap();
ok += 1;
}
Err(e) => {
fail += 1;
if fail <= 5 { eprintln!(" FAIL LBA {}: {}", lba, e); }
buf.fill(0);
writer.write_all(&buf).unwrap();
}
}
bytes += buf.len() as u64;
if i % 50 == 0 && i > 0 {
let elapsed = start.elapsed().as_secs_f64();
let mb = bytes as f64 / 1_048_576.0;
eprint!("\r {:.1} MB | {:.1} MB/s ", mb, mb / elapsed);
}
}
let elapsed = start.elapsed().as_secs_f64();
let mb = ok as f64 * batch as f64 * 2048.0 / 1_048_576.0;
eprintln!("\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s", ok, fail, mb, elapsed, mb / elapsed);
}
+70
View File
@@ -0,0 +1,70 @@
// Minimal ISO dumper — find exact stall point
use libfreemkv::Drive;
use std::io::{BufWriter, Write};
use std::path::Path;
use std::time::Instant;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("Usage: iso_dump <device> <output>");
std::process::exit(1);
}
let mut drive = Drive::open(Path::new(&args[1])).unwrap();
drive.wait_ready().unwrap();
let _ = drive.init();
let _ = drive.probe_disc();
// AACS handshake — required to read past the protected area
eprint!("Scanning disc... ");
let _ = libfreemkv::Disc::scan(&mut drive, &libfreemkv::ScanOptions::default());
eprintln!("OK");
let cap = drive.read_capacity().unwrap();
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
eprintln!("Device: {} | {} sectors | batch {}", args[1], cap, batch);
let file = std::fs::File::create(&args[2]).unwrap();
let mut w = BufWriter::with_capacity(4 * 1024 * 1024, file);
let mut buf = vec![0u8; batch as usize * 2048];
let mut lba: u32 = 0;
let start = Instant::now();
let mut last = Instant::now();
let mut bytes: u64 = 0;
let mut last_bytes: u64 = 0;
while lba < cap {
let count = ((cap - lba) as u16).min(batch);
let n = count as usize * 2048;
// Tiny yield between reads — test if pacing prevents firmware throttle
std::thread::yield_now();
let t0 = Instant::now();
let ok = drive.read(lba, count, &mut buf[..n]).is_ok();
let read_ms = t0.elapsed().as_millis();
// Flag slow reads
if read_ms > 2000 {
eprintln!("\n SLOW READ: LBA {} took {}ms (ok={})", lba, read_ms, ok);
}
if !ok { buf[..n].fill(0); }
w.write_all(&buf[..n]).unwrap();
lba += count as u32;
bytes += n as u64;
if last.elapsed().as_millis() >= 1000 {
let delta = bytes - last_bytes;
let speed = delta as f64 / last.elapsed().as_secs_f64() / 1_048_576.0;
let avg = bytes as f64 / start.elapsed().as_secs_f64() / 1_048_576.0;
let pct = bytes as f64 / (cap as f64 * 2048.0) * 100.0;
eprint!("\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ", pct, lba, speed, avg, bytes as f64 / 1e9);
last_bytes = bytes;
last = Instant::now();
}
}
w.flush().unwrap();
eprintln!("\nDone: {:.1} GB in {:.0}s", bytes as f64 / 1e9, start.elapsed().as_secs_f64());
}
+1 -4
View File
@@ -1050,10 +1050,7 @@ pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 1
}
/// Read data keys after successful authentication (for AACS 2.0 bus encryption).
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
View File
@@ -188,9 +188,13 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
// Try each processing key against each UV/cvalue pair
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 = [
+82
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()),
}
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+6 -2
View File
@@ -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
}
+3 -1
View File
@@ -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
+9 -6
View File
@@ -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
View File
@@ -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;
+4 -2
View File
@@ -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.
+2 -1
View File
@@ -172,7 +172,8 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> {
}
fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
(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
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -412,7 +412,7 @@ impl<W: Write + Seek> IsoWriter<W> {
icb[ad_offset..ad_offset + 4].copy_from_slice(&(extent_len as u32).to_le_bytes());
icb[ad_offset + 4..ad_offset + 8].copy_from_slice(&sector_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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}
}
+124
View File
@@ -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()
}
-1
View File
@@ -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
View File
@@ -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)))
}
}
}
+20
View File
@@ -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
View File
@@ -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]
+251
View File
@@ -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(&params);
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
}
+108
View File
@@ -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
View File
@@ -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 }
}
+10 -6
View File
@@ -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
View File
@@ -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) };
+18
View File
@@ -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
View File
@@ -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.
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -363,8 +363,7 @@ fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
/// The standard AACS IV, copied here independently so we are NOT importing
/// the 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
View File
@@ -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
View File
@@ -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;