Compare commits

..
11 Commits
Author SHA1 Message Date
MattJackson 6e771a1867 v0.7.2: Windows SPTI, 177 tests, platform file separation 2026-04-11 16:15:14 +00:00
MattJackson 8999db68ea Refactor: drive discovery into platform files, no inline cfg
- drive/unix.rs: find_drives() + resolve_device() for Linux/macOS
- drive/windows.rs: find_drives() + resolve_device() + normalize_path() for Windows
- drive/mod.rs: clean delegation, no cfg branches
- scsi/windows.rs: SPTI transport only, no drive discovery
2026-04-11 16:12:53 +00:00
MattJackson 2d67b11a2c Add Windows SPTI backend, CI check, platform support complete
- scsi/windows.rs: SCSI_PASS_THROUGH_DIRECT via DeviceIoControl
- Device path normalization (D:, \\.\CdRom0, \\.\D:)
- Windows drive discovery (CdRom0-15 + drive letters)
- CI: cargo check on windows-latest
- Platform table: Linux + macOS + Windows all supported
2026-04-11 16:08:30 +00:00
MattJackson 995525d3ff Add 113 tests, update CI to checkout@v5, add FEATURES.md
Test suite: 64 → 177 tests
- MPLS parser: 6 tests (synthetic binary, streams, errors)
- CLPI parser: 6 tests (EP map, PTS/SPN math, errors)
- H.264: 12 tests (NAL parsing, SPS/PPS, keyframes)
- HEVC: 13 tests (VPS/SPS/PPS, IRAP range, codec private)
- AC3: 12 tests (syncword, frame extraction)
- VC1: 15 tests (BITMAPINFOHEADER, start codes)
- DTS: 5, TrueHD: 4, PGS: 4 tests
- EBML: 6 tests (size/ID/string/float roundtrips)
- UDF: 10 tests (MockSectorReader, filesystem parsing, error paths)
- Disc: 8 tests (scan_image, DiscTitle helpers)
- Streams: 5 new (meta roundtrip, MkvStream)
- NullStream: 4, StdioStream: 2, IsoSectorReader: 2

CI: actions/checkout@v4 → v5 (all workflows)
FEATURES.md: created for v0.7.1
2026-04-11 16:02:49 +00:00
MattJackson dc4ebd7d9b v0.7.1: SectorReader trait, IsoStream, StdioStream, resolve_encryption
- SectorReader trait decouples disc scanning from SCSI
- Disc::scan_image() for ISO and any sector source
- resolve_encryption() handles AACS 1.0/2.0/none in one path
- IsoStream: full UDF/MPLS/CLPI/labels pipeline from ISO files
- StdioStream: stdin/stdout pipe
- Strict scheme:// URL format with validation
- Labels module refactored to SectorReader
- 7 stream types total
2026-04-11 15:49:29 +00:00
MattJackson 168005ef34 Fix update-readme: use ORG_DISPATCH_TOKEN to bypass branch protection 2026-04-11 15:04:22 +00:00
MattJackson 6dc308f245 Bump to v0.7.0 2026-04-11 14:55:52 +00:00
MattJackson cccb6b4631 Add StdioStream, IsoStream; enforce scheme:// URL format
- StdioStream: stdin/stdout pipe, format-agnostic
- IsoStream: read BD-TS from Blu-ray ISO images
- URL resolver: bare paths rejected, all URLs require scheme:// prefix
- Validation: empty paths, missing ports, read-only/write-only errors
- Tests: 22 passing (URL parsing, validation, metadata roundtrip)
- Docs: full stream table with 7 stream types
2026-04-11 14:54:28 +00:00
MattJackson 8c2f3898b8 Add IOStream trait and stream-based I/O architecture
Introduce IOStream trait for uniform read/write across disc, file,
network, and null streams. Rename Title→DiscTitle, add stream URL
resolver, split old stream.rs into focused modules (m2ts, mkvstream,
network, disc, null, resolve, meta).
2026-04-10 19:13:53 -07:00
MattJackson 37c98e8826 Support multiple host certs, expose handshake_error on AacsState
- host_cert: Option → host_certs: Vec (try all until one succeeds)
- handshake_error field shows why auth failed (e.g. cert rejected/revoked)
2026-04-10 09:48:29 -07:00
MattJackson 891dee3db9 Bump to v0.6.1 — republish with open() all drives, AACS 2.0, has_profile() 2026-04-10 09:30:15 -07:00
50 changed files with 5984 additions and 458 deletions
+9 -2
View File
@@ -9,13 +9,20 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo test
check-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo check
check-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo check
+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Verify Cargo.toml version matches tag
run: |
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
@@ -26,7 +26,7 @@ jobs:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo test
@@ -34,7 +34,7 @@ jobs:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io
run: cargo publish
@@ -45,7 +45,7 @@ jobs:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
+2 -1
View File
@@ -11,9 +11,10 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: main
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
- name: Update version in README
run: |
+52
View File
@@ -1,5 +1,57 @@
# Changelog
## 0.7.2 (2026-04-11)
### Windows support
- **SPTI backend** (`scsi/windows.rs`) — SCSI_PASS_THROUGH_DIRECT via DeviceIoControl
- **Windows drive discovery** (`drive/windows.rs`) — scans CdRom0-15 + drive letters
- **Platform file separation** — `drive/unix.rs` and `drive/windows.rs`, no inline cfg branches
- **CI** — `cargo check` on windows-latest, actions/checkout@v5
### Test suite
- **177 tests** (was 64) — MPLS, CLPI, H.264, HEVC, AC3, VC1, DTS, TrueHd, PGS, EBML, UDF, disc scanning, streams
- **FEATURES.md** created
### Improvements
- **Stable download URLs** — `/latest/download/freemkv-x86_64-unknown-linux-musl.tar.gz` works forever
## 0.7.1 (2026-04-11)
### SectorReader trait
- **`SectorReader` trait** — decouples disc scanning from SCSI. UDF, MPLS, CLPI, labels, and AACS resolution now work with any sector source.
- **`Disc::scan_image()`** — scan ISO images or any SectorReader. Full title/stream/label/AACS pipeline, no drive required.
- **`resolve_encryption()`** — single function handles AACS 1.0, 2.0, or none. Uses whatever path works (KEYDB VUK, handshake, media key, device key).
### Stream types
- **7 stream types** — Disc, ISO, MKV, M2TS, Network, Stdio, Null
- **`IsoStream`** — read/write Blu-ray ISO images. Uses `Disc::scan_image()` for full UDF parsing (not heuristic scanning).
- **`StdioStream`** — stdin/stdout pipe, format-agnostic
- **Strict URL format** — all URLs require `scheme://path`. Bare paths rejected with clear error messages.
- **Validation** — empty paths, missing ports, read-only/write-only direction errors
### IOStream trait
- `IOStream` trait for all stream types (Read + Write + info + finish)
- `open_input()` / `open_output()` resolve URL strings to stream instances
## 0.7.0 (2026-04-11)
### Stream I/O architecture
- **5 stream types** — Disc, MKV, M2TS, Network, Null
- **`IOStream` trait** — common interface for all streams
- **URL resolver** — `open_input()` / `open_output()` with scheme://path format
- **FMKV metadata header** — JSON metadata embedded in M2TS and network streams
- **Bidirectional MKV** — MkvStream reads and writes Matroska containers
- **Network streaming** — TCP with metadata header, TCP_NODELAY
- **BD-TS demuxer** — PAT/PMT scanning, PTS duration detection
- **EBML reader** — parse existing MKV files for read-side MkvStream
## 0.6.0 (2026-04-10)
### API improvements
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.6.0"
version = "0.7.2"
edition = "2021"
license = "AGPL-3.0-only"
description = "Open source raw disc access library for optical drives"
+30
View File
@@ -0,0 +1,30 @@
# libfreemkv — Feature List
## v0.7.1 (current)
### Done
- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject
- [x] 206 bundled drive profiles (MediaTek MT1959 A + B variants)
- [x] SCSI transport: Linux SG_IO, macOS IOKit, Windows SPTI
- [x] UDF 2.50 filesystem parser (metadata partitions, Blu-ray profile)
- [x] MPLS playlist parser (play items, STN table, secondary streams)
- [x] CLPI clip info parser (EP map, sector extents)
- [x] AACS 1.0 decryption (4 VUK paths: KEYDB, media key, processing key, device key)
- [x] AACS 2.0 SCSI handshake (P-256/SHA-256 ECDH, bus decryption, read data key)
- [x] KEYDB.cfg download, verify, save (raw TCP, zero HTTP deps)
- [x] Content reading with adaptive batch sizing, error recovery, 12+ MB/s
- [x] Stream labels: 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
- [x] MKV muxer: 8 codec parsers (H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1, LPCM)
- [x] SectorReader trait: decouples disc scanning from SCSI
- [x] 7 stream types: Disc, ISO, MKV, M2TS, Network, Stdio, Null
- [x] IOStream trait with URL-based resolver (scheme://path)
- [x] FMKV metadata header for M2TS and network streams
- [x] Numeric error codes only (no English text in library)
- [x] Event system for progress callbacks
### Planned
- [ ] Windows testing on real hardware
- [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C)
- [ ] DVD CSS decryption
- [ ] TranscodeStream (ffmpeg integration)
- [ ] ISO write with BD-compliant UDF structure
+16 -1
View File
@@ -56,6 +56,21 @@ while let Some(unit) = reader.read_unit()? {
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
- **Content reading** — adaptive batch reads with automatic decryption and error recovery
- **Stream I/O** — unified stream pipeline for reading and writing any format
### Streams
| Stream | Input | Output | Transport |
|--------|-------|--------|-----------|
| DiscStream | Yes | -- | Optical drive via SCSI |
| IsoStream | Yes | -- | Blu-ray ISO image file |
| MkvStream | Yes | Yes | Matroska container |
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
All streams implement the `IOStream` trait. `open_input()` and `open_output()` resolve URL strings to stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
@@ -99,7 +114,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|----------|--------|---------|
| Linux | Supported | SG_IO ioctl |
| macOS | Supported | IOKit SCSITask |
| Windows | Planned | SPTI |
| Windows | Supported | SPTI |
## Contributing
+72 -22
View File
@@ -148,21 +148,56 @@ pub enum Error {
App maps codes to localized strings. Lib never contains display text.
## Streams the Lib Provides
## Streams
| Stream | Purpose |
|--------|---------|
| MkvStream | BD-TS → MKV (demux + mux) |
All streams implement the `IOStream` trait (Read + Write). URL-based resolver opens any stream by string.
CLI provides:
| Stream | Purpose |
|--------|---------|
| ProgressStream | Byte counting + progress callback |
| Future: TranscodeStream | Re-encode video |
| Stream | Input | Output | URL | Transport |
|--------|-------|--------|-----|-----------|
| DiscStream | Yes | -- | `disc://` `disc:///dev/sg4` | Optical drive via SCSI |
| IsoStream | Yes | -- | `iso://path.iso` | Blu-ray ISO image |
| MkvStream | Yes | Yes | `mkv://path` | Matroska container |
| M2tsStream | Yes | Yes | `m2ts://path` | BD-TS with FMKV metadata header |
| NetworkStream | Yes (listen) | Yes (connect) | `network://host:port` | TCP with FMKV metadata header |
| StdioStream | Yes (stdin) | Yes (stdout) | `stdio://` | Raw byte pipe |
| NullStream | -- | Yes | `null://` | Discard sink (byte counter) |
Any `impl Write` works as a stream. Third-party apps create their own.
All URLs require a `scheme://path` format. Bare paths are rejected.
## MkvStream Internals
```rust
// URL-based opening
let input = open_input("disc://", &opts)?; // DiscStream (auto-detect)
let input = open_input("disc:///dev/sg4", &opts)?; // DiscStream (specific device)
let input = open_input("iso://Dune.iso", &opts)?; // IsoStream
let input = open_input("m2ts:///tmp/Dune.m2ts", &opts)?; // M2tsStream
let input = open_input("mkv://Dune.mkv", &opts)?; // MkvStream
let input = open_input("network://0.0.0.0:9000", &opts)?; // NetworkStream (listen)
let input = open_input("stdio://", &opts)?; // StdioStream (stdin)
let output = open_output("mkv://Dune.mkv", &meta)?; // MkvStream
let output = open_output("m2ts://Dune.m2ts", &meta)?; // M2tsStream
let output = open_output("network://10.0.0.1:9000", &meta)?;// NetworkStream (connect)
let output = open_output("stdio://", &meta)?; // StdioStream (stdout)
let output = open_output("null://", &meta)?; // NullStream
// Direct construction (for advanced use)
let mkv = MkvStream::new(writer).meta(&title).max_buffer(10 * 1024 * 1024);
let m2ts = M2tsStream::new(writer).meta(&title);
let net = NetworkStream::connect("10.0.0.1:9000")?.meta(&title);
let null = NullStream::new().meta(&title);
```
### FMKV Metadata Header
M2tsStream and NetworkStream embed a JSON metadata header before the BD-TS data:
```
[8B magic "FMKV\0\0\0\0"][4B JSON length][JSON metadata][padding to 192B boundary][BD-TS data...]
```
The header carries title name, duration, and full stream layout (PIDs, codecs, languages, labels). This allows the receiving end to set up demuxing and track metadata without scanning the TS.
### MkvStream Internals
LookaheadBuffer (default 5MB, configurable):
1. Phase 1: buffer incoming data, scan for codec setup (SPS/PPS)
@@ -170,6 +205,8 @@ LookaheadBuffer (default 5MB, configurable):
3. Buffer full? Error — app handles it
4. Phase 2: parse TS → frames → MKV clusters, direct to output
Reading: extracts MKV frames, wraps back into BD-TS PES packets.
## File Layout
```
@@ -186,18 +223,31 @@ libfreemkv/src/
├── mpls.rs Playlist parser
├── clpi.rs Clip info parser
├── mux/
│ ├── stream.rs MkvStream (builder pattern, impl Write)
│ ├── lookahead.rs LookaheadBuffer (generic, reusable)
│ ├── ts.rs BD-TS demuxer
│ ├── ebml.rs EBML primitives
│ ├── mkv.rs MKV muxer
│ └── codec/ Frame parsers (H.264, HEVC, VC-1, AC3, DTS, TrueHD, PGS)
│ ├── mod.rs IOStream trait, public exports
│ ├── resolve.rs URL parser + open_input/open_output
│ ├── meta.rs M2tsMeta (FMKV header format)
│ ├── disc.rs DiscStream (optical drive)
│ ├── mkvstream.rs MkvStream (bidirectional Matroska)
│ ├── m2ts.rs M2tsStream (BD-TS + FMKV header)
│ ├── network.rs NetworkStream (TCP + FMKV header)
│ ├── stdio.rs StdioStream (stdin/stdout pipe)
│ ├── iso.rs IsoStream (Blu-ray ISO image)
│ ├── null.rs NullStream (discard + byte counter)
│ ├── lookahead.rs LookaheadBuffer (codec header scanning)
│ ├── ts.rs BD-TS demuxer + PAT/PMT scanner
│ ├── ebml.rs EBML read/write primitives
│ ├── mkv.rs MKV muxer (tracks, clusters, cues)
│ └── codec/ Frame parsers (H.264, HEVC, VC-1, AC3, DTS, TrueHD, PGS, LPCM)
└── ...
freemkv/src/
├── main.rs CLI entry, command routing
├── rip.rs Rip command (streams + progress)
├── remux.rs Remux command (m2ts → MKV, no drive)
├── info.rs Drive info display
└── disc_info.rs Disc info display
├── main.rs CLI dispatcher (URL routing)
├── pipe.rs Generic source → dest copy
├── rip.rs Rip with progress display
├── remux.rs Remux with progress display
├── disc_info.rs Disc info display
├── info.rs Drive info + profile submission
├── strings.rs i18n string table
├── output.rs Verbosity-filtered output
└── build.rs Bundled locale code generation
```
+1 -1
View File
@@ -1190,7 +1190,7 @@ mod tests {
if !keydb_path.exists() { return; }
let db = crate::aacs::KeyDb::load(&keydb_path).unwrap();
if let Some(hc) = &db.host_cert {
if let Some(hc) = db.host_certs.first() {
let valid = verify_cert(&hc.certificate);
eprintln!("Host cert verification: {}", if valid { "PASS" } else { "FAIL" });
// Note: our cert is revoked but should still have valid LA signature
+7 -5
View File
@@ -27,7 +27,7 @@ pub struct KeyDb {
/// Processing keys (pre-computed media keys for specific MKB versions)
pub processing_keys: Vec<[u8; 16]>,
/// Host certificate + private key for SCSI authentication
pub host_cert: Option<HostCert>,
pub host_certs: Vec<HostCert>,
/// Per-disc VUK entries indexed by disc hash (hex lowercase)
pub disc_entries: HashMap<String, DiscEntry>,
}
@@ -105,7 +105,7 @@ impl KeyDb {
let mut db = KeyDb {
device_keys: Vec::new(),
processing_keys: Vec::new(),
host_cert: None,
host_certs: Vec::new(),
disc_entries: HashMap::new(),
};
@@ -135,7 +135,7 @@ impl KeyDb {
// Host Certificate (AACS 2.0)
if line.starts_with("| HC2") {
if let Some(ref mut hc) = db.host_cert {
if let Some(hc) = db.host_certs.last_mut() {
if let Some((pk, cert)) = Self::parse_host_cert_v2(line) {
hc.private_key_v2 = Some(pk);
hc.certificate_v2 = Some(cert);
@@ -146,7 +146,9 @@ impl KeyDb {
// Host Certificate (AACS 1.0)
if line.starts_with("| HC") {
db.host_cert = Self::parse_host_cert(line);
if let Some(hc) = Self::parse_host_cert(line) {
db.host_certs.push(hc);
}
continue;
}
@@ -1342,7 +1344,7 @@ mod tests {
assert_eq!(db.device_keys.len(), 4);
assert_eq!(db.processing_keys.len(), 3);
assert!(db.host_cert.is_some());
assert!(!db.host_certs.is_empty());
assert!(db.disc_entries.len() > 170000);
// Look up Dune: Part Two
+247
View File
@@ -268,3 +268,250 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
Ok((ep_coarse, ep_fine))
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal CLPI binary.
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
// We need at least 60 bytes for the header area.
// Offsets:
// 0..4: "HDMV"
// 4..8: "0200"
// 8..12: seq_info_start (unused, set to 0)
// 12..16: prog_info_start (unused, set to 0)
// 16..20: cpi_start
// 20..40: reserved/padding
// 40..56: ClipInfo section area (length + stuff before source_packet_count)
// 56..60: source_packet_count
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
let mut buf = vec![0u8; 60];
// Magic + version
buf[0..4].copy_from_slice(b"HDMV");
buf[4..8].copy_from_slice(b"0200");
// seq_info_start = 0
// prog_info_start = 0
// cpi_start
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
// source_packet_count at offset 56
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
if let Some(cpi) = cpi_data {
buf.extend_from_slice(cpi);
}
buf
}
/// Build a CPI section with one stream's EP map.
/// coarse_entries: Vec<(ref_to_fine_id, pts_coarse, spn_coarse)>
/// fine_entries: Vec<(pts_fine, spn_fine)>
fn build_cpi(
stream_pid: u16,
coarse_entries: &[(u32, u32, u32)],
fine_entries: &[(u32, u32)],
) -> Vec<u8> {
// CPI section layout:
// [0..4] cpi_length (u32 BE)
// [4..6] reserved/type (2 bytes)
// [6..] EP map
//
// EP map layout (relative to byte 6 of CPI):
// [0] reserved
// [1] num_streams (1)
// [2..4] stream_PID (u16 BE)
// [4..14] 80 bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32)
// [14..] (next stream entry, if any)
//
// Stream EP map (at EP_map_start relative to EP map start):
// [0..4] fine_start (relative to stream EP map start)
// [4..] coarse entries, 8 bytes each
// [fine_start..] fine entries, 4 bytes each
let num_coarse = coarse_entries.len() as u32;
let num_fine = fine_entries.len() as u32;
// EP_map_start: offset from ep_map start where the stream EP data begins.
// ep_map has: reserved(1) + num_streams(1) + stream_header(12) = 14 bytes
// So EP_map_start = 14 (first stream data right after the header)
let ep_map_start: u32 = 14;
// Build the 80-bit stream PID entry (10 bytes: ep_map[4..14])
// Bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32)
// Total: 80 bits = 10 bytes
//
// Pack into a u128 for convenience then extract 10 bytes
let ep_stream_type: u32 = 1; // video
let packed: u128 = ((0u128) << 70) // reserved: 10 bits
| ((ep_stream_type as u128) << 66) // EP_stream_type: 4 bits
| ((num_coarse as u128) << 50) // num_coarse: 16 bits
| ((num_fine as u128) << 32) // num_fine: 18 bits
| (ep_map_start as u128); // EP_map_start: 32 bits
let packed_bytes = packed.to_be_bytes(); // 16 bytes, we want the last 10
let stream_header_bits = &packed_bytes[6..16];
// Build stream EP data
// fine_start = 4 (header) + num_coarse * 8
let fine_start: u32 = 4 + num_coarse * 8;
let mut stream_ep = Vec::new();
stream_ep.extend_from_slice(&fine_start.to_be_bytes());
// Coarse entries: 8 bytes each
// dword0 = (ref_to_fine_id << 14) | (pts_coarse & 0x3FFF)
// dword1 = spn_coarse
for &(ref_id, pts_c, spn_c) in coarse_entries {
let dword0 = (ref_id << 14) | (pts_c & 0x3FFF);
stream_ep.extend_from_slice(&dword0.to_be_bytes());
stream_ep.extend_from_slice(&spn_c.to_be_bytes());
}
// Fine entries: 4 bytes each
// dword = (is_angle(1) + i_end_offset(3) + pts_fine(11) + spn_fine(17))
for &(pts_f, spn_f) in fine_entries {
let dword: u32 = ((pts_f & 0x7FF) << 17) | (spn_f & 0x1FFFF);
stream_ep.extend_from_slice(&dword.to_be_bytes());
}
// Assemble EP map
let mut ep_map = Vec::new();
ep_map.push(0); // reserved
ep_map.push(1); // num_streams = 1
ep_map.extend_from_slice(&stream_pid.to_be_bytes());
ep_map.extend_from_slice(stream_header_bits);
ep_map.extend_from_slice(&stream_ep);
// Assemble CPI section
let mut cpi = Vec::new();
let cpi_length = (2 + ep_map.len()) as u32; // reserved/type(2) + ep_map
cpi.extend_from_slice(&cpi_length.to_be_bytes());
cpi.extend_from_slice(&[0u8; 2]); // reserved/type
cpi.extend_from_slice(&ep_map);
cpi
}
#[test]
fn parse_valid_clpi() {
let cpi = build_cpi(
0x1011,
&[(0, 100, 0x00020000)], // 1 coarse
&[(50, 1024)], // 1 fine
);
let data = build_clpi(500_000, Some(&cpi));
let clip = parse(&data).expect("should parse valid CLPI");
assert_eq!(clip.version, "0200");
assert_eq!(clip.source_packet_count, 500_000);
assert_eq!(clip.ep_coarse.len(), 1);
assert_eq!(clip.ep_fine.len(), 1);
}
#[test]
fn parse_ep_map() {
let cpi = build_cpi(
0x1011,
&[
(0, 100, 0x00020000), // coarse 0: fine starts at 0, pts_coarse=100, spn_coarse=0x20000
(2, 200, 0x00040000), // coarse 1: fine starts at 2, pts_coarse=200, spn_coarse=0x40000
],
&[
(50, 1024), // fine 0
(100, 2048), // fine 1
(25, 512), // fine 2
(75, 1536), // fine 3
],
);
let data = build_clpi(1_000_000, Some(&cpi));
let clip = parse(&data).expect("should parse EP map");
assert_eq!(clip.ep_coarse.len(), 2);
assert_eq!(clip.ep_fine.len(), 4);
// Verify coarse entries
assert_eq!(clip.ep_coarse[0].ref_to_fine_id, 0);
assert_eq!(clip.ep_coarse[0].pts_coarse, 100);
assert_eq!(clip.ep_coarse[0].spn_coarse, 0x00020000);
assert_eq!(clip.ep_coarse[1].ref_to_fine_id, 2);
assert_eq!(clip.ep_coarse[1].pts_coarse, 200);
assert_eq!(clip.ep_coarse[1].spn_coarse, 0x00040000);
// Verify fine entries
assert_eq!(clip.ep_fine[0].pts_fine, 50);
assert_eq!(clip.ep_fine[0].spn_fine, 1024);
assert_eq!(clip.ep_fine[1].pts_fine, 100);
assert_eq!(clip.ep_fine[1].spn_fine, 2048);
assert_eq!(clip.ep_fine[2].pts_fine, 25);
assert_eq!(clip.ep_fine[2].spn_fine, 512);
assert_eq!(clip.ep_fine[3].pts_fine, 75);
assert_eq!(clip.ep_fine[3].spn_fine, 1536);
// Verify resolved EP map assigns fine entries to coarse correctly
let resolved = clip.resolved_ep_map();
assert_eq!(resolved.len(), 4);
// First two fines belong to coarse 0, last two to coarse 1
}
#[test]
fn full_pts_calculation() {
let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 100, spn_coarse: 0 };
let fine = EpFine { pts_fine: 50, spn_fine: 0 };
// full_pts = (100 << 19) + (50 << 8) = 52_428_800 + 12_800 = 52_441_600
let pts = ClipInfo::full_pts(&coarse, &fine);
assert_eq!(pts, (100 << 19) + (50 << 8));
assert_eq!(pts, 52_441_600);
}
#[test]
fn full_spn_calculation() {
let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FE0000 };
let fine = EpFine { pts_fine: 0, spn_fine: 0x1234 };
// full_spn = (0x00FE0000 & 0xFFFE0000) + 0x1234 = 0x00FE0000 + 0x1234 = 0x00FE1234
let spn = ClipInfo::full_spn(&coarse, &fine);
assert_eq!(spn, 0x00FE0000 + 0x1234);
assert_eq!(spn, 0x00FE1234);
// Test that the low bit of spn_coarse is masked out
let coarse2 = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FF0000 };
let spn2 = ClipInfo::full_spn(&coarse2, &fine);
// 0x00FF0000 & 0xFFFE0000 = 0x00FE0000, so low 17 bits of coarse are zeroed
assert_eq!(spn2, 0x00FE0000 + 0x1234);
}
#[test]
fn parse_invalid_magic() {
let mut data = build_clpi(1000, None);
data[0] = b'X';
data[1] = b'X';
data[2] = b'X';
data[3] = b'X';
assert!(parse(&data).is_err());
}
#[test]
fn parse_empty_ep_map() {
// cpi_start = 0 means no CPI section
let data = build_clpi(100_000, None);
let clip = parse(&data).expect("should parse with no EP map");
assert_eq!(clip.source_packet_count, 100_000);
assert!(clip.ep_coarse.is_empty());
assert!(clip.ep_fine.is_empty());
// Also test: CPI section present but with zero streams
let mut cpi = Vec::new();
let cpi_length: u32 = 6; // reserved/type(2) + ep_map(reserved(1) + num_streams=0(1) + 2 padding)
cpi.extend_from_slice(&cpi_length.to_be_bytes());
cpi.extend_from_slice(&[0u8; 2]); // reserved/type
cpi.push(0); // reserved
cpi.push(0); // num_streams = 0
cpi.extend_from_slice(&[0u8; 4]); // padding
let data2 = build_clpi(100_000, Some(&cpi));
let clip2 = parse(&data2).expect("should parse with zero-stream EP map");
assert!(clip2.ep_coarse.is_empty());
assert!(clip2.ep_fine.is_empty());
}
}
+118 -55
View File
@@ -10,6 +10,7 @@
use crate::error::{Error, Result};
use crate::drive::DriveSession;
use crate::sector::SectorReader;
use crate::speed::DriveSpeed;
use crate::udf;
use crate::mpls;
@@ -34,7 +35,7 @@ pub struct Disc {
/// Number of layers (1 = single, 2 = dual)
pub layers: u8,
/// Titles sorted by duration (longest first), then playlist name
pub titles: Vec<Title>,
pub titles: Vec<DiscTitle>,
/// Disc region
pub region: DiscRegion,
/// AACS state -- None if disc is unencrypted or keys unavailable
@@ -80,7 +81,7 @@ pub enum BdRegion {
/// A title (one MPLS playlist).
#[derive(Debug, Clone)]
pub struct Title {
pub struct DiscTitle {
/// Playlist filename (e.g. "00800.mpls")
pub playlist: String,
/// Playlist number (e.g. 800)
@@ -279,7 +280,20 @@ impl ColorSpace {
}
}
impl Title {
impl DiscTitle {
/// Empty DiscTitle with no streams.
pub fn empty() -> Self {
Self {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
extents: Vec::new(),
}
}
/// Duration formatted as "Xh Ym"
pub fn duration_display(&self) -> String {
let hrs = (self.duration_secs / 3600.0) as u32;
@@ -299,7 +313,16 @@ impl Title {
}
// ─── AACS state ─────────────────────────────────────────────────────────────
// ─── Encryption ─────────────────────────────────────────────────────────────
/// Result of SCSI AACS handshake (ECDH authentication).
/// Only available when scanning from a real drive, not ISO images.
#[derive(Debug)]
struct HandshakeResult {
volume_id: [u8; 16],
read_data_key: Option<[u8; 16]>,
error: Option<crate::error::Error>,
}
/// AACS decryption state for a disc.
#[derive(Debug)]
@@ -322,6 +345,8 @@ pub struct AacsState {
pub read_data_key: Option<[u8; 16]>,
/// Volume ID (16 bytes) -- from SCSI handshake
pub volume_id: [u8; 16],
/// Handshake error code if authentication failed (None = no HC, or success)
pub handshake_error: Option<crate::error::Error>,
}
/// How AACS keys were resolved.
@@ -480,19 +505,34 @@ impl Disc {
/// The session must be open and unlocked (DriveSession::open handles this).
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
// 1. Capacity
let capacity = Self::read_capacity(session)?;
let handshake = Self::do_handshake(session, opts);
Self::scan_with(session, capacity, handshake, opts)
}
// 2. UDF filesystem
let udf_fs = udf::read_filesystem(session)?;
/// Scan a disc image (ISO or any SectorReader). No SCSI, no handshake.
/// AACS resolution uses KEYDB VUK lookup only.
pub fn scan_image(reader: &mut dyn SectorReader, capacity: u32, opts: &ScanOptions) -> Result<Self> {
Self::scan_with(reader, capacity, None, opts)
}
// 3. AACS -- read files from disc via UDF, resolve keys via KEYDB
/// Core scan pipeline — works with any SectorReader.
fn scan_with(
reader: &mut dyn SectorReader,
capacity: u32,
handshake: Option<HandshakeResult>,
opts: &ScanOptions,
) -> Result<Self> {
// 1. UDF filesystem
let udf_fs = udf::read_filesystem(reader)?;
// 2. Resolve encryption (AACS, CSS, or none)
let encrypted = udf_fs.find_dir("/AACS").is_some()
|| udf_fs.find_dir("/BDMV/AACS").is_some();
let aacs = if encrypted {
if let Some(keydb_path) = opts.resolve_keydb() {
Self::resolve_aacs(&udf_fs, session, &keydb_path).ok()
Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()).ok()
} else {
None
}
@@ -500,14 +540,14 @@ impl Disc {
None
};
// 4. Playlists
// 3. Playlists
let mut titles = Vec::new();
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
for entry in &playlist_dir.entries {
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
if let Ok(mpls_data) = udf_fs.read_file(session, &path) {
if let Some(title) = Self::parse_playlist(session, &udf_fs, &entry.name, &mpls_data) {
if let Ok(mpls_data) = udf_fs.read_file(reader, &path) {
if let Some(title) = Self::parse_playlist(reader, &udf_fs, &entry.name, &mpls_data) {
titles.push(title);
}
}
@@ -516,11 +556,11 @@ impl Disc {
}
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
// 5. Metadata + labels
let meta_title = Self::read_meta_title(session, &udf_fs);
crate::labels::apply(session, &udf_fs, &mut titles);
// 4. Metadata + labels
let meta_title = Self::read_meta_title(reader, &udf_fs);
crate::labels::apply(reader, &udf_fs, &mut titles);
// 6. Derive format, layers, region
// 5. Derive format, layers, region
let format = Self::detect_format(&titles);
let layers = if capacity > 24_000_000 { 2 } else { 1 };
let region = if format == DiscFormat::Uhd { DiscRegion::Free } else { DiscRegion::Free };
@@ -539,52 +579,74 @@ impl Disc {
})
}
/// Resolve AACS keys from disc files + KEYDB. No SCSI commands.
/// Reads Unit_Key_RO.inf, Content Certificate, and MKB from UDF.
fn resolve_aacs(
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
/// Only available when scanning from a real drive (not ISO images).
fn do_handshake(session: &mut DriveSession, opts: &ScanOptions) -> Option<HandshakeResult> {
use crate::aacs::{self, KeyDb};
let keydb_path = opts.resolve_keydb()?;
let keydb = KeyDb::load(&keydb_path).ok()?;
for hc in &keydb.host_certs {
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 read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
.ok().map(|(rdk, _)| rdk);
return Some(HandshakeResult { volume_id, read_data_key, error: None });
}
Err(e) => {
// Try next host cert
return Some(HandshakeResult {
volume_id: [0u8; 16],
read_data_key: None,
error: Some(e),
});
}
}
}
None
}
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
///
/// Reads AACS files from UDF (via SectorReader), resolves keys through
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
fn resolve_encryption(
udf_fs: &udf::UdfFs,
session: &mut DriveSession,
reader: &mut dyn SectorReader,
keydb_path: &std::path::Path,
handshake: Option<&HandshakeResult>,
) -> Result<AacsState> {
use crate::aacs::{self, KeyDb};
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string() })?;
// Read AACS files from disc via UDF (standard READ(10), no vendor commands)
let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
// Read AACS files from disc/image via UDF
let uk_ro_data = udf_fs.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
let cc_data = udf_fs.read_file(reader, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
.ok();
let mkb_data = udf_fs.read_file(session, "/AACS/MKB_RW.inf")
.or_else(|_| udf_fs.read_file(session, "/AACS/MKB_RO.inf"))
let mkb_data = udf_fs.read_file(reader, "/AACS/MKB_RW.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
.ok();
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
// AACS SCSI handshake — get Volume ID (and read data key for AACS 2.0)
let mut volume_id = [0u8; 16];
let mut read_data_key = None;
// Use handshake volume ID if available, otherwise zeros
// (KEYDB VUK lookup by disc hash works without volume ID)
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
let read_data_key = handshake.and_then(|h| h.read_data_key);
let handshake_error = None;
if let Some(ref hc) = keydb.host_cert {
if let Ok(mut auth) = aacs::handshake::aacs_authenticate(
session, &hc.private_key, &hc.certificate,
) {
// Read Volume ID (needed for MK → VUK derivation)
if let Ok(vid) = aacs::handshake::read_volume_id(session, &mut auth) {
volume_id = vid;
}
// Read data keys for bus decryption (AACS 2.0 / UHD)
if let Ok((rdk, _wdk)) = aacs::handshake::read_data_keys(session, &mut auth) {
read_data_key = Some(rdk);
}
}
}
// Resolve: disc hash → KEYDB lookup → VUK → unit keys
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key
let resolved = aacs::resolve_keys(
&uk_ro_data,
cc_data.as_deref(),
@@ -609,13 +671,14 @@ impl Disc {
unit_keys: resolved.unit_keys,
read_data_key,
volume_id,
handshake_error,
})
}
// ── Internal helpers ────────────────────────────────────────────────────
/// Detect disc format from the main title's video streams.
fn detect_format(titles: &[Title]) -> DiscFormat {
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
for title in titles.iter().take(3) {
for stream in &title.streams {
if let Stream::Video(v) = stream {
@@ -637,7 +700,7 @@ impl Disc {
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
/// Prefers English, falls back to first available language.
/// Returns None if META directory is empty or XML has no usable title.
fn read_meta_title(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> Option<String> {
fn read_meta_title(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Option<String> {
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
for sub in &meta_dir.entries {
if !sub.is_dir { continue; }
@@ -652,7 +715,7 @@ impl Disc {
if let Some(entry) = target {
let path = format!("{}/{}", dl_path, entry.name);
if let Ok(data) = udf_fs.read_file(session, &path) {
if let Ok(data) = udf_fs.read_file(reader, &path) {
let xml = String::from_utf8_lossy(&data);
if let Some(start) = xml.find("<di:name>") {
let s = start + "<di:name>".len();
@@ -679,11 +742,11 @@ impl Disc {
}
fn parse_playlist(
session: &mut DriveSession,
reader: &mut dyn SectorReader,
udf_fs: &udf::UdfFs,
filename: &str,
data: &[u8],
) -> Option<Title> {
) -> Option<DiscTitle> {
let parsed = mpls::parse(data).ok()?;
// Calculate duration from play items
@@ -707,7 +770,7 @@ impl Disc {
let mut pkt_count: u32 = 0;
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
if let Ok(clpi_data) = udf_fs.read_file(session, &clpi_path) {
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
if let Ok(clip_info) = clpi::parse(&clpi_data) {
pkt_count = clip_info.source_packet_count;
total_size += pkt_count as u64 * 192;
@@ -715,7 +778,7 @@ impl Disc {
// Get m2ts file start LBA and compute extent from packet count.
// BD-ROM m2ts files are contiguous on disc (mastering requirement).
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
let file_lba = udf_fs.file_start_lba(session, &m2ts_path).unwrap_or(0);
let file_lba = udf_fs.file_start_lba(reader, &m2ts_path).unwrap_or(0);
let total_bytes = pkt_count as u64 * 192;
let total_sectors = ((total_bytes + 2047) / 2048) as u32;
if total_sectors > 0 && file_lba > 0 {
@@ -799,7 +862,7 @@ impl Disc {
let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS");
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
Some(Title {
Some(DiscTitle {
playlist: filename.to_string(),
playlist_id,
duration_secs,
+20 -43
View File
@@ -6,8 +6,14 @@
//! 3. `init()` — activate custom firmware. Removes riplock.
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;
use std::path::Path;
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::scsi::ScsiTransport;
use crate::identity::DriveId;
use crate::profile::{self, DriveProfile};
@@ -160,21 +166,17 @@ impl DriveSession {
}
}
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
for i in 0..16 {
let path = format!("/dev/sg{}", i);
if !std::path::Path::new(&path).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()) {
// Include all optical drives (peripheral device type 0x05)
if id.raw_inquiry.len() > 0 && (id.raw_inquiry[0] & 0x1F) == 0x05 {
drives.push((path, id));
}
}
}
impl SectorReader for DriveSession {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.read_disc(lba, count, buf)
}
drives
}
pub fn find_drives() -> Vec<(String, DriveId)> {
#[cfg(unix)]
{ unix::find_drives() }
#[cfg(windows)]
{ windows::find_drives() }
}
pub fn find_drive() -> Option<String> {
@@ -182,35 +184,10 @@ pub fn find_drive() -> Option<String> {
}
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
if path.contains("/sg") {
if !std::path::Path::new(path).exists() {
return Err(Error::DeviceNotFound { path: path.to_string() });
}
return Ok((path.to_string(), None));
}
if path.contains("/sr") {
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
drop(sr_transport);
for (sg_path, sg_id) in find_drives() {
if sg_id.vendor_id == sr_id.vendor_id
&& sg_id.product_id == sr_id.product_id
&& sg_id.serial_number == sr_id.serial_number
{
let warning = format!(
"{} is a block device (sr) — using {} (sg) for raw access", path, sg_path
);
return Ok((sg_path, Some(warning)));
}
}
return Ok((path.to_string(), Some(format!(
"{} is a block device (sr) — no matching sg device found", path
))));
}
if !std::path::Path::new(path).exists() {
return Err(Error::DeviceNotFound { path: path.to_string() });
}
Ok((path.to_string(), None))
#[cfg(unix)]
{ unix::resolve_device(path) }
#[cfg(windows)]
{ windows::resolve_device(path) }
}
fn create_driver(platform: profile::Platform, profile: &DriveProfile) -> Result<Box<dyn PlatformDriver>> {
+52
View File
@@ -0,0 +1,52 @@
//! Unix (Linux/macOS) drive discovery and device resolution.
use crate::error::{Error, Result};
use crate::identity::DriveId;
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
for i in 0..16 {
let path = format!("/dev/sg{}", i);
if !std::path::Path::new(&path).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 {
drives.push((path, id));
}
}
}
}
drives
}
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
if path.contains("/sg") {
if !std::path::Path::new(path).exists() {
return Err(Error::DeviceNotFound { path: path.to_string() });
}
return Ok((path.to_string(), None));
}
if path.contains("/sr") {
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
drop(sr_transport);
for (sg_path, sg_id) in find_drives() {
if sg_id.vendor_id == sr_id.vendor_id
&& sg_id.product_id == sr_id.product_id
&& sg_id.serial_number == sr_id.serial_number
{
let warning = format!(
"{} is a block device (sr) — using {} (sg) for raw access", path, sg_path
);
return Ok((sg_path, Some(warning)));
}
}
return Ok((path.to_string(), Some(format!(
"{} is a block device (sr) — no matching sg device found", path
))));
}
if !std::path::Path::new(path).exists() {
return Err(Error::DeviceNotFound { path: path.to_string() });
}
Ok((path.to_string(), None))
}
+80
View File
@@ -0,0 +1,80 @@
//! Windows drive discovery and device resolution.
use crate::error::Result;
use crate::identity::DriveId;
use std::path::Path;
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
// Try CdRom0..CdRom15
for i in 0..16 {
let path = format!("\\\\.\\CdRom{}", i);
if let Ok(mut transport) = crate::scsi::open(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 {
drives.push((path, id));
}
}
}
}
// Also try drive letters if CdRom didn't find anything
if drives.is_empty() {
for letter in b'D'..=b'Z' {
let path = format!("{}:", letter as char);
if let Ok(mut transport) = crate::scsi::open(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 {
drives.push((path, id));
}
}
}
}
}
drives
}
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
Ok((normalize_path(path), None))
}
/// Normalize a device path to Windows \\.\X: format.
///
/// Accepts: "D:", "D:\\", "\\.\D:", "\\.\CdRom0"
fn normalize_path(path: &str) -> String {
if path.starts_with("\\\\.\\") {
return path.to_string();
}
let trimmed = path.trim_end_matches('\\');
if trimmed.len() == 2 && trimmed.as_bytes()[1] == b':' {
return format!("\\\\.\\{}", trimmed);
}
if path.to_lowercase().starts_with("cdrom") {
return format!("\\\\.\\{}", path);
}
format!("\\\\.\\{}", path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_drive_letter() {
assert_eq!(normalize_path("D:"), "\\\\.\\D:");
assert_eq!(normalize_path("E:\\"), "\\\\.\\E:");
}
#[test]
fn normalize_already_prefixed() {
assert_eq!(normalize_path("\\\\.\\D:"), "\\\\.\\D:");
assert_eq!(normalize_path("\\\\.\\CdRom0"), "\\\\.\\CdRom0");
}
#[test]
fn normalize_cdrom() {
assert_eq!(normalize_path("CdRom0"), "\\\\.\\CdRom0");
}
}
+4 -4
View File
@@ -3,7 +3,7 @@
//! Clean structured XML with Content/Qualifier per stream and
//! stream number mapping via playbackconfig.
use crate::drive::DriveSession;
use crate::sector::SectorReader;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
use std::collections::HashMap;
@@ -12,8 +12,8 @@ pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "streamproperties.xml")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?;
let sp_text = std::str::from_utf8(&sp_data).ok()?;
let stream_infos = parse_stream_infos(sp_text);
@@ -21,7 +21,7 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>
// Stream number mapping from playbackconfig.xml
let mut stream_map: HashMap<String, u16> = HashMap::new();
if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") {
if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml") {
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
parse_playback_config(pc_text, &mut stream_map);
}
+8 -8
View File
@@ -4,7 +4,7 @@
//! When both exist, language_streams.txt provides structured types while
//! menu_base.prop provides stream number → button name mapping.
use crate::drive::DriveSession;
use crate::sector::SectorReader;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
use std::collections::HashMap;
@@ -14,12 +14,12 @@ pub fn detect(udf: &UdfFs) -> bool {
|| super::jar_file_exists(udf, "language_streams.txt")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
// Try language_streams.txt first (richer structured data)
let ls_labels = parse_language_streams(session, udf);
let ls_labels = parse_language_streams(reader, udf);
// Try menu_base.prop (stream numbers + key names)
let mb_labels = parse_menu_base(session, udf);
let mb_labels = parse_menu_base(reader, udf);
// If we have both, merge: language_streams for structure, menu_base for names
match (ls_labels, mb_labels) {
@@ -48,8 +48,8 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
// ── language_streams.txt parser ────────────────────────────────────────────
fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "language_streams.txt")?;
fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(reader, udf, "language_streams.txt")?;
let text = std::str::from_utf8(&data).ok()?;
let mut labels = Vec::new();
@@ -123,8 +123,8 @@ fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec
// ── menu_base.prop parser ──────────────────────────────────────────────────
fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "menu_base.prop")?;
fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(reader, udf, "menu_base.prop")?;
let text = std::str::from_utf8(&data).ok()?;
// Parse key=value, group by prefix
+10 -10
View File
@@ -4,7 +4,7 @@
//! To add a new format:
//! 1. Create `src/labels/myformat.rs`
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
//! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
mod paramount;
@@ -13,9 +13,9 @@ mod pixelogic;
mod ctrm;
pub mod vocab;
use crate::drive::DriveSession;
use crate::sector::SectorReader;
use crate::udf::UdfFs;
use crate::disc::{Title, Stream};
use crate::disc::{DiscTitle, Stream};
/// A stream label extracted from disc config files.
#[derive(Debug, Clone)]
@@ -67,7 +67,7 @@ pub enum LabelQualifier {
// Order = priority. First match wins. Highest quality output first.
type DetectFn = fn(&UdfFs) -> bool;
type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>;
type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<Vec<StreamLabel>>;
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
("paramount", paramount::detect, paramount::parse),
@@ -79,9 +79,9 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
/// Search disc for config files, extract labels, apply to streams.
/// This is 100% optional — if anything fails, streams are untouched.
pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) {
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
extract(session, udf)
extract(reader, udf)
})).unwrap_or_default();
if labels.is_empty() { return; }
@@ -133,10 +133,10 @@ pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
}
}
fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
for (_name, detect, parse) in PARSERS {
if detect(udf) {
if let Some(labels) = parse(session, udf) {
if let Some(labels) = parse(reader, udf) {
return labels;
}
}
@@ -169,7 +169,7 @@ pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
}
/// Read a file from any BDMV/JAR subdirectory by filename.
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
pub(crate) fn read_jar_file(reader: &mut dyn SectorReader, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
let path = find_jar_file(udf, filename)?;
udf.read_file(session, &path).ok().filter(|d| !d.is_empty())
udf.read_file(reader, &path).ok().filter(|d| !d.is_empty())
}
+3 -3
View File
@@ -12,7 +12,7 @@
//! sub_com1_idx="23,24,25" />
//! ```
use crate::drive::DriveSession;
use crate::sector::SectorReader;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
@@ -20,8 +20,8 @@ pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "playlists.xml")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "playlists.xml")?;
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(reader, udf, "playlists.xml")?;
let text = std::str::from_utf8(&data).ok()?;
// Find the feature playlist — longest duration or name="Feature"
+3 -3
View File
@@ -5,7 +5,7 @@
//!
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
use crate::drive::DriveSession;
use crate::sector::SectorReader;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
@@ -18,8 +18,8 @@ pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "bluray_project.bin")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(reader, udf, "bluray_project.bin")?;
let strings = extract_strings(&data);
let mut labels = Vec::new();
+12 -1
View File
@@ -68,6 +68,7 @@
//! | E7xxx | AACS errors |
pub mod error;
pub mod sector;
pub mod scsi;
pub mod profile;
pub mod platform;
@@ -90,9 +91,19 @@ pub use drive::{DriveSession, find_drive, find_drives, resolve_device};
pub use identity::DriveId;
pub use profile::DriveProfile;
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
pub use sector::SectorReader;
pub use scsi::ScsiTransport;
pub use speed::DriveSpeed;
pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
pub use disc::{Disc, DiscFormat, DiscTitle, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
Codec, HdrFormat, ColorSpace,
Extent, ContentReader, AacsState, KeySource, ScanOptions};
pub use mux::IOStream;
pub use mux::MkvStream;
pub use mux::M2tsStream;
pub use mux::NetworkStream;
pub use mux::DiscStream;
pub use mux::NullStream;
pub use mux::StdioStream;
pub use mux::IsoStream;
pub use mux::DiscOptions;
pub use mux::{open_input, open_output, parse_url, InputOptions};
+305
View File
@@ -321,3 +321,308 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
secondary: false,
}, sa_end))
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal MPLS binary with given play items and STN streams on the first item.
/// STN counts: (n_video, n_audio, n_pg, n_ig, n_sec_audio, n_sec_video, n_pip_pg, n_dv)
fn build_mpls(
play_items_data: &[(/*clip_id*/&[u8;5], /*conn*/u8, /*in_time*/u32, /*out_time*/u32)],
stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8),
stream_entries: &[Vec<u8>], // raw stream entry + attributes bytes for each stream
) -> Vec<u8> {
let playlist_start: u32 = 40; // right after the 40-byte header
let mut buf = Vec::new();
// File header: "MPLS" + version + playlist_start + 3 more offsets (unused)
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&playlist_start.to_be_bytes());
// mark_start, extension_start (unused by parser), padding to 40 bytes
buf.extend_from_slice(&[0u8; 28]);
// PlayList section starts here (offset 40)
// PlayList: length(4) + reserved(2) + num_play_items(2) + num_sub_paths(2) = 10 header bytes
let pl_start = buf.len();
buf.extend_from_slice(&[0u8; 4]); // length placeholder
buf.extend_from_slice(&[0u8; 2]); // reserved
buf.extend_from_slice(&(play_items_data.len() as u16).to_be_bytes());
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
for (idx, (clip_id, conn, in_time, out_time)) in play_items_data.iter().enumerate() {
// Build play item content
let mut item = Vec::new();
// [0..5] clip_id
item.extend_from_slice(*clip_id);
// [5..9] codec_id ("M2TS")
item.extend_from_slice(b"M2TS");
// [9] connection_condition in low nibble
item.push(*conn & 0x0F);
// [10..12] reserved
item.extend_from_slice(&[0u8; 2]);
// [12..16] in_time
item.extend_from_slice(&in_time.to_be_bytes());
// [16..20] out_time
item.extend_from_slice(&out_time.to_be_bytes());
// [20..28] UO_mask_table
item.extend_from_slice(&[0u8; 8]);
// [28] misc flags
item.push(0);
// [29] still_mode
item.push(0);
// [30..32] still_time
item.extend_from_slice(&[0u8; 2]);
// STN table (only for the first play item)
if idx == 0 {
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
let stn_header_start = item.len();
item.extend_from_slice(&[0u8; 2]); // STN length placeholder
item.extend_from_slice(&[0u8; 2]); // reserved
item.push(stn_counts.0); // n_video
item.push(stn_counts.1); // n_audio
item.push(stn_counts.2); // n_pg
item.push(stn_counts.3); // n_ig
item.push(stn_counts.4); // n_sec_audio
item.push(stn_counts.5); // n_sec_video
item.push(stn_counts.6); // n_pip_pg
item.push(stn_counts.7); // n_dv
item.extend_from_slice(&[0u8; 4]); // reserved
// Stream entries
for se in stream_entries {
item.extend_from_slice(se);
}
// Patch STN length
let stn_len = (item.len() - stn_header_start - 2) as u16;
let stn_len_bytes = stn_len.to_be_bytes();
item[stn_header_start] = stn_len_bytes[0];
item[stn_header_start + 1] = stn_len_bytes[1];
}
// Write item_length(2) + item
let item_length = item.len() as u16;
buf.extend_from_slice(&item_length.to_be_bytes());
buf.extend_from_slice(&item);
}
// Patch PlayList length
let pl_len = (buf.len() - pl_start - 4) as u32;
let pl_len_bytes = pl_len.to_be_bytes();
buf[pl_start] = pl_len_bytes[0];
buf[pl_start + 1] = pl_len_bytes[1];
buf[pl_start + 2] = pl_len_bytes[2];
buf[pl_start + 3] = pl_len_bytes[3];
buf
}
/// Build a stream entry (stream_entry part + stream_attributes part).
/// stream_entry: type=0x01 (PlayItem stream), PID given.
/// For video: attrs = coding_type(1) + format_rate(1) [+ hdr_byte if HEVC]
/// For audio: attrs = coding_type(1) + format_rate(1) + language(3)
/// For PG: attrs = coding_type(1) + language(3)
fn build_stream_entry_video(pid: u16, coding_type: u8, format: u8, rate: u8, hdr: Option<u8>) -> Vec<u8> {
let mut out = Vec::new();
// Stream entry: length(1) + sub_path_type(1) + pid(2)
out.push(3); // se_len = 3 bytes (type + pid_hi + pid_lo)
out.push(0x01); // type: PlayItem stream
out.extend_from_slice(&pid.to_be_bytes());
// Stream attributes
let mut attrs = vec![coding_type, (format << 4) | rate];
if let Some(h) = hdr {
attrs.push(h);
}
out.push(attrs.len() as u8); // sa_len
out.extend_from_slice(&attrs);
out
}
fn build_stream_entry_audio(pid: u16, coding_type: u8, ch_layout: u8, sample_rate: u8, lang: &[u8; 3]) -> Vec<u8> {
let mut out = Vec::new();
out.push(3);
out.push(0x01);
out.extend_from_slice(&pid.to_be_bytes());
// attrs: coding_type(1) + format_rate(1) + language(3)
let attrs = vec![coding_type, (ch_layout << 4) | sample_rate, lang[0], lang[1], lang[2]];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
let mut out = Vec::new();
out.push(3);
out.push(0x01);
out.extend_from_slice(&pid.to_be_bytes());
// attrs: coding_type(1) + language(3)
let attrs = vec![coding_type, lang[0], lang[1], lang[2]];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
#[test]
fn parse_valid_mpls() {
let in_time: u32 = 90000; // 2 seconds at 45kHz
let out_time: u32 = 4500000; // 100 seconds
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); // H264, 1080p, 23.976
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"); // TrueHD, 5.1, 48kHz
let pg = build_stream_entry_pg(0x1200, 0x90, b"eng"); // PGS subtitle
let data = build_mpls(
&[(b"00001", 1, in_time, out_time)],
(1, 1, 1, 0, 0, 0, 0, 0),
&[video, audio, pg],
);
let playlist = parse(&data).expect("should parse valid MPLS");
assert_eq!(playlist.version, "0200");
assert_eq!(playlist.play_items.len(), 1);
assert_eq!(playlist.play_items[0].clip_id, "00001");
assert_eq!(playlist.play_items[0].in_time, in_time);
assert_eq!(playlist.play_items[0].out_time, out_time);
assert_eq!(playlist.play_items[0].connection_condition, 1);
}
#[test]
fn parse_streams() {
let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12)); // HEVC, 2160p, 23.976, HDR10+BT.2020
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng");
let pg = build_stream_entry_pg(0x1200, 0x90, b"fra");
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 1, 1, 0, 0, 0, 0, 0),
&[video, audio, pg],
);
let playlist = parse(&data).expect("should parse");
assert_eq!(playlist.streams.len(), 3);
// Video stream
let v = &playlist.streams[0];
assert_eq!(v.stream_type, 1);
assert_eq!(v.pid, 0x1011);
assert_eq!(v.coding_type, 0x24); // HEVC
assert_eq!(v.video_format, 8); // 2160p
assert_eq!(v.video_rate, 1); // 23.976
assert_eq!(v.dynamic_range, 1); // HDR10
assert_eq!(v.color_space, 2); // BT.2020
assert!(!v.secondary);
// Audio stream
let a = &playlist.streams[1];
assert_eq!(a.stream_type, 2);
assert_eq!(a.pid, 0x1100);
assert_eq!(a.coding_type, 0x83); // TrueHD
assert_eq!(a.audio_format, 6); // 5.1
assert_eq!(a.audio_rate, 1); // 48kHz
assert_eq!(a.language, "eng");
assert!(!a.secondary);
// PG subtitle stream
let s = &playlist.streams[2];
assert_eq!(s.stream_type, 3);
assert_eq!(s.pid, 0x1200);
assert_eq!(s.coding_type, 0x90); // PGS
assert_eq!(s.language, "fra");
assert!(!s.secondary);
}
#[test]
fn parse_invalid_magic() {
let mut data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
);
data[0] = b'X';
data[1] = b'X';
data[2] = b'X';
data[3] = b'X';
assert!(parse(&data).is_err());
}
#[test]
fn parse_truncated() {
// Less than 40 bytes
assert!(parse(&[0u8; 10]).is_err());
assert!(parse(b"MPLS0200").is_err());
assert!(parse(&[0u8; 39]).is_err());
}
#[test]
fn parse_multiple_play_items() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let data = build_mpls(
&[
(b"00001", 1, 90000, 4500000),
(b"00002", 5, 4500000, 9000000),
(b"00003", 6, 9000000, 13500000),
],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let playlist = parse(&data).expect("should parse multiple play items");
assert_eq!(playlist.play_items.len(), 3);
assert_eq!(playlist.play_items[0].clip_id, "00001");
assert_eq!(playlist.play_items[0].connection_condition, 1);
assert_eq!(playlist.play_items[1].clip_id, "00002");
assert_eq!(playlist.play_items[1].connection_condition, 5);
assert_eq!(playlist.play_items[1].in_time, 4500000);
assert_eq!(playlist.play_items[2].clip_id, "00003");
assert_eq!(playlist.play_items[2].connection_condition, 6);
assert_eq!(playlist.play_items[2].out_time, 13500000);
}
#[test]
fn parse_secondary_streams() {
// Primary video
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
// Secondary audio (stream_type 5): build as audio, parser overrides type to 5
let sec_audio_se = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"eng");
// Need ref bytes after secondary audio: num_refs(1) + reserved(1) = 2 bytes min
let mut sec_audio_with_refs = sec_audio_se;
sec_audio_with_refs.push(0); // num_refs = 0
sec_audio_with_refs.push(0); // reserved
// Secondary video (stream_type 6): build as video, parser overrides type to 6
let sec_video_se = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
// Need ref bytes: n_arefs(1) + reserved(1) + n_prefs(1) + reserved(1) = 4 bytes
let mut sec_video_with_refs = sec_video_se;
sec_video_with_refs.push(0); // n_arefs = 0
sec_video_with_refs.push(0); // reserved
sec_video_with_refs.push(0); // n_prefs = 0
sec_video_with_refs.push(0); // reserved
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 1, 1, 0, 0), // 1 video, 0 audio, 0 pg, 0 ig, 1 sec_audio, 1 sec_video
&[video, sec_audio_with_refs, sec_video_with_refs],
);
let playlist = parse(&data).expect("should parse secondary streams");
// Should have 3 streams: primary video, secondary audio, secondary video
assert_eq!(playlist.streams.len(), 3);
// Primary video
assert_eq!(playlist.streams[0].stream_type, 1);
assert!(!playlist.streams[0].secondary);
// Secondary audio
assert_eq!(playlist.streams[1].stream_type, 5);
assert!(playlist.streams[1].secondary);
assert_eq!(playlist.streams[1].pid, 0x1A00);
// Secondary video
assert_eq!(playlist.streams[2].stream_type, 6);
assert!(playlist.streams[2].secondary);
assert_eq!(playlist.streams[2].pid, 0x1B00);
}
}
+150 -4
View File
@@ -16,22 +16,168 @@ impl Ac3Parser {
impl CodecParser for Ac3Parser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
if pes.data.len() < 2 {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
// AC3: each PES = one frame, always a keyframe
// Find AC3 syncword (0x0B77) — skip any garbage before it
let data = &pes.data;
let start = find_ac3_sync(data).unwrap_or(0);
vec![Frame {
pts_ns,
keyframe: true,
data: pes.data.clone(),
data: data[start..].to_vec(),
}]
}
fn codec_private(&self) -> Option<Vec<u8>> {
// AC3 doesn't need codecPrivate in MKV
None
}
}
/// Find AC3 syncword (0x0B77) in data.
fn find_ac3_sync(data: &[u8]) -> Option<usize> {
for i in 0..data.len().saturating_sub(1) {
if data[i] == 0x0B && data[i + 1] == 0x77 {
return Some(i);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1100, pts, dts: None, data }
}
// --- syncword detection ---
#[test]
fn find_ac3_sync_at_start() {
let data = [0x0B, 0x77, 0x01, 0x02, 0x03];
assert_eq!(find_ac3_sync(&data), Some(0));
}
#[test]
fn find_ac3_sync_with_garbage_prefix() {
let data = [0xFF, 0xFE, 0x0B, 0x77, 0x01, 0x02];
assert_eq!(find_ac3_sync(&data), Some(2));
}
#[test]
fn find_ac3_sync_none() {
let data = [0x0B, 0x78, 0x00, 0x00];
assert_eq!(find_ac3_sync(&data), None);
}
#[test]
fn find_ac3_sync_empty() {
let data: [u8; 0] = [];
assert_eq!(find_ac3_sync(&data), None);
}
// --- parse syncword → frame extracted ---
#[test]
fn parse_syncword() {
let mut parser = Ac3Parser::new();
// AC3 frame starting with syncword
let data = vec![0x0B, 0x77, 0x44, 0x55, 0x66, 0x77, 0x88];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn parse_syncword_with_garbage_prefix() {
let mut parser = Ac3Parser::new();
// Garbage bytes before syncword
let data = vec![0xFF, 0xFE, 0x0B, 0x77, 0x44, 0x55];
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Data should start from the syncword
assert_eq!(frames[0].data[0], 0x0B);
assert_eq!(frames[0].data[1], 0x77);
assert_eq!(frames[0].data.len(), 4); // syncword + 2 payload bytes
}
// --- all frames are keyframes ---
#[test]
fn all_keyframes() {
let mut parser = Ac3Parser::new();
for i in 0..5 {
let data = vec![0x0B, 0x77, 0x00, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "AC3 frame {} should be a keyframe", i);
}
}
// --- codec_private is None ---
#[test]
fn codec_private_none() {
let parser = Ac3Parser::new();
assert!(parser.codec_private().is_none());
}
// --- empty / too-short PES ---
#[test]
fn parse_empty_pes() {
let mut parser = Ac3Parser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
#[test]
fn parse_single_byte_pes() {
let mut parser = Ac3Parser::new();
let pes = make_pes(vec![0x0B], Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = Ac3Parser::new();
let data = vec![0x0B, 0x77, 0x00, 0x01];
// 45000 ticks = 0.5 seconds → 500_000_000 ns
let pes = make_pes(data, Some(45000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 500_000_000);
}
// --- None PTS ---
#[test]
fn no_pts() {
let mut parser = Ac3Parser::new();
let data = vec![0x0B, 0x77, 0x00, 0x01];
let pes = make_pes(data, None);
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 0);
}
}
+57
View File
@@ -24,3 +24,60 @@ impl CodecParser for DtsParser {
fn codec_private(&self) -> Option<Vec<u8>> { None }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1100, pts, dts: None, data }
}
#[test]
fn parse_basic_frame() {
let mut parser = DtsParser::new();
// DTS core syncword: 7F FE 80 01 + payload
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn all_keyframes() {
let mut parser = DtsParser::new();
for i in 0..3 {
let data = vec![0x7F, 0xFE, 0x80, 0x01, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "DTS frame should always be keyframe");
}
}
#[test]
fn codec_private_none() {
let parser = DtsParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn parse_empty_pes() {
let mut parser = DtsParser::new();
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
#[test]
fn no_pts() {
let mut parser = DtsParser::new();
let pes = make_pes(vec![0x7F, 0xFE, 0x80, 0x01], None);
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 0);
}
}
+252 -1
View File
@@ -29,7 +29,8 @@ impl CodecParser for H264Parser {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
// Use DTS when available (monotonic for B-frame content), fall back to PTS
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
// Scan NAL units for SPS, PPS, and IDR detection
let mut keyframe = false;
@@ -188,3 +189,253 @@ pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1011, pts, dts: None, data }
}
// --- find_start_code tests ---
#[test]
fn find_start_code_3byte() {
let data = [0x00, 0x00, 0x01, 0x65];
assert_eq!(find_start_code(&data, 0), Some(0));
}
#[test]
fn find_start_code_4byte() {
let data = [0x00, 0x00, 0x00, 0x01, 0x65];
// find_start_code looks for 00 00 01 pattern, which starts at offset 1 in a 4-byte start code
assert_eq!(find_start_code(&data, 0), Some(1));
}
#[test]
fn find_start_code_offset() {
let data = [0xFF, 0xFF, 0x00, 0x00, 0x01, 0x09];
assert_eq!(find_start_code(&data, 0), Some(2));
}
#[test]
fn find_start_code_none() {
let data = [0x00, 0x00, 0x00, 0x00];
assert_eq!(find_start_code(&data, 0), None);
}
#[test]
fn find_start_code_too_short() {
let data = [0x00, 0x00];
assert_eq!(find_start_code(&data, 0), None);
}
// --- parse SPS+PPS → codec_private ---
#[test]
fn parse_sps_pps() {
let mut parser = H264Parser::new();
// Build PES with SPS (type 7) + PPS (type 8) + IDR slice (type 5)
// SPS NAL: 0x67 = 0_11_00111 (nal_type = 7), followed by profile/compat/level + payload
// PPS NAL: 0x68 = 0_11_01000 (nal_type = 8)
let mut data = Vec::new();
// SPS: 00 00 01 [67 42 00 1E <payload>]
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x67); // SPS
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB, 0xCD]); // profile=0x42, compat=0x00, level=0x1E
// PPS: 00 00 01 [68 <payload>]
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x68); // PPS
data.extend_from_slice(&[0xCE, 0x01]);
// IDR slice: 00 00 01 [65 <payload>]
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x65); // IDR
data.extend_from_slice(&[0x88, 0x00, 0x10]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
// codec_private should now be available
let cp = parser.codec_private();
assert!(cp.is_some(), "codec_private should be Some after seeing SPS+PPS");
let cp = cp.unwrap();
// AVCDecoderConfigurationRecord checks
assert_eq!(cp[0], 1, "configurationVersion");
assert_eq!(cp[1], 0x42, "profile from SPS[1]");
assert_eq!(cp[2], 0x00, "compatibility from SPS[2]");
assert_eq!(cp[3], 0x1E, "level from SPS[3]");
assert_eq!(cp[4], 0xFF, "reserved + lengthSizeMinusOne=3");
assert_eq!(cp[5], 0xE1, "reserved + numSPS=1");
// Frames should have been produced
assert_eq!(frames.len(), 1);
}
#[test]
fn codec_private_none_before_sps_pps() {
let parser = H264Parser::new();
assert!(parser.codec_private().is_none());
}
// --- IDR keyframe detection ---
#[test]
fn parse_idr_keyframe() {
let mut parser = H264Parser::new();
// PES with IDR NAL (type 5 = 0x65)
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x65); // IDR slice (nal_type = 5)
data.extend_from_slice(&[0x88, 0x00, 0x10, 0x20]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "IDR slice should be detected as keyframe");
}
// --- non-IDR → not keyframe ---
#[test]
fn parse_non_idr() {
let mut parser = H264Parser::new();
// PES with non-IDR slice (type 1 = 0x61 or 0x41)
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x41); // non-IDR coded slice (nal_type = 1)
data.extend_from_slice(&[0x9A, 0x00, 0x10]);
let pes = make_pes(data, Some(180000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "non-IDR slice should not be keyframe");
}
// --- length prefix conversion ---
#[test]
fn length_prefix_conversion() {
let mut parser = H264Parser::new();
// PES with a single non-IDR NAL
let nal_payload = [0x41, 0xAA, 0xBB, 0xCC, 0xDD]; // type 1, 5 bytes
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&nal_payload);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
let frame_data = &frames[0].data;
// Should start with 4-byte big-endian length prefix
assert!(frame_data.len() >= 4, "frame data should have length prefix");
let length = u32::from_be_bytes([frame_data[0], frame_data[1], frame_data[2], frame_data[3]]);
assert_eq!(length as usize, nal_payload.len(), "length prefix should match NAL size");
// Followed by the NAL data itself
assert_eq!(&frame_data[4..], &nal_payload);
// No start code (00 00 01) should appear in the output
for i in 0..frame_data.len().saturating_sub(2) {
let is_sc = frame_data[i] == 0x00 && frame_data[i + 1] == 0x00 && frame_data[i + 2] == 0x01;
assert!(!is_sc, "output should not contain Annex B start codes");
}
}
// --- SPS/PPS/AUD are stripped from frame data ---
#[test]
fn sps_pps_aud_stripped_from_frame_data() {
let mut parser = H264Parser::new();
let mut data = Vec::new();
// AUD (type 9)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x09);
data.push(0xF0);
// SPS (type 7)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x67);
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB]);
// PPS (type 8)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x68);
data.extend_from_slice(&[0xCE, 0x01]);
// IDR (type 5) - only this should appear in frame data
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x65);
data.extend_from_slice(&[0x88, 0x00]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Frame data should only contain the IDR NAL (length-prefixed)
let fd = &frames[0].data;
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
// IDR NAL is 0x65, 0x88 (trailing 0x00 is stripped as potential start code prefix)
assert_eq!(length, 2);
assert_eq!(fd[4], 0x65); // IDR NAL type byte
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = H264Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x41);
data.extend_from_slice(&[0x00, 0x10]);
// PTS = 90000 (1 second at 90kHz) → 1_000_000_000 ns
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// --- empty PES ---
#[test]
fn parse_empty_pes() {
let mut parser = H264Parser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- DTS preferred over PTS when present ---
#[test]
fn dts_preferred_over_pts() {
let mut parser = H264Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x41);
data.extend_from_slice(&[0x00, 0x10]);
let pes = PesPacket {
pid: 0x1011,
pts: Some(180000), // 2 seconds
dts: Some(90000), // 1 second
data,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// DTS should be used, not PTS
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
}
+258 -1
View File
@@ -34,7 +34,8 @@ impl CodecParser for HevcParser {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
// Use DTS when available (monotonic for B-frame content), fall back to PTS
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
let data = &pes.data;
let mut keyframe = false;
@@ -168,3 +169,259 @@ impl CodecParser for HevcParser {
Some(record)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1011, pts, dts: None, data }
}
/// Build an HEVC NAL header (2 bytes). Type is bits 1-6 of first byte.
/// Format: forbidden(1) | type(6) | layer_id_high(1) || layer_id_low(5) | tid(3)
fn hevc_nal_header(nal_type: u8) -> [u8; 2] {
[(nal_type & 0x3F) << 1, 0x01] // tid=1
}
// --- VPS+SPS+PPS → codec_private ---
#[test]
fn parse_vps_sps_pps() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
// VPS (type 32)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let vps_hdr = hevc_nal_header(32);
data.extend_from_slice(&vps_hdr);
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // VPS payload
// SPS (type 33)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let sps_hdr = hevc_nal_header(33);
data.extend_from_slice(&sps_hdr);
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0A, 0x0B, 0x0C, 0x0D]); // SPS payload (>12 bytes for level)
// PPS (type 34)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let pps_hdr = hevc_nal_header(34);
data.extend_from_slice(&pps_hdr);
data.extend_from_slice(&[0xDD, 0xEE]); // PPS payload
// IRAP slice (type 19 = IDR_W_RADL) so a frame is emitted
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let idr_hdr = hevc_nal_header(19);
data.extend_from_slice(&idr_hdr);
data.extend_from_slice(&[0x10, 0x20, 0x30]);
let pes = make_pes(data, Some(90000));
let _frames = parser.parse(&pes);
let cp = parser.codec_private();
assert!(cp.is_some(), "codec_private should be Some after VPS+SPS+PPS");
let cp = cp.unwrap();
// configurationVersion = 1
assert_eq!(cp[0], 1);
// numOfArrays = 3 (VPS, SPS, PPS)
assert_eq!(cp[22], 3);
// Should be longer than the minimal header (23 bytes) + array entries
assert!(cp.len() > 23, "codec_private should contain VPS+SPS+PPS data");
}
#[test]
fn codec_private_none_before_params() {
let parser = HevcParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn codec_private_none_missing_pps() {
let mut parser = HevcParser::new();
// Only VPS + SPS, no PPS
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA, 0xBB]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(33));
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]);
// Add a slice so parse doesn't return empty
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
parser.parse(&pes);
assert!(parser.codec_private().is_none(), "should be None without PPS");
}
// --- IRAP keyframe detection ---
#[test]
fn parse_irap_keyframe_idr_w_radl() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
// IDR_W_RADL = type 19
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(19));
data.extend_from_slice(&[0x10, 0x20, 0x30]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "IDR_W_RADL (type 19) should be keyframe");
}
#[test]
fn parse_irap_keyframe_bla() {
let mut parser = HevcParser::new();
// BLA_W_LP = type 16
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(16));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "BLA_W_LP (type 16) should be keyframe");
}
#[test]
fn parse_irap_keyframe_cra() {
let mut parser = HevcParser::new();
// CRA_NUT = type 21
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(21));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "CRA (type 21) should be keyframe");
}
#[test]
fn parse_irap_type_23() {
let mut parser = HevcParser::new();
// RSV_IRAP_VCL23 = type 23 (upper boundary)
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(23));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "type 23 should be keyframe");
}
// --- non-IRAP (trailing) → not keyframe ---
#[test]
fn parse_trailing_not_keyframe() {
let mut parser = HevcParser::new();
// TRAIL_R = type 1
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(1));
data.extend_from_slice(&[0x10, 0x20, 0x30]);
let pes = make_pes(data, Some(180000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "TRAIL_R (type 1) should not be keyframe");
}
#[test]
fn parse_tsa_not_keyframe() {
let mut parser = HevcParser::new();
// TSA_N = type 2
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(2));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "TSA_N (type 2) should not be keyframe");
}
// --- VPS/SPS/PPS stripped from frame data ---
#[test]
fn param_sets_stripped_from_frame() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
// VPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA]);
// SPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(33));
data.extend_from_slice(&[0xBB]);
// PPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34));
data.extend_from_slice(&[0xCC]);
// IDR slice
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let idr_hdr = hevc_nal_header(19);
data.extend_from_slice(&idr_hdr);
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Frame data should only have the IDR NAL (length-prefixed)
let fd = &frames[0].data;
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
// IDR NAL = 2 bytes header + 2 bytes payload = 4 bytes
assert_eq!(length as usize + 4, fd.len(), "frame should contain exactly one length-prefixed NAL");
}
// --- empty PES ---
#[test]
fn parse_empty_pes() {
let mut parser = HevcParser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(1));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
}
+48
View File
@@ -23,3 +23,51 @@ impl CodecParser for PgsParser {
fn codec_private(&self) -> Option<Vec<u8>> { None }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1200, pts, dts: None, data }
}
#[test]
fn parse_basic_segment() {
let mut parser = PgsParser::new();
// PGS segment data (PCS = presentation composition segment)
let data = vec![0x16, 0x00, 0x00, 0x11, 0x01, 0x02, 0x03];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn all_keyframes() {
let mut parser = PgsParser::new();
for i in 0..3 {
let data = vec![0x16, 0x00, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "PGS segment should always be keyframe");
}
}
#[test]
fn codec_private_none() {
let parser = PgsParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn parse_empty_pes() {
let mut parser = PgsParser::new();
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
}
+48
View File
@@ -25,3 +25,51 @@ impl CodecParser for TrueHdParser {
fn codec_private(&self) -> Option<Vec<u8>> { None }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1100, pts, dts: None, data }
}
#[test]
fn parse_basic_frame() {
let mut parser = TrueHdParser::new();
// TrueHD major sync: F8 72 6F BA (at 4-byte aligned position) + payload
let data = vec![0xF8, 0x72, 0x6F, 0xBA, 0x01, 0x02, 0x03, 0x04];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn all_keyframes() {
let mut parser = TrueHdParser::new();
for i in 0..3 {
let data = vec![0xF8, 0x72, 0x6F, 0xBA, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "TrueHD frame should always be keyframe");
}
}
#[test]
fn codec_private_none() {
let parser = TrueHdParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn parse_empty_pes() {
let mut parser = TrueHdParser::new();
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
}
+242 -13
View File
@@ -28,8 +28,10 @@ impl CodecParser for Vc1Parser {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
let mut keyframe = false;
// Use DTS when available (monotonic for B-frame content), fall back to PTS
let ts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
let mut has_seq_header = false;
let mut frame_start: Option<usize> = None;
// Scan for start codes (00 00 01 XX)
let data = &pes.data;
@@ -39,23 +41,18 @@ impl CodecParser for Vc1Parser {
let sc_type = data[i + 3];
match sc_type {
SC_SEQUENCE_HEADER => {
// Capture everything from here to next start code
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
self.seq_header = Some(data[i..end].to_vec());
has_seq_header = true;
}
SC_ENTRY_POINT => {
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
self.entry_point = Some(data[i..end].to_vec());
}
SC_FRAME => {
// First frame after sequence header + entry point is a keyframe
if self.seq_header.is_some() && self.entry_point.is_some() {
keyframe = true;
}
// Also check frame type from bitstream (bit after start code)
if i + 4 < data.len() {
// For Advanced profile: first 2 bits of frame data indicate type
// But simpler: any frame preceded by seq+entry is I-frame
// Frame data starts at this start code
if frame_start.is_none() {
frame_start = Some(i);
}
}
_ => {}
@@ -66,10 +63,20 @@ impl CodecParser for Vc1Parser {
}
}
// Keyframe = this PES contains a sequence header (I-frame indicator in BD)
let keyframe = has_seq_header;
// Strip sequence header + entry point from frame data — those are in codecPrivate.
// Only include data from the frame start code onwards.
let frame_data = match frame_start {
Some(start) => &data[start..],
None => data, // no frame start code found, pass through entire PES
};
vec![Frame {
pts_ns,
pts_ns: ts_ns,
keyframe,
data: pes.data.clone(),
data: frame_data.to_vec(),
}]
}
@@ -113,3 +120,225 @@ fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1011, pts, dts: None, data }
}
/// Build a VC-1 PES with sequence header + entry point + frame start code.
fn build_vc1_iframe_pes() -> Vec<u8> {
let mut data = Vec::new();
// Sequence header: 00 00 01 0F + payload
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
// Entry point: 00 00 01 0E + payload
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_ENTRY_POINT]);
data.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]);
// Frame: 00 00 01 0D + payload
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66, 0x77, 0x88, 0x99]);
data
}
// --- sequence header detection ---
#[test]
fn parse_sequence_header() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Sequence header present → keyframe
assert!(frames[0].keyframe, "PES with sequence header should be keyframe");
// seq_header should be stored internally
assert!(parser.seq_header.is_some());
}
#[test]
fn parse_entry_point() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data, Some(0));
parser.parse(&pes);
assert!(parser.entry_point.is_some());
}
// --- codec_private is BITMAPINFOHEADER (40+ bytes) ---
#[test]
fn codec_private_bitmapinfoheader() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data, Some(0));
parser.parse(&pes);
let cp = parser.codec_private();
assert!(cp.is_some(), "codec_private should be Some after seq header + entry point");
let cp = cp.unwrap();
// BITMAPINFOHEADER is 40 bytes + extra data
assert!(cp.len() >= 40, "codec_private should be at least 40 bytes (BITMAPINFOHEADER)");
// biSize (first 4 bytes, little-endian) should equal total length
let bi_size = u32::from_le_bytes([cp[0], cp[1], cp[2], cp[3]]);
assert_eq!(bi_size as usize, cp.len(), "biSize should match total codec_private length");
// biCompression = "WVC1" at offset 16
assert_eq!(&cp[16..20], b"WVC1", "FOURCC should be WVC1");
// biWidth at offset 4 (little-endian u32) = 1920
let width = u32::from_le_bytes([cp[4], cp[5], cp[6], cp[7]]);
assert_eq!(width, 1920);
// biHeight at offset 8 (little-endian u32) = 1080
let height = u32::from_le_bytes([cp[8], cp[9], cp[10], cp[11]]);
assert_eq!(height, 1080);
}
#[test]
fn codec_private_none_before_data() {
let parser = Vc1Parser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn codec_private_none_missing_entry_point() {
let mut parser = Vc1Parser::new();
// Only sequence header, no entry point
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66]);
let pes = make_pes(data, Some(0));
parser.parse(&pes);
assert!(parser.codec_private().is_none(), "should be None without entry point");
}
// --- frame without sequence header → not keyframe ---
#[test]
fn parse_non_keyframe() {
let mut parser = Vc1Parser::new();
// PES with only a frame start code (no sequence header)
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66, 0x77]);
let pes = make_pes(data, Some(180000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "frame without sequence header should not be keyframe");
}
// --- frame data starts from frame start code ---
#[test]
fn frame_data_starts_at_frame_sc() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data.clone(), Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Frame data should start with the frame start code (00 00 01 0D)
assert!(frames[0].data.len() >= 4);
assert_eq!(&frames[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
}
// --- empty PES ---
#[test]
fn parse_empty_pes() {
let mut parser = Vc1Parser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = Vc1Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// --- DTS preferred over PTS ---
#[test]
fn dts_preferred_over_pts() {
let mut parser = Vc1Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66]);
let pes = PesPacket {
pid: 0x1011,
pts: Some(180000),
dts: Some(90000),
data,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// --- find_next_sc utility ---
#[test]
fn find_next_sc_basic() {
let data = [0xAA, 0x00, 0x00, 0x01, 0x0D, 0xBB];
assert_eq!(find_next_sc(&data, 0), Some(1));
}
#[test]
fn find_next_sc_none() {
let data = [0xAA, 0xBB, 0xCC];
assert_eq!(find_next_sc(&data, 0), None);
}
// --- codec_private extra data contains seq header + entry point ---
#[test]
fn codec_private_contains_extra_data() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data, Some(0));
parser.parse(&pes);
let cp = parser.codec_private().unwrap();
// After the 40-byte BITMAPINFOHEADER, we should have seq_header + entry_point data
let extra = &cp[40..];
assert!(!extra.is_empty(), "extra data after BITMAPINFOHEADER should not be empty");
// Extra data should start with the sequence header start code
assert_eq!(&extra[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
}
}
+130
View File
@@ -0,0 +1,130 @@
//! DiscStream — read BD-TS data from an optical disc drive.
//!
//! Read-only stream. Wraps DriveSession + Disc.
//! Handles drive init, AACS decryption, and sector reading.
use std::io::{self, Read, Write};
use std::path::Path;
use super::IOStream;
use crate::disc::{DiscTitle, Disc};
use crate::drive::DriveSession;
use crate::error::Error;
/// Options for opening a disc stream.
pub struct DiscOptions {
/// Device path (e.g. "/dev/sg4"). None = auto-detect.
pub device: Option<String>,
/// KEYDB.cfg path. None = search standard locations.
pub keydb_path: Option<String>,
/// Which title to read (0-based). None = longest title.
pub title_index: Option<usize>,
}
impl Default for DiscOptions {
fn default() -> Self {
Self { device: None, keydb_path: None, title_index: None }
}
}
/// Optical disc stream. Read-only — yields decrypted BD-TS bytes.
pub struct DiscStream {
disc_title: DiscTitle,
disc: Disc,
session: DriveSession,
title_index: usize,
// Read buffer: holds one batch from ContentReader
batch_buf: Vec<u8>,
batch_pos: usize,
started: bool,
eof: bool,
}
impl DiscStream {
/// Open the disc drive and scan disc metadata.
pub fn open(opts: DiscOptions) -> Result<Self, Error> {
let device = match opts.device {
Some(ref d) => crate::drive::resolve_device(d)?.0,
None => crate::drive::find_drive()
.ok_or_else(|| Error::DeviceNotFound { path: String::new() })?,
};
let mut session = DriveSession::open(Path::new(&device))?;
session.wait_ready()?;
let _ = session.init();
let _ = session.probe_disc();
let scan_opts = match opts.keydb_path {
Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp),
None => crate::disc::ScanOptions::default(),
};
let disc = Disc::scan(&mut session, &scan_opts)?;
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();
Ok(Self {
disc_title, disc, session, title_index,
batch_buf: Vec::new(), batch_pos: 0,
started: false, eof: false,
})
}
/// Get the full Disc (for listing all titles, etc.)
pub fn disc(&self) -> &Disc { &self.disc }
}
impl IOStream for DiscStream {
fn info(&self) -> &DiscTitle { &self.disc_title }
fn finish(&mut self) -> io::Result<()> { Ok(()) }
}
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;
return Ok(n);
}
if self.eof { return Ok(0); }
// Open reader on first call
if !self.started {
self.started = true;
}
// Read next batch via a temporary ContentReader
// ContentReader borrows session and disc, so we create it inline
let mut reader = self.disc.open_title(&mut self.session, self.title_index)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
match reader.read_batch() {
Ok(Some(batch)) => {
let n = batch.len().min(buf.len());
buf[..n].copy_from_slice(&batch[..n]);
if batch.len() > n {
self.batch_buf = batch.to_vec();
self.batch_pos = n;
} else {
self.batch_buf.clear();
self.batch_pos = 0;
}
Ok(n)
}
Ok(None) => { self.eof = true; Ok(0) }
Err(e) => Err(io::Error::new(io::ErrorKind::Other, e.to_string())),
}
}
}
impl Write for DiscStream {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::Unsupported, "disc is read-only"))
}
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
+269 -4
View File
@@ -3,16 +3,16 @@
//! EBML uses variable-length integers for element IDs and sizes.
//! This module provides low-level writers for constructing MKV files.
use std::io::{self, Write, Seek, SeekFrom};
use std::io::{self, Read, Write, Seek, SeekFrom};
/// Write an EBML element ID (1-4 bytes, already encoded).
/// Element IDs are predefined constants — we write them verbatim.
pub fn write_id(w: &mut impl Write, id: u32) -> io::Result<()> {
if id <= 0x7F {
if id <= 0xFF {
w.write_all(&[id as u8])
} else if id <= 0x7FFF {
} else if id <= 0xFFFF {
w.write_all(&[(id >> 8) as u8, id as u8])
} else if id <= 0x7F_FFFF {
} else if id <= 0xFF_FFFF {
w.write_all(&[(id >> 16) as u8, (id >> 8) as u8, id as u8])
} else {
w.write_all(&[(id >> 24) as u8, (id >> 16) as u8, (id >> 8) as u8, id as u8])
@@ -140,6 +140,149 @@ pub fn end_master<W: Write + Seek>(w: &mut W, size_pos: u64) -> io::Result<()> {
Ok(())
}
// ============================================================
// EBML Read primitives
// ============================================================
/// Read an EBML element ID. Returns (id, bytes_consumed).
pub fn read_id(r: &mut impl Read) -> io::Result<(u32, usize)> {
let mut first = [0u8; 1];
r.read_exact(&mut first)?;
let b0 = first[0];
if b0 & 0x80 != 0 {
Ok((b0 as u32, 1))
} else if b0 & 0x40 != 0 {
let mut b = [0u8; 1];
r.read_exact(&mut b)?;
Ok((((b0 as u32) << 8) | b[0] as u32, 2))
} else if b0 & 0x20 != 0 {
let mut b = [0u8; 2];
r.read_exact(&mut b)?;
Ok((((b0 as u32) << 16) | (b[0] as u32) << 8 | b[1] as u32, 3))
} else if b0 & 0x10 != 0 {
let mut b = [0u8; 3];
r.read_exact(&mut b)?;
Ok((((b0 as u32) << 24) | (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32, 4))
} else {
Err(io::Error::new(io::ErrorKind::InvalidData, "invalid EBML ID"))
}
}
/// Read an EBML variable-length size. Returns (size, bytes_consumed).
/// Size of u64::MAX means "unknown size".
pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
let mut first = [0u8; 1];
r.read_exact(&mut first)?;
let b0 = first[0];
if b0 & 0x80 != 0 {
let val = (b0 & 0x7F) as u64;
if val == 0x7F { return Ok((u64::MAX, 1)); } // unknown
Ok((val, 1))
} else if b0 & 0x40 != 0 {
let mut b = [0u8; 1];
r.read_exact(&mut b)?;
let val = (((b0 & 0x3F) as u64) << 8) | b[0] as u64;
if val == 0x3FFF { return Ok((u64::MAX, 2)); }
Ok((val, 2))
} else if b0 & 0x20 != 0 {
let mut b = [0u8; 2];
r.read_exact(&mut b)?;
let val = (((b0 & 0x1F) as u64) << 16) | (b[0] as u64) << 8 | b[1] as u64;
if val == 0x1FFFFF { return Ok((u64::MAX, 3)); }
Ok((val, 3))
} else if b0 & 0x10 != 0 {
let mut b = [0u8; 3];
r.read_exact(&mut b)?;
let val = (((b0 & 0x0F) as u64) << 24) | (b[0] as u64) << 16 | (b[1] as u64) << 8 | b[2] as u64;
if val == 0x0FFFFFFF { return Ok((u64::MAX, 4)); }
Ok((val, 4))
} else if b0 & 0x08 != 0 {
let mut b = [0u8; 4];
r.read_exact(&mut b)?;
let val = (((b0 & 0x07) as u64) << 32) | (b[0] as u64) << 24 | (b[1] as u64) << 16 | (b[2] as u64) << 8 | b[3] as u64;
Ok((val, 5))
} else if b0 & 0x04 != 0 {
let mut b = [0u8; 5];
r.read_exact(&mut b)?;
let val = (((b0 & 0x03) as u64) << 40) | (b[0] as u64) << 32 | (b[1] as u64) << 24 | (b[2] as u64) << 16 | (b[3] as u64) << 8 | b[4] as u64;
Ok((val, 6))
} else if b0 & 0x02 != 0 {
let mut b = [0u8; 6];
r.read_exact(&mut b)?;
let val = (((b0 & 0x01) as u64) << 48) | (b[0] as u64) << 40 | (b[1] as u64) << 32 | (b[2] as u64) << 24 | (b[3] as u64) << 16 | (b[4] as u64) << 8 | b[5] as u64;
Ok((val, 7))
} else {
let mut b = [0u8; 7];
r.read_exact(&mut b)?;
let val = (b[0] as u64) << 48 | (b[1] as u64) << 40 | (b[2] as u64) << 32 | (b[3] as u64) << 24 | (b[4] as u64) << 16 | (b[5] as u64) << 8 | b[6] as u64;
if val == 0x00FFFFFFFFFFFFFF { return Ok((u64::MAX, 8)); }
Ok((val, 8))
}
}
/// Read an EBML element header (ID + size). Returns (id, data_size, header_bytes).
pub fn read_element_header(r: &mut impl Read) -> io::Result<(u32, u64, usize)> {
let (id, id_len) = read_id(r)?;
let (size, size_len) = read_size(r)?;
Ok((id, size, id_len + size_len))
}
/// Read an unsigned integer value of `len` bytes.
pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> {
let mut buf = [0u8; 8];
r.read_exact(&mut buf[..len])?;
let mut val = 0u64;
for &b in &buf[..len] {
val = (val << 8) | b as u64;
}
Ok(val)
}
/// Read a float value (4 or 8 bytes).
pub fn read_float_val(r: &mut impl Read, len: usize) -> io::Result<f64> {
if len == 4 {
let mut buf = [0u8; 4];
r.read_exact(&mut buf)?;
Ok(f32::from_be_bytes(buf) as f64)
} else {
let mut buf = [0u8; 8];
r.read_exact(&mut buf)?;
Ok(f64::from_be_bytes(buf))
}
}
/// Read a UTF-8 string value of `len` bytes.
pub fn read_string_val(r: &mut impl Read, len: usize) -> io::Result<String> {
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
// Strip trailing nulls
while buf.last() == Some(&0) { buf.pop(); }
String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
/// Read binary data of `len` bytes.
pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
Ok(buf)
}
/// Read a VINT (track number) from a SimpleBlock. Returns (value, bytes_consumed).
pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
let mut first = [0u8; 1];
r.read_exact(&mut first)?;
let b0 = first[0];
if b0 & 0x80 != 0 { return Ok(((b0 & 0x7F) as u64, 1)); }
if b0 & 0x40 != 0 {
let mut b = [0u8; 1];
r.read_exact(&mut b)?;
return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
}
Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported VINT width"))
}
// ============================================================
// Matroska Element IDs
// ============================================================
@@ -183,6 +326,7 @@ pub const FLAG_FORCED: u32 = 0x55AA;
pub const LANGUAGE: u32 = 0x22B59C;
pub const CODEC_ID: u32 = 0x86;
pub const CODEC_PRIVATE: u32 = 0x63A2;
pub const TRACK_NAME: u32 = 0x536E;
pub const DEFAULT_DURATION: u32 = 0x23E383;
// Video
@@ -269,4 +413,125 @@ mod tests {
// EBML header: 1A 45 DF A3, then 8-byte size, then content
assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]);
}
#[test]
fn write_read_id_roundtrip() {
// 1-byte IDs have high bit set (0x80..=0xFF)
for &id in &[0x80u32, 0xA3, 0xFF] {
let mut buf = Vec::new();
write_id(&mut buf, id).unwrap();
assert_eq!(buf.len(), 1);
let mut cursor = Cursor::new(&buf);
let (read_back, consumed) = read_id(&mut cursor).unwrap();
assert_eq!(read_back, id, "1-byte ID roundtrip failed for 0x{:X}", id);
assert_eq!(consumed, 1);
}
// 2-byte IDs (0x4000..=0x7FFF)
for &id in &[0x4286u32, 0x4282, 0x7FFF] {
let mut buf = Vec::new();
write_id(&mut buf, id).unwrap();
assert_eq!(buf.len(), 2);
let mut cursor = Cursor::new(&buf);
let (read_back, consumed) = read_id(&mut cursor).unwrap();
assert_eq!(read_back, id, "2-byte ID roundtrip failed for 0x{:X}", id);
assert_eq!(consumed, 2);
}
// 3-byte IDs (0x200000..=0x3FFFFF)
for &id in &[0x22B59Cu32, 0x23E383] {
let mut buf = Vec::new();
write_id(&mut buf, id).unwrap();
assert_eq!(buf.len(), 3);
let mut cursor = Cursor::new(&buf);
let (read_back, consumed) = read_id(&mut cursor).unwrap();
assert_eq!(read_back, id, "3-byte ID roundtrip failed for 0x{:X}", id);
assert_eq!(consumed, 3);
}
// 4-byte IDs (0x10000000..=0x1FFFFFFF)
for &id in &[EBML, SEGMENT, TRACKS, CLUSTER] {
let mut buf = Vec::new();
write_id(&mut buf, id).unwrap();
assert_eq!(buf.len(), 4);
let mut cursor = Cursor::new(&buf);
let (read_back, consumed) = read_id(&mut cursor).unwrap();
assert_eq!(read_back, id, "4-byte ID roundtrip failed for 0x{:X}", id);
assert_eq!(consumed, 4);
}
}
#[test]
fn write_read_size_roundtrip() {
let test_sizes: &[u64] = &[0, 1, 0x7E, 127, 128, 0x3FFE, 16383, 16384, 0x1FFFFE, 0x0FFFFFFE, 0x1_0000_0000];
for &size in test_sizes {
let mut buf = Vec::new();
write_size(&mut buf, size).unwrap();
let mut cursor = Cursor::new(&buf);
let (read_back, _consumed) = read_size(&mut cursor).unwrap();
assert_eq!(read_back, size, "size roundtrip failed for {}", size);
}
}
#[test]
fn write_read_uint_roundtrip() {
let test_vals: &[u64] = &[0, 1, 127, 255, 256, 0xFFFF, 0xFF_FFFF, 0xFFFF_FFFF, 1_000_000_000_000];
let test_id = EBML_VERSION;
for &val in test_vals {
let mut buf = Vec::new();
write_uint(&mut buf, test_id, val).unwrap();
let mut cursor = Cursor::new(&buf);
let (id, id_len) = read_id(&mut cursor).unwrap();
assert_eq!(id, test_id);
let (size, _) = read_size(&mut cursor).unwrap();
let read_val = read_uint_val(&mut cursor, size as usize).unwrap();
assert_eq!(read_val, val, "uint roundtrip failed for {}", val);
}
}
#[test]
fn write_read_string_roundtrip() {
let test_strings = &["", "matroska", "freemkv", "Hello, World!", "unicode: \u{1F600}"];
let test_id = EBML_DOC_TYPE;
for &s in test_strings {
let mut buf = Vec::new();
write_string(&mut buf, test_id, s).unwrap();
let mut cursor = Cursor::new(&buf);
let (id, _) = read_id(&mut cursor).unwrap();
assert_eq!(id, test_id);
let (size, _) = read_size(&mut cursor).unwrap();
let read_s = read_string_val(&mut cursor, size as usize).unwrap();
assert_eq!(read_s, s, "string roundtrip failed for {:?}", s);
}
}
#[test]
fn write_read_float_roundtrip() {
let test_vals: &[f64] = &[0.0, 1.0, -1.0, 3.14159265358979, 48000.0, 7200000.0, f64::MIN, f64::MAX];
let test_id = DURATION;
for &val in test_vals {
let mut buf = Vec::new();
write_float(&mut buf, test_id, val).unwrap();
let mut cursor = Cursor::new(&buf);
let (id, _) = read_id(&mut cursor).unwrap();
assert_eq!(id, test_id);
let (size, _) = read_size(&mut cursor).unwrap();
assert_eq!(size, 8);
let read_val = read_float_val(&mut cursor, size as usize).unwrap();
assert_eq!(read_val.to_bits(), val.to_bits(), "float roundtrip failed for {}", val);
}
}
#[test]
fn unknown_size() {
let mut buf = Vec::new();
write_unknown_size(&mut buf).unwrap();
assert_eq!(buf.len(), 8);
assert_eq!(buf[0], 0x01);
for &b in &buf[1..] {
assert_eq!(b, 0xFF, "unknown size bytes should all be 0xFF after first byte");
}
// Reading it back should yield u64::MAX
let mut cursor = Cursor::new(&buf);
let (size, consumed) = read_size(&mut cursor).unwrap();
assert_eq!(size, u64::MAX);
assert_eq!(consumed, 8);
}
}
+290
View File
@@ -0,0 +1,290 @@
//! IsoStream — read/write Blu-ray ISO disc images.
//!
//! Read: parses UDF filesystem inside the ISO using the same pipeline as
//! DiscStream (titles, streams, labels, AACS). An ISO is a flat image of
//! 2048-byte sectors — sector N starts at byte offset N * 2048.
//!
//! Write: creates a sector-by-sector disc image from a SectorReader source.
use std::io::{self, Read, Write, Seek, SeekFrom};
use std::fs::File;
use std::path::Path;
use super::IOStream;
use crate::disc::{Disc, DiscTitle, ScanOptions};
use crate::sector::SectorReader;
use crate::error::{Error, Result};
const SECTOR_SIZE: u64 = 2048;
/// File-backed sector reader for ISO images.
pub struct IsoSectorReader {
file: File,
capacity: u32,
}
impl IsoSectorReader {
pub fn open(path: &str) -> io::Result<Self> {
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;
Ok(Self { file, capacity })
}
pub fn capacity(&self) -> u32 { self.capacity }
}
impl SectorReader for IsoSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let bytes = count as usize * SECTOR_SIZE as usize;
self.file.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE))
.map_err(|e| Error::IoError { source: e })?;
self.file.read_exact(&mut buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
Ok(bytes)
}
}
/// Blu-ray ISO image stream.
///
/// Read: opens ISO, parses UDF (same as DiscStream), streams BD-TS content.
/// Write: receives sector data and writes to ISO file.
pub struct IsoStream {
disc_title: DiscTitle,
disc: Option<Disc>,
reader: Option<IsoSectorReader>,
writer: Option<io::BufWriter<File>>,
/// Sector ranges to read: (start_lba, sector_count)
extents: Vec<(u32, u32)>,
extent_idx: usize,
sectors_remaining: u32,
sector_buf: [u8; SECTOR_SIZE as usize],
buf_pos: usize,
buf_len: usize,
eof: bool,
}
impl IsoStream {
/// Open an ISO file for reading. Parses UDF, scans titles, streams, labels.
pub fn open(path: &str, title_index: Option<usize>, opts: &ScanOptions) -> io::Result<Self> {
let mut reader = IsoSectorReader::open(path)?;
let capacity = reader.capacity();
let disc = Disc::scan_image(&mut reader, capacity, opts)
.map_err(|e| io::Error::new(io::ErrorKind::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() {
return Err(io::Error::new(io::ErrorKind::NotFound, "no titles found in ISO image"));
} else {
disc.titles[idx].clone()
};
let extents: Vec<(u32, u32)> = disc_title.extents.iter()
.map(|e| (e.start_lba, e.sector_count))
.collect();
let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0);
Ok(IsoStream {
disc_title,
disc: Some(disc),
reader: Some(reader),
writer: None,
extents,
extent_idx: 0,
sectors_remaining,
sector_buf: [0u8; SECTOR_SIZE as usize],
buf_pos: 0,
buf_len: 0,
eof: false,
})
}
/// Create an ISO file for writing. Receives raw sector data.
pub fn create(path: &str) -> io::Result<Self> {
let file = File::create(Path::new(path))
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
Ok(IsoStream {
disc_title: DiscTitle::empty(),
disc: None,
reader: None,
writer: Some(writer),
extents: Vec::new(),
extent_idx: 0,
sectors_remaining: 0,
sector_buf: [0u8; SECTOR_SIZE as usize],
buf_pos: 0,
buf_len: 0,
eof: false,
})
}
/// Set metadata (for write mode).
pub fn meta(mut self, dt: &DiscTitle) -> Self {
self.disc_title = dt.clone();
self
}
/// Get the full Disc (for listing all titles).
pub fn disc(&self) -> Option<&Disc> { self.disc.as_ref() }
/// Read the next sector from the current extent.
fn read_next_sector(&mut self) -> io::Result<bool> {
let reader = match self.reader.as_mut() {
Some(r) => r,
None => return Ok(false),
};
if self.extent_idx >= self.extents.len() {
return Ok(false);
}
let (start_lba, total) = self.extents[self.extent_idx];
let offset = total - self.sectors_remaining;
let lba = start_lba + offset;
reader.read_sectors(lba, 1, &mut self.sector_buf)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
self.buf_pos = 0;
self.buf_len = SECTOR_SIZE as usize;
self.sectors_remaining -= 1;
if self.sectors_remaining == 0 {
self.extent_idx += 1;
if self.extent_idx < self.extents.len() {
self.sectors_remaining = self.extents[self.extent_idx].1;
}
}
Ok(true)
}
}
impl IOStream for IsoStream {
fn info(&self) -> &DiscTitle { &self.disc_title }
fn finish(&mut self) -> io::Result<()> {
if let Some(ref mut w) = self.writer {
w.flush()?;
}
Ok(())
}
}
impl Read for IsoStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.eof { return Ok(0); }
// Drain current sector buffer
if self.buf_pos < self.buf_len {
let n = (self.buf_len - self.buf_pos).min(buf.len());
buf[..n].copy_from_slice(&self.sector_buf[self.buf_pos..self.buf_pos + n]);
self.buf_pos += n;
return Ok(n);
}
// Read next sector
if self.read_next_sector()? {
let n = self.buf_len.min(buf.len());
buf[..n].copy_from_slice(&self.sector_buf[..n]);
self.buf_pos = n;
Ok(n)
} else {
self.eof = true;
Ok(0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use crate::sector::SectorReader;
#[test]
fn iso_reader_read_sectors() {
// Create a temp file with known sector data
let dir = std::env::temp_dir();
let path = dir.join("libfreemkv_test_iso_sectors.iso");
let path_str = path.to_str().unwrap();
// Write 4 sectors of known data
{
let mut f = File::create(&path).unwrap();
for sector_idx in 0u8..4 {
let mut sector = [sector_idx; SECTOR_SIZE as usize];
sector[0] = sector_idx;
sector[2047] = sector_idx.wrapping_mul(0x37);
f.write_all(&sector).unwrap();
}
f.flush().unwrap();
}
let mut reader = IsoSectorReader::open(path_str).unwrap();
assert_eq!(reader.capacity(), 4);
// Read sector 0
let mut buf = [0u8; SECTOR_SIZE as usize];
let n = reader.read_sectors(0, 1, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE as usize);
assert_eq!(buf[0], 0);
assert_eq!(buf[2047], 0u8.wrapping_mul(0x37));
// Read sector 2
let n = reader.read_sectors(2, 1, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE as usize);
assert_eq!(buf[0], 2);
assert_eq!(buf[1], 2); // filled with sector_idx
assert_eq!(buf[2047], 2u8.wrapping_mul(0x37));
// Read 2 sectors at once (sectors 1 and 2)
let mut buf2 = [0u8; SECTOR_SIZE as usize * 2];
let n = reader.read_sectors(1, 2, &mut buf2).unwrap();
assert_eq!(n, SECTOR_SIZE as usize * 2);
assert_eq!(buf2[0], 1); // sector 1 first byte
assert_eq!(buf2[SECTOR_SIZE as usize], 2); // sector 2 first byte
// Clean up
let _ = std::fs::remove_file(&path);
}
#[test]
fn iso_reader_capacity() {
let dir = std::env::temp_dir();
let path = dir.join("libfreemkv_test_iso_capacity.iso");
let path_str = path.to_str().unwrap();
// Write exactly 10 sectors
{
let mut f = File::create(&path).unwrap();
let data = vec![0u8; SECTOR_SIZE as usize * 10];
f.write_all(&data).unwrap();
f.flush().unwrap();
}
let reader = IsoSectorReader::open(path_str).unwrap();
assert_eq!(reader.capacity(), 10);
// Clean up
let _ = std::fs::remove_file(&path);
}
}
impl Write for IsoStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.writer.as_mut() {
Some(w) => w.write(buf),
None => Err(io::Error::new(io::ErrorKind::Unsupported,
"iso:// opened for reading — cannot write")),
}
}
fn flush(&mut self) -> io::Result<()> {
match self.writer.as_mut() {
Some(w) => w.flush(),
None => Ok(()),
}
}
}
+139
View File
@@ -0,0 +1,139 @@
//! M2tsStream — BD transport stream with embedded metadata header.
//!
//! Write: prepends FMKV metadata header, then passes through BD-TS bytes.
//! Read: extracts metadata header (or scans PMT), then yields BD-TS bytes.
use std::io::{self, Read, Write, Seek, SeekFrom};
use super::{IOStream, ReadSeek, meta, ts};
use crate::disc::{DiscTitle, Stream as DiscStream};
/// Size of initial scan buffer for PMT/stream detection.
const SCAN_SIZE: usize = 1024 * 1024;
enum Mode {
Write {
writer: Box<dyn Write>,
header_written: bool,
},
Read {
reader: Box<dyn ReadSeek>,
},
}
/// BD transport stream with embedded metadata.
pub struct M2tsStream {
disc_title: DiscTitle,
mode: Mode,
finished: bool,
}
impl M2tsStream {
/// Create for writing. Metadata header is written on first write().
pub fn new(writer: impl Write + 'static) -> Self {
Self {
disc_title: DiscTitle::empty(),
mode: Mode::Write {
writer: Box::new(writer),
header_written: false,
},
finished: false,
}
}
/// 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.
///
/// Tries FMKV metadata header first. Falls back to PMT scan + PTS duration.
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
// Try FMKV metadata header
if let Ok(Some(m)) = meta::read_header(&mut reader) {
return Ok(Self {
disc_title: m.to_title(),
mode: Mode::Read { reader: Box::new(reader) },
finished: false,
});
}
// 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])
.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);
reader.seek(SeekFrom::Start(0))?;
Ok(Self {
disc_title: DiscTitle {
duration_secs: duration,
streams,
..DiscTitle::empty()
},
mode: Mode::Read { reader: Box::new(reader) },
finished: false,
})
}
}
impl IOStream for M2tsStream {
fn info(&self) -> &DiscTitle { &self.disc_title }
fn finish(&mut self) -> io::Result<()> {
if self.finished { return Ok(()); }
self.finished = true;
if let Mode::Write { ref mut writer, .. } = self.mode {
writer.flush()
} else {
Ok(())
}
}
}
impl Write for M2tsStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.mode {
Mode::Write { ref mut writer, ref mut header_written } => {
if !*header_written {
if !self.disc_title.streams.is_empty() {
let m = meta::M2tsMeta::from_title(&self.disc_title);
meta::write_header(&mut *writer, &m)?;
}
*header_written = true;
}
writer.write(buf)
}
Mode::Read { .. } => Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for reading")),
}
}
fn flush(&mut self) -> io::Result<()> {
if let Mode::Write { ref mut writer, .. } = self.mode {
writer.flush()
} else {
Ok(())
}
}
}
impl Read for M2tsStream {
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")),
}
}
}
+293
View File
@@ -0,0 +1,293 @@
//! M2TS metadata header — embeds title/stream info in raw m2ts files.
//!
//! 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 std::io::{self, Read, Seek, SeekFrom, Write};
use serde::{Serialize, Deserialize};
use crate::disc::{DiscTitle, Stream, VideoStream, AudioStream, SubtitleStream,
Codec, HdrFormat, ColorSpace};
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
/// BD-TS packet size (header must be padded to this boundary).
const PACKET_SIZE: usize = 192;
/// Metadata embedded in an m2ts file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct M2tsMeta {
/// Format version.
pub v: u8,
/// Title name (e.g. filename stem or disc title).
#[serde(default)]
pub title: String,
/// Duration in seconds.
#[serde(default)]
pub duration: f64,
/// Stream descriptors.
pub streams: Vec<MetaStream>,
}
/// A single stream descriptor in the metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum MetaStream {
#[serde(rename = "video")]
Video {
pid: u16,
codec: String,
#[serde(default)] resolution: String,
#[serde(default)] frame_rate: String,
#[serde(default)] hdr: String,
#[serde(default)] label: String,
#[serde(default)] secondary: bool,
},
#[serde(rename = "audio")]
Audio {
pid: u16,
codec: String,
#[serde(default)] channels: String,
#[serde(default)] language: String,
#[serde(default)] sample_rate: String,
#[serde(default)] label: String,
#[serde(default)] secondary: bool,
},
#[serde(rename = "subtitle")]
Subtitle {
pid: u16,
codec: String,
#[serde(default)] language: String,
#[serde(default)] forced: bool,
},
}
impl M2tsMeta {
/// Build metadata from a disc Title.
pub fn from_title(title: &DiscTitle) -> Self {
let streams = title.streams.iter().map(|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),
label: v.label.clone(),
secondary: v.secondary,
},
Stream::Audio(a) => MetaStream::Audio {
pid: a.pid,
codec: codec_to_str(a.codec),
channels: a.channels.clone(),
language: a.language.clone(),
sample_rate: a.sample_rate.clone(),
label: a.label.clone(),
secondary: a.secondary,
},
Stream::Subtitle(s) => MetaStream::Subtitle {
pid: s.pid,
codec: codec_to_str(s.codec),
language: s.language.clone(),
forced: s.forced,
},
}).collect();
Self {
v: 1,
title: title.playlist.clone(),
duration: title.duration_secs,
streams,
}
}
/// Convert back to a library Title (for remux).
pub fn to_title(&self) -> DiscTitle {
let streams = self.streams.iter().map(|s| match s {
MetaStream::Video { pid, codec, resolution, frame_rate, hdr, label, secondary } => {
Stream::Video(VideoStream {
pid: *pid,
codec: str_to_codec(codec),
resolution: resolution.clone(),
frame_rate: frame_rate.clone(),
hdr: str_to_hdr(hdr),
color_space: ColorSpace::Bt709,
secondary: *secondary,
label: label.clone(),
})
}
MetaStream::Audio { pid, codec, channels, language, sample_rate, label, secondary } => {
Stream::Audio(AudioStream {
pid: *pid,
codec: str_to_codec(codec),
channels: channels.clone(),
language: language.clone(),
sample_rate: sample_rate.clone(),
secondary: *secondary,
label: label.clone(),
})
}
MetaStream::Subtitle { pid, codec, language, forced } => {
Stream::Subtitle(SubtitleStream {
pid: *pid,
codec: str_to_codec(codec),
language: language.clone(),
forced: *forced,
})
}
}).collect();
DiscTitle {
playlist: self.title.clone(),
playlist_id: 0,
duration_secs: self.duration,
size_bytes: 0,
clips: Vec::new(),
streams,
extents: Vec::new(),
}
}
}
/// Write the metadata header to a writer. Padded to 192-byte boundary.
pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
let json = serde_json::to_vec(meta)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let json_len = json.len() as u32;
let raw_len = 8 + 4 + json.len(); // magic + len + json
let padded_len = ((raw_len + PACKET_SIZE - 1) / PACKET_SIZE) * PACKET_SIZE;
let padding = padded_len - raw_len;
w.write_all(&MAGIC)?;
w.write_all(&json_len.to_be_bytes())?;
w.write_all(&json)?;
if padding > 0 {
w.write_all(&vec![0u8; padding])?;
}
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()?;
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;
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 to next 192-byte boundary
let raw_len = 8 + 4 + json_len;
let padded_len = ((raw_len + PACKET_SIZE - 1) / 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 + PACKET_SIZE - 1) / PACKET_SIZE) * PACKET_SIZE;
let padding = padded_len - raw_len;
if padding > 0 {
let mut skip = vec![0u8; padding];
r.read_exact(&mut skip)?;
}
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::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,
}
}
+7
View File
@@ -13,6 +13,7 @@ pub struct MkvTrack {
pub track_type: u64, // 1=video, 2=audio, 17=subtitle
pub codec_id: &'static str,
pub language: String,
pub name: String, // Track name / label (e.g. "English (Lossless)")
pub codec_private: Option<Vec<u8>>,
pub is_default: bool,
pub is_forced: bool,
@@ -39,6 +40,7 @@ impl MkvTrack {
track_type: ebml::TRACK_TYPE_VIDEO,
codec_id,
language: "und".into(),
name: v.label.clone(),
codec_private: None, // filled later by parser
is_default: !v.secondary,
is_forced: false,
@@ -65,6 +67,7 @@ impl MkvTrack {
track_type: ebml::TRACK_TYPE_AUDIO,
codec_id,
language: a.language.clone(),
name: a.label.clone(),
codec_private: None,
is_default: !a.secondary,
is_forced: false,
@@ -81,6 +84,7 @@ impl MkvTrack {
track_type: ebml::TRACK_TYPE_SUBTITLE,
codec_id: "S_HDMV/PGS",
language: s.language.clone(),
name: String::new(),
codec_private: None,
is_default: false,
is_forced: s.forced,
@@ -163,6 +167,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
ebml::write_string(&mut writer, ebml::LANGUAGE, &track.language)?;
if !track.name.is_empty() {
ebml::write_string(&mut writer, ebml::TRACK_NAME, &track.name)?;
}
if !track.is_default {
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
+512
View File
@@ -0,0 +1,512 @@
//! MkvStream — Matroska container stream.
//!
//! Write: BD-TS bytes in → demux → codec parse → MKV container out.
//! Read: MKV container in → extract frames → wrap as BD-TS → bytes out.
use std::io::{self, Read, Write, Seek, SeekFrom};
use super::{IOStream, WriteSeek, ReadSeek, ebml};
use super::ts::TsDemuxer;
use super::mkv::{MkvMuxer, MkvTrack};
use super::codec::{self, CodecParser};
use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE};
use crate::disc::*;
/// Lookahead buffer for codec header detection (5 MB default).
const DEFAULT_MAX_BUFFER: usize = DEFAULT_LOOKAHEAD_SIZE;
#[derive(Debug, Clone, Copy, PartialEq)]
enum WritePhase { Scanning, Streaming }
struct WriteState {
demuxer: TsDemuxer,
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
writer: Option<Box<dyn WriteSeek>>,
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
tracks: Vec<MkvTrack>,
lookahead: LookaheadBuffer,
phase: WritePhase,
video_pending: usize,
}
struct ReadState {
reader: Box<dyn ReadSeek>,
buf: Vec<u8>,
pos: usize,
len: usize,
cluster_ts_ms: i64,
}
enum Mode {
Write(WriteState),
Read(ReadState),
}
/// Matroska container stream.
pub struct MkvStream {
disc_title: DiscTitle,
mode: Mode,
max_buffer: usize,
finished: bool,
}
impl MkvStream {
/// Create for writing. BD-TS bytes written to this stream produce MKV output.
pub fn new(writer: impl Write + Seek + 'static) -> Self {
Self {
disc_title: DiscTitle::empty(),
mode: Mode::Write(WriteState {
demuxer: TsDemuxer::new(&[]),
muxer: None,
writer: Some(Box::new(writer)),
parsers: Vec::new(),
pid_to_track: Vec::new(),
tracks: Vec::new(),
lookahead: LookaheadBuffer::new(DEFAULT_MAX_BUFFER),
phase: WritePhase::Scanning,
video_pending: 0,
}),
max_buffer: DEFAULT_MAX_BUFFER,
finished: false,
}
}
/// Set stream metadata. Returns self for chaining.
pub fn meta(mut self, dt: &DiscTitle) -> Self {
if let Mode::Write(ref mut ws) = self.mode {
let mut pids = Vec::new();
for s in &dt.streams {
let (pid, track, parser) = match s {
crate::disc::Stream::Video(v) => {
ws.video_pending += 1;
(v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec))
}
crate::disc::Stream::Audio(a) => {
(a.pid, MkvTrack::audio(a), codec::parser_for_codec(a.codec))
}
crate::disc::Stream::Subtitle(s) => {
(s.pid, MkvTrack::subtitle(s), codec::parser_for_codec(s.codec))
}
};
let idx = ws.tracks.len();
pids.push(pid);
ws.pid_to_track.push((pid, idx));
ws.parsers.push((pid, parser));
ws.tracks.push(track);
}
ws.demuxer = TsDemuxer::new(&pids);
}
self.disc_title = dt.clone();
self
}
/// Set lookahead buffer size. Returns self.
pub fn max_buffer(mut self, size: usize) -> Self {
self.max_buffer = size;
if let Mode::Write(ref mut ws) = self.mode {
ws.lookahead = LookaheadBuffer::new(size);
}
self
}
/// Open an MKV file for reading.
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
let disc_title = parse_mkv_header(&mut reader)?;
Ok(Self {
disc_title,
mode: Mode::Read(ReadState {
reader: Box::new(reader),
buf: Vec::new(),
pos: 0,
len: 0,
cluster_ts_ms: 0,
}),
max_buffer: 0,
finished: false,
})
}
}
impl IOStream for MkvStream {
fn info(&self) -> &DiscTitle { &self.disc_title }
fn finish(&mut self) -> io::Result<()> {
if self.finished { return Ok(()); }
self.finished = true;
if let Mode::Write(ref mut ws) = self.mode {
// Flush remaining PES packets
if let Some(ref mut muxer) = ws.muxer {
for pes in &ws.demuxer.flush() {
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
}
}
// Write cues and finalize
if let Some(muxer) = ws.muxer.take() {
muxer.finish()?;
}
}
Ok(())
}
}
// ── Write ──────────────────────────────────────────────────────
impl Write for MkvStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let dt = &self.disc_title;
let ws = match self.mode {
Mode::Write(ref mut ws) => ws,
Mode::Read(_) => return Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for reading")),
};
match ws.phase {
WritePhase::Scanning => {
// Feed demuxer for codec detection
let packets = ws.demuxer.feed(buf);
for pes in &packets {
if let Some((_, p)) = ws.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
let _ = p.parse(pes);
}
}
let state = ws.lookahead.push(buf);
// Check if all video codec headers found
if check_codec_private(ws) {
ws.lookahead.mark_ready();
begin_streaming(ws, dt)?;
return Ok(buf.len());
}
match state {
LookaheadState::Collecting | LookaheadState::Ready => Ok(buf.len()),
LookaheadState::Overflow => Err(io::Error::new(
io::ErrorKind::OutOfMemory,
"no codec headers found within lookahead buffer",
)),
}
}
WritePhase::Streaming => {
let packets = ws.demuxer.feed(buf);
if let Some(ref mut muxer) = ws.muxer {
for pes in &packets {
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
}
}
Ok(buf.len())
}
}
}
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
// ── Read ───────────────────────────────────────────────────────
impl Read for MkvStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let rs = match self.mode {
Mode::Read(ref mut rs) => rs,
Mode::Write(_) => return Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for writing")),
};
// Drain internal buffer first
if rs.pos < rs.len {
let n = (rs.len - rs.pos).min(buf.len());
buf[..n].copy_from_slice(&rs.buf[rs.pos..rs.pos + n]);
rs.pos += n;
return Ok(n);
}
// Read next element from MKV
loop {
let (id, size, _) = match ebml::read_element_header(&mut rs.reader) {
Ok(h) => h,
Err(_) => return Ok(0),
};
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 frame = &block[vl + 3..];
let pts_ms = rs.cluster_ts_ms + rel_ts as i64;
rs.buf.clear();
frame_to_ts(&mut rs.buf, track as u16, pts_ms, frame);
rs.pos = 0;
rs.len = rs.buf.len();
if rs.len > 0 {
let n = rs.len.min(buf.len());
buf[..n].copy_from_slice(&rs.buf[..n]);
rs.pos = n;
return Ok(n);
}
}
_ => {
if size != u64::MAX && size > 0 {
rs.reader.seek(SeekFrom::Current(size as i64))?;
}
}
}
}
}
}
// ── Write internals ────────────────────────────────────────────
fn check_codec_private(ws: &mut WriteState) -> bool {
if ws.video_pending == 0 { return true; }
for (pid, parser) in &ws.parsers {
if let Some(cp) = parser.codec_private() {
if let Some((_, idx)) = ws.pid_to_track.iter().find(|(p, _)| p == pid) {
if ws.tracks[*idx].codec_private.is_none() {
ws.tracks[*idx].codec_private = Some(cp);
ws.video_pending -= 1;
}
}
}
}
ws.video_pending == 0
}
fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> {
let writer = ws.writer.take()
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "writer already consumed"))?;
ws.muxer = Some(MkvMuxer::new(writer, &ws.tracks, Some(&dt.playlist), dt.duration_secs)?);
ws.phase = WritePhase::Streaming;
// Re-parse buffered data through a fresh demuxer
let buffered = ws.lookahead.drain();
if !buffered.is_empty() {
let pids: Vec<u16> = ws.pid_to_track.iter().map(|(pid, _)| *pid).collect();
let mut temp = TsDemuxer::new(&pids);
let packets = temp.feed(&buffered);
if let Some(ref mut muxer) = ws.muxer {
for pes in &packets {
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
}
}
}
Ok(())
}
fn write_pes(
pid_to_track: &[(u16, usize)],
parsers: &mut [(u16, Box<dyn CodecParser>)],
muxer: &mut MkvMuxer<Box<dyn WriteSeek>>,
pes: &super::ts::PesPacket,
) -> io::Result<()> {
let idx = match pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
Some((_, idx)) => *idx,
None => return Ok(()),
};
let parser = match parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
Some((_, p)) => p,
None => return Ok(()),
};
for frame in parser.parse(pes) {
muxer.write_frame(idx, frame.pts_ns, frame.keyframe, &frame.data)?;
}
Ok(())
}
// ── MKV header parsing (read side) ────────────────────────────
fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
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 (id, size, _) = ebml::read_element_header(r)?;
if id != ebml::EBML { return Err(io::Error::new(io::ErrorKind::InvalidData, "not EBML")); }
r.seek(SeekFrom::Current(size as i64))?;
let (id, _, _) = ebml::read_element_header(r)?;
if id != ebml::SEGMENT { return Err(io::Error::new(io::ErrorKind::InvalidData, "no Segment")); }
let (mut got_info, mut got_tracks) = (false, false);
loop {
if got_info && got_tracks { break; }
let (id, size, _) = match ebml::read_element_header(r) { Ok(h) => h, Err(_) => break };
match id {
ebml::INFO => {
let end = r.stream_position()? + size;
while r.stream_position()? < end {
let (cid, cs, _) = ebml::read_element_header(r)?;
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))?; }
}
}
got_info = true;
}
ebml::TRACKS => {
let end = r.stream_position()? + size;
while r.stream_position()? < end {
let (cid, cs, _) = ebml::read_element_header(r)?;
if cid == ebml::TRACK_ENTRY {
if let Some(s) = parse_track(r, cs)? { streams.push(s); }
} else { r.seek(SeekFrom::Current(cs as i64))?; }
}
got_tracks = true;
}
ebml::CLUSTER => break,
_ if size != u64::MAX => { r.seek(SeekFrom::Current(size as i64))?; }
_ => break,
}
}
Ok(DiscTitle {
playlist: title,
duration_secs: duration_ms * (ts_scale as f64) / 1_000_000_000.0,
streams,
..DiscTitle::empty()
})
}
fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate::disc::Stream>> {
let end = r.stream_position()? + size;
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);
while r.stream_position()? < end {
let (cid, cs, _) = ebml::read_element_header(r)?;
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::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)?;
if vid == ebml::PIXEL_HEIGHT { ph = ebml::read_uint_val(r, vs as usize)? as u32; }
else { r.seek(SeekFrom::Current(vs as i64))?; }
}
}
ebml::AUDIO => {
let ae = r.stream_position()? + cs;
while r.stream_position()? < ae {
let (aid, as_, _) = ebml::read_element_header(r)?;
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))?; }
}
}
}
_ => { r.seek(SeekFrom::Current(cs as i64))?; }
}
}
let codec = match codec_id.as_str() {
"V_MPEGH/ISO/HEVC" => Codec::Hevc, "V_MPEG4/ISO/AVC" => Codec::H264,
"V_MS/VFW/FOURCC" => Codec::Vc1, "V_MPEG2" => Codec::Mpeg2,
"A_AC3" => Codec::Ac3, "A_EAC3" => Codec::Ac3Plus,
"A_TRUEHD" => Codec::TrueHd, "A_DTS" => Codec::Dts,
"A_PCM/INT/BIG" => Codec::Lpcm, "S_HDMV/PGS" => Codec::Pgs,
_ => Codec::Unknown(0),
};
let res = format!("{}p", ph);
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();
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,
})),
2 => Some(crate::disc::Stream::Audio(AudioStream {
pid: tnum, codec, channels: chs, language: lang, sample_rate: srs,
secondary: false, label: name,
})),
17 => Some(crate::disc::Stream::Subtitle(SubtitleStream {
pid: tnum, codec, language: lang, forced,
})),
_ => None,
})
}
// ── BD-TS frame wrapping (read side) ──────────────────────────
fn block_vint(d: &[u8]) -> (u64, usize) {
if d.is_empty() { return (0, 0); }
if d[0] & 0x80 != 0 { return ((d[0] & 0x7F) as u64, 1); }
if d[0] & 0x40 != 0 && d.len() >= 2 {
return ((((d[0] & 0x3F) as u64) << 8) | d[1] as u64, 2);
}
(0, 1)
}
fn frame_to_ts(out: &mut Vec<u8>, track: u16, pts_ms: i64, data: &[u8]) {
let pid = if track == 1 { 0x1011 } else { 0x1100 + (track - 2) as u16 };
let stream_id: u8 = if track == 1 { 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);
let mut off = 0;
let mut pusi = true;
while off < pes.len() {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
pkt[5] = (pid >> 8) as u8 & 0x1F;
if pusi { pkt[5] |= 0x40; pusi = false; }
pkt[6] = pid as u8;
let space = 184;
let rem = pes.len() - off;
let n = rem.min(space);
if n < space {
let pad = space - n;
pkt[7] = 0x30; // AF + payload
pkt[8] = pad as u8;
if pad > 1 { pkt[9] = 0x00; }
for i in 10..(8 + pad).min(192) { pkt[i] = 0xFF; }
pkt[8 + pad..8 + pad + n].copy_from_slice(&pes[off..off + n]);
} else {
pkt[7] = 0x10; // payload only
pkt[8..8 + n].copy_from_slice(&pes[off..off + n]);
}
out.extend_from_slice(&pkt);
off += n;
}
}
fn encode_pts(pts: i64) -> [u8; 5] {
let p = pts as u64;
[
0x21 | ((p >> 29) & 0x0E) as u8,
((p >> 22) & 0xFF) as u8,
0x01 | ((p >> 14) & 0xFE) as u8,
((p >> 7) & 0xFF) as u8,
0x01 | ((p << 1) & 0xFE) as u8,
]
}
+54 -13
View File
@@ -1,25 +1,66 @@
//! MKV muxing pipeline.
//! Stream-based I/O pipeline.
//!
//! Provides BD transport stream → MKV remuxing via composable streams.
//!
//! The main type is `MkvStream` — wraps any `Write + Seek` output,
//! receives raw BD-TS bytes via `write()`, outputs MKV.
//! All formats are streams. Two URLs, left reads, right writes:
//!
//! ```text
//! disc.rip(title, MkvStream::new(file, &title))
//! freemkv disc:// mkv://Dune.mkv
//! freemkv m2ts://Dune.m2ts mkv://Dune.mkv
//! freemkv disc:// network://10.0.0.1:9000
//! freemkv disc:// stdio://
//! freemkv stdio:// mkv://Dune.mkv
//! ```
//!
//! Components (for advanced use):
//! - `ts`: BD transport stream demuxer (192-byte packets → PES frames)
//! - `ebml`: EBML write primitives for Matroska container
//! - `mkv`: MKV muxer (tracks, clusters, blocks, cues)
//! - `codec`: Elementary stream parsers (frame boundaries, codec headers)
//! Streams implement `IOStream` for uniform handling:
//!
//! ```text
//! let mut input = open_input("disc://", &opts)?;
//! let mut output = open_output("mkv://Dune.mkv", input.info())?;
//! io::copy(&mut *input, &mut *output)?;
//! output.finish()?;
//! ```
pub mod ebml;
pub mod ts;
pub mod mkv;
pub mod codec;
pub mod lookahead;
pub mod stream;
pub mod meta;
mod m2ts;
mod mkvstream;
pub mod network;
pub mod disc;
pub mod null;
pub mod stdio;
pub mod iso;
pub mod resolve;
pub use stream::MkvStream;
pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream;
pub use network::NetworkStream;
pub use disc::{DiscStream, DiscOptions};
pub use null::NullStream;
pub use stdio::StdioStream;
pub use iso::IsoStream;
pub use resolve::{open_input, open_output, parse_url, InputOptions, StreamUrl};
use std::io::{self, Read, Write, Seek};
use crate::disc::DiscTitle;
/// Common interface for all stream types.
///
/// A stream can be opened for reading or created for writing.
/// Calling the unsupported direction returns an error.
pub trait IOStream: Read + Write {
/// Get stream metadata.
fn info(&self) -> &DiscTitle;
/// Finalize the stream (flush, write index/cues, close).
fn finish(&mut self) -> io::Result<()>;
}
// 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 {}
impl<T: Write + Seek> WriteSeek for T {}
+128
View File
@@ -0,0 +1,128 @@
//! NetworkStream — BD-TS over TCP with embedded metadata.
//!
//! 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).
use std::io::{self, Read, Write, BufReader, BufWriter};
use std::net::{TcpListener, TcpStream};
use super::{IOStream, meta};
use crate::disc::DiscTitle;
/// I/O buffer size for network reads/writes.
const NET_BUF_SIZE: usize = 256 * 1024;
enum Mode {
Write {
writer: BufWriter<TcpStream>,
header_written: bool,
},
Read {
reader: BufReader<TcpStream>,
},
}
/// TCP network stream for distributed rip/remux.
pub struct NetworkStream {
disc_title: DiscTitle,
mode: Mode,
finished: bool,
}
impl NetworkStream {
/// Connect to a remote listener for writing.
/// Sends FMKV metadata header on first write.
pub fn connect(addr: &str) -> io::Result<Self> {
let stream = TcpStream::connect(addr)?;
stream.set_nodelay(true)?;
Ok(Self {
disc_title: DiscTitle::empty(),
mode: Mode::Write {
writer: BufWriter::with_capacity(NET_BUF_SIZE, stream),
header_written: false,
},
finished: false,
})
}
/// Set stream metadata (for write side). Returns self for chaining.
pub fn meta(mut self, dt: &DiscTitle) -> Self {
self.disc_title = dt.clone();
self
}
/// Listen for an incoming connection and read from it.
/// Extracts FMKV metadata header from the sender.
pub fn listen(addr: &str) -> io::Result<Self> {
let listener = TcpListener::bind(addr)?;
let (stream, _peer) = listener.accept()?;
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)?
.ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidData,
"no FMKV metadata header from sender",
))?
.to_title();
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(()); }
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 } => {
if !*header_written {
if !self.disc_title.streams.is_empty() {
let m = meta::M2tsMeta::from_title(&self.disc_title);
meta::write_header(&mut *writer, &m)?;
}
*header_written = true;
}
writer.write(buf)
}
Mode::Read { .. } => Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for reading")),
}
}
fn flush(&mut self) -> io::Result<()> {
if let Mode::Write { ref mut writer, .. } = self.mode {
writer.flush()
} else {
Ok(())
}
}
}
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")),
}
}
}
+88
View File
@@ -0,0 +1,88 @@
//! NullStream — discards all data. Write-only. For benchmarking.
use std::io::{self, Read, Write};
use super::IOStream;
use crate::disc::DiscTitle;
/// Null stream — accepts writes, discards data. For benchmarking rip speed.
pub struct NullStream {
disc_title: DiscTitle,
bytes_written: u64,
}
impl NullStream {
pub fn new() -> Self {
Self { disc_title: DiscTitle::empty(), bytes_written: 0 }
}
pub fn meta(mut self, dt: &DiscTitle) -> Self {
self.disc_title = dt.clone();
self
}
pub fn bytes_written(&self) -> u64 { self.bytes_written }
}
impl IOStream for NullStream {
fn info(&self) -> &DiscTitle { &self.disc_title }
fn finish(&mut self) -> io::Result<()> { Ok(()) }
}
impl Write for NullStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.bytes_written += buf.len() as u64;
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
impl Read for NullStream {
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::Unsupported, "null stream is write-only"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn null_counts_bytes() {
let mut ns = NullStream::new();
assert_eq!(ns.bytes_written(), 0);
ns.write_all(&[0u8; 100]).unwrap();
assert_eq!(ns.bytes_written(), 100);
ns.write_all(&[1u8; 50]).unwrap();
assert_eq!(ns.bytes_written(), 150);
// Single write returns correct count
let n = ns.write(&[0u8; 200]).unwrap();
assert_eq!(n, 200);
assert_eq!(ns.bytes_written(), 350);
}
#[test]
fn null_read_errors() {
let mut ns = NullStream::new();
let mut buf = [0u8; 10];
let err = ns.read(&mut buf).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
}
#[test]
fn null_finish_ok() {
let mut ns = NullStream::new();
ns.write_all(&[0u8; 1000]).unwrap();
ns.finish().unwrap();
}
#[test]
fn null_implements_iostream() {
let ns = NullStream::new();
let mut boxed: Box<dyn IOStream> = Box::new(ns);
boxed.write_all(&[0u8; 50]).unwrap();
let info = boxed.info();
assert_eq!(info.streams.len(), 0);
boxed.finish().unwrap();
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Stream URL resolver — parses URL strings into IOStream instances.
//!
//! Format: `scheme://path`
//!
//! | Scheme | Input | Output | Path |
//! |--------|-------|--------|------|
//! | disc:// | Yes | -- | empty (auto-detect) or /dev/sgN |
//! | m2ts:// | Yes | Yes | file path (required) |
//! | mkv:// | Yes | Yes | file path (required) |
//! | network:// | Yes (listen) | Yes (connect) | host:port (required) |
//! | stdio:// | Yes (stdin) | Yes (stdout) | empty |
//! | iso:// | Yes | -- | file path (required) |
//! | null:// | -- | Yes | empty |
//!
//! Bare paths without a scheme are rejected.
use std::io::{self, BufReader, BufWriter};
use std::path::Path;
use super::{IOStream, M2tsStream, MkvStream};
use super::network::NetworkStream;
use super::null::NullStream;
use super::stdio::StdioStream;
use super::iso::IsoStream;
use super::disc::{DiscStream, DiscOptions};
use crate::disc::DiscTitle;
/// I/O buffer size for file streams.
const IO_BUF_SIZE: usize = 4 * 1024 * 1024;
/// MKV lookahead buffer size.
const MKV_LOOKAHEAD: usize = 10 * 1024 * 1024;
/// Parsed stream URL.
pub struct StreamUrl {
pub scheme: String,
pub path: String,
}
/// Parse a URL string into scheme + path.
///
/// All URLs must use the `scheme://path` format. Bare paths are not supported.
///
/// ```text
/// disc:// → scheme="disc", path=""
/// disc:///dev/sg4 → scheme="disc", path="/dev/sg4"
/// m2ts:///tmp/Dune.m2ts → scheme="m2ts", path="/tmp/Dune.m2ts"
/// mkv://Dune.mkv → scheme="mkv", path="Dune.mkv"
/// network://10.0.0.1:9000 → scheme="network", path="10.0.0.1:9000"
/// null:// → scheme="null", path=""
/// ```
pub fn parse_url(url: &str) -> StreamUrl {
if let Some(rest) = url.strip_prefix("disc://") {
return StreamUrl { scheme: "disc".into(), path: rest.to_string() };
}
if let Some(rest) = url.strip_prefix("m2ts://") {
return StreamUrl { scheme: "m2ts".into(), path: rest.to_string() };
}
if let Some(rest) = url.strip_prefix("mkv://") {
return StreamUrl { scheme: "mkv".into(), path: rest.to_string() };
}
if let Some(rest) = url.strip_prefix("network://") {
return StreamUrl { scheme: "network".into(), path: rest.to_string() };
}
if url == "null://" || url.starts_with("null://") {
return StreamUrl { scheme: "null".into(), path: String::new() };
}
if url == "stdio://" || url.starts_with("stdio://") {
return StreamUrl { scheme: "stdio".into(), path: String::new() };
}
if let Some(rest) = url.strip_prefix("iso://") {
return StreamUrl { scheme: "iso".into(), path: rest.to_string() };
}
StreamUrl { scheme: "unknown".into(), path: url.to_string() }
}
/// Validate that a file path is non-empty and has a filename component.
fn validate_file_path(path: &str, scheme: &str) -> io::Result<()> {
if path.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("{}:// requires a file path (e.g. {}://movie.{})", scheme, scheme, scheme)));
}
let p = Path::new(path);
if p.file_name().is_none() {
return Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("{}://{} is not a valid file path — must include a filename", scheme, path)));
}
Ok(())
}
/// Validate that a network address has host:port format.
fn validate_network_addr(addr: &str) -> io::Result<()> {
if addr.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput,
"network:// requires host:port (e.g. network://0.0.0.0:9000)"));
}
if !addr.contains(':') {
return Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("network://{} missing port — use network://{}:PORT", addr, addr)));
}
Ok(())
}
/// Open a stream URL for reading (source).
pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream>> {
let parsed = parse_url(url);
match parsed.scheme.as_str() {
"disc" => {
let disc_opts = DiscOptions {
device: if parsed.path.is_empty() { None } else { Some(parsed.path) },
keydb_path: opts.keydb_path.clone(),
title_index: opts.title_index,
};
let stream = DiscStream::open(disc_opts)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
Ok(Box::new(stream))
}
"m2ts" => {
validate_file_path(&parsed.path, "m2ts")?;
let file = std::fs::File::open(&parsed.path)
.map_err(|e| io::Error::new(e.kind(),
format!("m2ts://{}: {}", parsed.path, e)))?;
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::open(reader)?))
}
"mkv" => {
validate_file_path(&parsed.path, "mkv")?;
let file = std::fs::File::open(&parsed.path)
.map_err(|e| io::Error::new(e.kind(),
format!("mkv://{}: {}", parsed.path, e)))?;
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::open(reader)?))
}
"network" => {
validate_network_addr(&parsed.path)?;
Ok(Box::new(NetworkStream::listen(&parsed.path)?))
}
"stdio" => {
Ok(Box::new(StdioStream::input()))
}
"iso" => {
validate_file_path(&parsed.path, "iso")?;
let scan_opts = match &opts.keydb_path {
Some(p) => crate::disc::ScanOptions::with_keydb(p),
None => crate::disc::ScanOptions::default(),
};
Ok(Box::new(IsoStream::open(&parsed.path, opts.title_index, &scan_opts)?))
}
"null" => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
"null:// is write-only — cannot use as input"))
}
"unknown" => {
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)", parsed.path)))
}
_ => Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("unknown scheme: {}://", parsed.scheme))),
}
}
/// Open a stream URL for writing (destination).
pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>> {
let parsed = parse_url(url);
match parsed.scheme.as_str() {
"disc" => {
Err(io::Error::new(io::ErrorKind::Unsupported,
"disc:// is read-only — cannot use as output"))
}
"iso" => {
validate_file_path(&parsed.path, "iso")?;
Ok(Box::new(IsoStream::create(&parsed.path)?.meta(meta)))
}
"null" => {
Ok(Box::new(NullStream::new().meta(meta)))
}
"stdio" => {
Ok(Box::new(StdioStream::output().meta(meta)))
}
"m2ts" => {
validate_file_path(&parsed.path, "m2ts")?;
let file = std::fs::File::create(&parsed.path)
.map_err(|e| io::Error::new(e.kind(),
format!("m2ts://{}: {}", parsed.path, e)))?;
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::new(writer).meta(meta)))
}
"mkv" => {
validate_file_path(&parsed.path, "mkv")?;
let file = std::fs::File::create(&parsed.path)
.map_err(|e| io::Error::new(e.kind(),
format!("mkv://{}: {}", parsed.path, e)))?;
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(MKV_LOOKAHEAD)))
}
"network" => {
validate_network_addr(&parsed.path)?;
Ok(Box::new(NetworkStream::connect(&parsed.path)?.meta(meta)))
}
"unknown" => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", parsed.path)))
}
_ => Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("unknown scheme: {}://", parsed.scheme))),
}
}
/// Options for opening an input stream.
pub struct InputOptions {
pub keydb_path: Option<String>,
pub title_index: Option<usize>,
}
impl Default for InputOptions {
fn default() -> Self {
Self { keydb_path: None, title_index: None }
}
}
+100
View File
@@ -0,0 +1,100 @@
//! StdioStream — raw byte pipe via stdin/stdout. Format-agnostic.
use std::io::{self, Read, Write};
use super::IOStream;
use crate::disc::DiscTitle;
/// Stdio stream — reads from stdin, writes to stdout.
///
/// No headers, no metadata, no format opinions. Just bytes.
/// The format is determined by whatever is on the other end.
pub struct StdioStream {
disc_title: DiscTitle,
reader: Option<io::Stdin>,
writer: Option<io::Stdout>,
}
impl StdioStream {
/// Create a stdio stream for reading (stdin).
pub fn input() -> Self {
Self {
disc_title: DiscTitle::empty(),
reader: Some(io::stdin()),
writer: None,
}
}
/// Create a stdio stream for writing (stdout).
pub fn output() -> Self {
Self {
disc_title: DiscTitle::empty(),
reader: None,
writer: Some(io::stdout()),
}
}
/// Set metadata (for output — passed through from input side).
pub fn meta(mut self, dt: &DiscTitle) -> Self {
self.disc_title = dt.clone();
self
}
}
impl IOStream for StdioStream {
fn info(&self) -> &DiscTitle { &self.disc_title }
fn finish(&mut self) -> io::Result<()> {
if let Some(ref mut w) = self.writer {
w.flush()?;
}
Ok(())
}
}
impl Read for StdioStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self.reader {
Some(ref mut r) => r.read(buf),
None => Err(io::Error::new(io::ErrorKind::Unsupported,
"stdio:// opened for output — cannot read")),
}
}
}
impl Write for StdioStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.writer {
Some(ref mut w) => w.write(buf),
None => Err(io::Error::new(io::ErrorKind::Unsupported,
"stdio:// opened for input — cannot write")),
}
}
fn flush(&mut self) -> io::Result<()> {
match self.writer {
Some(ref mut w) => w.flush(),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn stdio_output_write_errors_on_read() {
let mut stream = StdioStream::output();
let mut buf = [0u8; 10];
let err = stream.read(&mut buf).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
assert!(err.to_string().contains("cannot read"), "got: {}", err);
}
#[test]
fn stdio_input_read_errors_on_write() {
let mut stream = StdioStream::input();
let err = stream.write(&[0u8; 10]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
assert!(err.to_string().contains("cannot write"), "got: {}", err);
}
}
-218
View File
@@ -1,218 +0,0 @@
//! MkvStream — a Write adapter that demuxes BD-TS and writes MKV.
//!
//! ```rust,ignore
//! let output = MkvStream::new(file)
//! .title(&disc.titles[0])
//! .max_buffer(20 * 1024 * 1024);
//!
//! disc.rip(0, output)?;
//! ```
use std::io::{self, Write, Seek};
use super::ts::{TsDemuxer, PesPacket};
use super::mkv::{MkvMuxer, MkvTrack};
use super::codec::{self, CodecParser};
use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE};
use crate::disc::{Stream, Title};
/// Phase of the MkvStream.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Phase {
/// Collecting data in lookahead buffer, scanning for codec setup.
Scanning,
/// Header written, streaming directly to muxer.
Streaming,
}
/// MKV output stream. Implements `Write`.
pub struct MkvStream<W: Write + Seek> {
demuxer: TsDemuxer,
muxer: Option<MkvMuxer<W>>,
writer: Option<W>,
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
tracks: Vec<MkvTrack>,
title_name: String,
duration_secs: f64,
lookahead: LookaheadBuffer,
phase: Phase,
video_tracks_pending: usize,
}
impl<W: Write + Seek> MkvStream<W> {
/// Create a new MkvStream wrapping an output writer.
pub fn new(writer: W) -> Self {
Self {
demuxer: TsDemuxer::new(&[]),
muxer: None,
writer: Some(writer),
parsers: Vec::new(),
pid_to_track: Vec::new(),
tracks: Vec::new(),
title_name: String::new(),
duration_secs: 0.0,
lookahead: LookaheadBuffer::new(DEFAULT_LOOKAHEAD_SIZE),
phase: Phase::Scanning,
video_tracks_pending: 0,
}
}
/// Set the title metadata (streams, duration, name). Returns self.
pub fn title(mut self, title: &Title) -> Self {
let mut pids = Vec::new();
for stream in &title.streams {
let (pid, track, parser) = match stream {
Stream::Video(v) => {
self.video_tracks_pending += 1;
(v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec))
}
Stream::Audio(a) => (a.pid, MkvTrack::audio(a), codec::parser_for_codec(a.codec)),
Stream::Subtitle(s) => (s.pid, MkvTrack::subtitle(s), codec::parser_for_codec(s.codec)),
};
let track_idx = self.tracks.len();
pids.push(pid);
self.pid_to_track.push((pid, track_idx));
self.parsers.push((pid, parser));
self.tracks.push(track);
}
self.demuxer = TsDemuxer::new(&pids);
self.title_name = title.playlist.clone();
self.duration_secs = title.duration_secs;
self
}
/// Set the lookahead buffer size in bytes. Default 5 MB. Returns self.
pub fn max_buffer(mut self, size: usize) -> Self {
self.lookahead = LookaheadBuffer::new(size);
self
}
/// Finalize the MKV file — close cluster, write cues.
pub fn finish(mut self) -> io::Result<()> {
if let Some(ref mut muxer) = self.muxer {
let remaining = self.demuxer.flush();
for pes in &remaining {
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
}
}
if let Some(muxer) = self.muxer {
muxer.finish()?;
}
Ok(())
}
fn check_codec_private(&mut self) -> bool {
if self.video_tracks_pending == 0 {
return true;
}
for (pid, parser) in &self.parsers {
if let Some(cp) = parser.codec_private() {
if let Some((_, track_idx)) = self.pid_to_track.iter().find(|(p, _)| p == pid) {
if self.tracks[*track_idx].codec_private.is_none() {
self.tracks[*track_idx].codec_private = Some(cp);
self.video_tracks_pending -= 1;
}
}
}
}
self.video_tracks_pending == 0
}
fn start_streaming(&mut self) -> io::Result<()> {
let writer = self.writer.take().ok_or_else(|| {
io::Error::new(io::ErrorKind::Other, "writer already consumed")
})?;
let muxer = MkvMuxer::new(writer, &self.tracks, Some(&self.title_name), self.duration_secs)?;
self.muxer = Some(muxer);
self.phase = Phase::Streaming;
// Re-parse and write buffered data
let buffered = self.lookahead.drain();
if !buffered.is_empty() {
let pids: Vec<u16> = self.pid_to_track.iter().map(|(pid, _)| *pid).collect();
let mut temp_demuxer = TsDemuxer::new(&pids);
let mut packets = temp_demuxer.feed(&buffered);
packets.extend(temp_demuxer.flush());
if let Some(ref mut muxer) = self.muxer {
for pes in &packets {
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
}
}
}
Ok(())
}
fn process_one_pes(
pid_to_track: &[(u16, usize)],
parsers: &mut [(u16, Box<dyn CodecParser>)],
muxer: &mut MkvMuxer<W>,
pes: &PesPacket,
) -> io::Result<()> {
let track_idx = match pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
Some((_, idx)) => *idx,
None => return Ok(()),
};
let parser = match parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
Some((_, p)) => p,
None => return Ok(()),
};
let frames = parser.parse(pes);
for frame in frames {
muxer.write_frame(track_idx, frame.pts_ns, frame.keyframe, &frame.data)?;
}
Ok(())
}
}
impl<W: Write + Seek> Write for MkvStream<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.phase {
Phase::Scanning => {
// Parse for codec info
let packets = self.demuxer.feed(buf);
for pes in &packets {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pes.pid) {
let _ = parser.parse(pes);
}
}
// Try to buffer
let state = self.lookahead.push(buf);
// Check if we have everything
if self.check_codec_private() {
self.lookahead.mark_ready();
self.start_streaming()?;
return Ok(buf.len());
}
match state {
LookaheadState::Collecting => Ok(buf.len()),
LookaheadState::Overflow => Err(io::Error::new(
io::ErrorKind::OutOfMemory,
"MKV lookahead buffer overflow — no codec data found within buffer limit",
)),
LookaheadState::Ready => Ok(buf.len()),
}
}
Phase::Streaming => {
let packets = self.demuxer.feed(buf);
if let Some(ref mut muxer) = self.muxer {
for pes in &packets {
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
}
}
Ok(buf.len())
}
}
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
+262 -5
View File
@@ -15,6 +15,12 @@ const TS_PACKET_SIZE: usize = 188;
/// TS sync byte.
const SYNC_BYTE: u8 = 0x47;
/// Size of head buffer for PTS/stream scanning (1 MB).
const SCAN_HEAD_SIZE: usize = 1024 * 1024;
/// Size of tail buffer for last-PTS scanning (2 MB).
const SCAN_TAIL_SIZE: usize = 2 * 1024 * 1024;
/// A reassembled PES packet with timestamp info.
#[derive(Debug)]
pub struct PesPacket {
@@ -94,6 +100,7 @@ impl PesAssembler {
pub struct TsDemuxer {
assemblers: Vec<PesAssembler>,
pid_index: [i16; 8192], // PID → index into assemblers, -1 = not tracked
remainder: Vec<u8>, // leftover bytes from previous feed() call
}
impl TsDemuxer {
@@ -105,17 +112,31 @@ impl TsDemuxer {
pid_index[pid as usize] = i as i16;
assemblers.push(PesAssembler::new(pid));
}
Self { assemblers, pid_index }
Self { assemblers, pid_index, remainder: Vec::new() }
}
/// Feed a chunk of BD transport stream data (must be aligned to 192-byte packets).
/// Returns completed PES packets.
/// Feed a chunk of BD transport stream data. Handles non-192-byte-aligned input
/// by buffering leftover bytes between calls. Returns completed PES packets.
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
let mut completed = Vec::new();
// Prepend any remainder from previous call
let work: &[u8];
let mut combined: Vec<u8> = Vec::new();
if !self.remainder.is_empty() {
combined.reserve(self.remainder.len() + data.len());
combined.extend_from_slice(&self.remainder);
combined.extend_from_slice(data);
self.remainder.clear();
work = &combined;
} else {
work = data;
}
let mut offset = 0;
while offset + BD_TS_PACKET_SIZE <= data.len() {
let packet = &data[offset..offset + BD_TS_PACKET_SIZE];
while offset + BD_TS_PACKET_SIZE <= work.len() {
let packet = &work[offset..offset + BD_TS_PACKET_SIZE];
offset += BD_TS_PACKET_SIZE;
// Skip 4-byte TP_extra_header, check sync byte
@@ -172,6 +193,11 @@ impl TsDemuxer {
}
}
// Save leftover bytes for next call
if offset < work.len() {
self.remainder.extend_from_slice(&work[offset..]);
}
completed
}
@@ -242,6 +268,237 @@ fn parse_timestamp(data: &[u8]) -> i64 {
| b4 >> 1
}
// ============================================================
// Stream scanning (PAT/PMT → stream list)
// ============================================================
/// Scan BD-TS data for streams by parsing PAT and PMT tables.
/// Returns None if no valid program is found.
pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
use crate::disc::*;
// Pass 1: find PMT PID from PAT
let mut pat_pmt_pid: Option<u16> = None;
let mut offset = 0;
while offset + BD_TS_PACKET_SIZE <= data.len() {
if data[offset + 4] != SYNC_BYTE { offset += 1; continue; }
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
let pusi = data[offset + 5] & 0x40 != 0;
if pid == 0 && pusi {
let payload_start = offset + 4 + 4;
if payload_start + 12 < data.len() {
let pointer = data[payload_start] as usize;
let pat_start = payload_start + 1 + pointer;
if pat_start + 12 < data.len() && data[pat_start] == 0x00 {
let section_len = (((data[pat_start + 1] & 0x0F) as usize) << 8) | data[pat_start + 2] as usize;
let entries_start = pat_start + 8;
let entries_end = pat_start + 3 + section_len - 4;
let mut e = entries_start;
while e + 4 <= data.len() && e < entries_end {
let prog_num = ((data[e] as u16) << 8) | data[e + 1] as u16;
let p = (((data[e + 2] & 0x1F) as u16) << 8) | data[e + 3] as u16;
if prog_num != 0 {
pat_pmt_pid = Some(p);
break;
}
e += 4;
}
}
}
}
offset += BD_TS_PACKET_SIZE;
}
let pmt_pid = pat_pmt_pid?;
// Pass 2: parse PMT for stream entries
let mut streams = Vec::new();
offset = 0;
while offset + BD_TS_PACKET_SIZE <= data.len() {
if data[offset + 4] != SYNC_BYTE { offset += 1; continue; }
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
let pusi = data[offset + 5] & 0x40 != 0;
if pid == pmt_pid && pusi {
let payload_start = offset + 4 + 4;
if payload_start + 1 >= data.len() { offset += BD_TS_PACKET_SIZE; continue; }
let pointer = data[payload_start] as usize;
let pmt_start = payload_start + 1 + pointer;
if pmt_start + 12 >= data.len() { offset += BD_TS_PACKET_SIZE; continue; }
if data[pmt_start] != 0x02 { offset += BD_TS_PACKET_SIZE; continue; }
let section_len = (((data[pmt_start + 1] & 0x0F) as usize) << 8) | data[pmt_start + 2] as usize;
let prog_info_len = (((data[pmt_start + 10] & 0x0F) as usize) << 8) | data[pmt_start + 11] as usize;
let mut pos = pmt_start + 12 + prog_info_len;
let end = pmt_start + 3 + section_len - 4;
while pos + 5 <= data.len() && pos < end {
let stream_type = data[pos];
let es_pid = (((data[pos + 1] & 0x1F) as u16) << 8) | data[pos + 2] as u16;
let es_info_len = (((data[pos + 3] & 0x0F) as usize) << 8) | data[pos + 4] as usize;
let stream = match stream_type {
0x1B => Some(Stream::Video(VideoStream {
pid: es_pid, codec: Codec::H264,
resolution: "1080p".into(), frame_rate: String::new(),
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
secondary: false, label: String::new(),
})),
0x24 => Some(Stream::Video(VideoStream {
pid: es_pid, codec: Codec::Hevc,
resolution: "2160p".into(), frame_rate: String::new(),
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
secondary: false, label: String::new(),
})),
0xEA => Some(Stream::Video(VideoStream {
pid: es_pid, codec: Codec::Vc1,
resolution: "1080p".into(), frame_rate: String::new(),
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
secondary: false, label: String::new(),
})),
0x02 => Some(Stream::Video(VideoStream {
pid: es_pid, codec: Codec::Mpeg2,
resolution: "1080i".into(), frame_rate: String::new(),
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
secondary: false, label: String::new(),
})),
0x81 => Some(Stream::Audio(AudioStream {
pid: es_pid, codec: Codec::Ac3,
channels: "5.1".into(), language: "und".into(),
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
})),
0x83 => Some(Stream::Audio(AudioStream {
pid: es_pid, codec: Codec::TrueHd,
channels: "5.1".into(), language: "und".into(),
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
})),
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
pid: es_pid, codec: Codec::Ac3Plus,
channels: "5.1".into(), language: "und".into(),
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
})),
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
pid: es_pid, codec: Codec::DtsHdMa,
channels: "5.1".into(), language: "und".into(),
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
})),
0x82 => Some(Stream::Audio(AudioStream {
pid: es_pid, codec: Codec::Dts,
channels: "5.1".into(), language: "und".into(),
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
})),
0x90 => Some(Stream::Subtitle(SubtitleStream {
pid: es_pid, codec: Codec::Pgs,
language: "und".into(), forced: false,
})),
_ => None,
};
if let Some(s) = stream {
streams.push(s);
}
pos += 5 + es_info_len;
}
break;
}
offset += BD_TS_PACKET_SIZE;
}
if streams.is_empty() { None } else { Some(streams) }
}
// ============================================================
// PTS scanning utilities (for duration detection)
// ============================================================
/// Find the first PTS for a given PID in BD-TS data.
pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option<i64> {
let mut offset = 0;
while offset + BD_TS_PACKET_SIZE <= data.len() {
if data[offset + 4] != SYNC_BYTE { offset += 1; continue; }
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
let pusi = data[offset + 5] & 0x40 != 0;
if pid == target_pid && pusi {
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
let afc = (ts[3] >> 4) & 0x03;
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
if payload_start < TS_PACKET_SIZE {
let payload = &ts[payload_start..];
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]));
}
}
}
}
offset += BD_TS_PACKET_SIZE;
}
None
}
/// Find the last PTS for a given PID in BD-TS data.
pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option<i64> {
let mut last_pts = None;
let mut offset = 0;
while offset + BD_TS_PACKET_SIZE <= data.len() {
if data[offset + 4] != SYNC_BYTE { offset += 1; continue; }
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
let pusi = data[offset + 5] & 0x40 != 0;
if pid == target_pid && pusi {
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
let afc = (ts[3] >> 4) & 0x03;
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
if payload_start < TS_PACKET_SIZE {
let payload = &ts[payload_start..];
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]));
}
}
}
}
offset += BD_TS_PACKET_SIZE;
}
last_pts
}
/// Scan an m2ts file for duration by reading first and last PTS.
/// Returns duration in seconds, or None if PTS cannot be found.
/// The reader position is restored after scanning.
pub fn scan_duration<R: std::io::Read + std::io::Seek>(r: &mut R, video_pid: u16) -> Option<f64> {
use std::io::SeekFrom;
let start_pos = r.stream_position().ok()?;
// Read first 1MB for first PTS
let mut head_buf = vec![0u8; SCAN_HEAD_SIZE];
r.seek(SeekFrom::Start(0)).ok()?;
let head_n = r.read(&mut head_buf).ok()?;
let first_pts = scan_first_pts(&head_buf[..head_n], video_pid)?;
// Read last 2MB for last PTS (aligned to 192-byte boundary)
let file_size = r.seek(SeekFrom::End(0)).ok()?;
let tail_size: u64 = SCAN_TAIL_SIZE as u64;
let raw_pos = if file_size > tail_size { file_size - tail_size } else { 0 };
let seek_pos = (raw_pos / BD_TS_PACKET_SIZE as u64) * BD_TS_PACKET_SIZE as u64;
r.seek(SeekFrom::Start(seek_pos)).ok()?;
let mut tail_buf = vec![0u8; tail_size as usize];
let tail_n = r.read(&mut tail_buf).ok()?;
let last_pts = scan_last_pts(&tail_buf[..tail_n], video_pid)?;
// Restore reader position
let _ = r.seek(SeekFrom::Start(start_pos));
if last_pts > first_pts {
Some((last_pts - first_pts) as f64 / 90000.0)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
+4 -2
View File
@@ -3,12 +3,14 @@
//! Platform backends are in separate files:
//! - `linux.rs` — SG_IO ioctl
//! - `macos.rs` — IOKit SCSITaskDeviceInterface
//! - `windows.rs` — SPTI (planned)
//! - `windows.rs` — SPTI (SCSI Pass-Through Interface)
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
#[allow(unused_imports)]
use crate::error::{Error, Result};
@@ -71,7 +73,7 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
{ Ok(Box::new(macos::MacScsiTransport::open(device)?)) }
#[cfg(target_os = "windows")]
{ Err(Error::DeviceNotFound { path: format!("{}: Windows not yet supported", device.display()) }) }
{ Ok(Box::new(windows::SptiTransport::open(device)?)) }
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{ Err(Error::DeviceNotFound { path: format!("{}: unsupported platform", device.display()) }) }
+209
View File
@@ -0,0 +1,209 @@
//! Windows SCSI transport via SPTI (SCSI Pass-Through Interface).
//!
//! Sends SCSI commands through DeviceIoControl with IOCTL_SCSI_PASS_THROUGH_DIRECT.
//! Accepts device paths like `D:`, `E:`, `\\.\CdRom0`, or `\\.\D:`.
//!
//! Requires administrator privileges for raw SCSI access.
use crate::error::{Error, Result};
use super::{DataDirection, ScsiResult, ScsiTransport};
use std::path::Path;
// ── Windows constants ──────────────────────────────────────────────────────
const IOCTL_SCSI_PASS_THROUGH_DIRECT: u32 = 0x4D014;
const SCSI_IOCTL_DATA_OUT: u8 = 0;
const SCSI_IOCTL_DATA_IN: u8 = 1;
const SCSI_IOCTL_DATA_UNSPECIFIED: u8 = 2;
const GENERIC_READ: u32 = 0x80000000;
const GENERIC_WRITE: u32 = 0x40000000;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_WRITE: u32 = 0x00000002;
const OPEN_EXISTING: u32 = 3;
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
const INVALID_HANDLE_VALUE: isize = -1;
const K_MAX_CDB_SIZE: usize = 16;
const K_SENSE_SIZE: usize = 32;
// ── SCSI_PASS_THROUGH_DIRECT structure ─────────────────────────────────────
#[repr(C)]
#[allow(non_snake_case)]
struct ScsiPassThroughDirect {
Length: u16,
ScsiStatus: u8,
PathId: u8,
TargetId: u8,
Lun: u8,
CdbLength: u8,
SenseInfoLength: u8,
DataIn: u8,
_padding1: [u8; 3],
DataTransferLength: u32,
TimeOutValue: u32,
DataBuffer: *mut u8,
SenseInfoOffset: u32,
Cdb: [u8; K_MAX_CDB_SIZE],
}
#[repr(C)]
struct SptwbDirect {
spt: ScsiPassThroughDirect,
sense: [u8; K_SENSE_SIZE],
}
// ── Windows FFI ────────────────────────────────────────────────────────────
extern "system" {
fn CreateFileW(
lpFileName: *const u16,
dwDesiredAccess: u32,
dwShareMode: u32,
lpSecurityAttributes: *const std::ffi::c_void,
dwCreationDisposition: u32,
dwFlagsAndAttributes: u32,
hTemplateFile: *const std::ffi::c_void,
) -> isize;
fn CloseHandle(hObject: isize) -> i32;
fn DeviceIoControl(
hDevice: isize,
dwIoControlCode: u32,
lpInBuffer: *mut std::ffi::c_void,
nInBufferSize: u32,
lpOutBuffer: *mut std::ffi::c_void,
nOutBufferSize: u32,
lpBytesReturned: *mut u32,
lpOverlapped: *mut std::ffi::c_void,
) -> i32;
}
// ── Transport implementation ───────────────────────────────────────────────
pub struct SptiTransport {
handle: isize,
}
/// Normalize a device path to Windows \\.\X: format.
fn normalize_device_path(path: &str) -> String {
if path.starts_with("\\\\.\\") { return path.to_string(); }
let trimmed = path.trim_end_matches('\\');
if trimmed.len() == 2 && trimmed.as_bytes()[1] == b':' {
return format!("\\\\.\\{}", trimmed);
}
if path.to_lowercase().starts_with("cdrom") {
return format!("\\\\.\\{}", path);
}
format!("\\\\.\\{}", path)
}
impl SptiTransport {
pub fn open(device: &Path) -> Result<Self> {
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
// Normalize device path to \\.\X: format
let win_path = normalize_device_path(dev_str);
let wide: Vec<u16> = win_path.encode_utf16().chain(std::iter::once(0)).collect();
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 Err(Error::DeviceNotFound {
path: format!("{}: cannot open device (run as administrator)", dev_str),
});
}
Ok(SptiTransport { handle })
}
}
impl Drop for SptiTransport {
fn drop(&mut self) {
unsafe { CloseHandle(self.handle); }
}
}
impl ScsiTransport for SptiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
let mut sptwb: SptwbDirect = unsafe { std::mem::zeroed() };
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);
sptwb.spt.Length = std::mem::size_of::<ScsiPassThroughDirect>() as u16;
sptwb.spt.CdbLength = cdb_len as u8;
sptwb.spt.SenseInfoLength = K_SENSE_SIZE as u8;
sptwb.spt.DataIn = match direction {
DataDirection::None => SCSI_IOCTL_DATA_UNSPECIFIED,
DataDirection::FromDevice => SCSI_IOCTL_DATA_IN,
DataDirection::ToDevice => SCSI_IOCTL_DATA_OUT,
};
sptwb.spt.DataTransferLength = data.len() as u32;
sptwb.spt.TimeOutValue = (timeout_ms / 1000).max(1) as u32;
sptwb.spt.DataBuffer = if data.is_empty() { std::ptr::null_mut() } else { data.as_mut_ptr() };
sptwb.spt.SenseInfoOffset = std::mem::offset_of!(SptwbDirect, sense) as u32;
sptwb.spt.Cdb[..cdb_len].copy_from_slice(&cdb[..cdb_len]);
let buf_size = std::mem::size_of::<SptwbDirect>() as u32;
let mut bytes_returned: u32 = 0;
let ok = unsafe {
DeviceIoControl(
self.handle,
IOCTL_SCSI_PASS_THROUGH_DIRECT,
&mut sptwb as *mut _ as *mut std::ffi::c_void,
buf_size,
&mut sptwb as *mut _ as *mut std::ffi::c_void,
buf_size,
&mut bytes_returned,
std::ptr::null_mut(),
)
};
if ok == 0 {
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
});
}
if sptwb.spt.ScsiStatus != 0 {
let sense_key = if sptwb.sense[2] != 0 { sptwb.sense[2] & 0x0F } else { 0 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: sptwb.spt.ScsiStatus,
sense_key,
});
}
let mut sense = [0u8; 32];
sense.copy_from_slice(&sptwb.sense);
Ok(ScsiResult {
status: sptwb.spt.ScsiStatus,
bytes_transferred: sptwb.spt.DataTransferLength as usize,
sense,
})
}
}
+14
View File
@@ -0,0 +1,14 @@
//! SectorReader — trait for reading 2048-byte disc sectors.
//!
//! Implemented by DriveSession (SCSI) and IsoFile (file-backed).
//! Used by UDF parser, disc scanner, label parsers — anything that
//! reads sectors doesn't need to know where they come from.
use crate::error::Result;
/// Read 2048-byte sectors from a disc or disc image.
pub trait SectorReader {
/// 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>;
}
+33 -33
View File
@@ -19,7 +19,7 @@
//! BD-ROM Part 3 — Blu-ray filesystem profile
use crate::error::{Error, Result};
use crate::drive::DriveSession;
use crate::sector::SectorReader;
/// A UDF filesystem parsed from disc.
#[derive(Debug)]
@@ -74,7 +74,7 @@ impl UdfFs {
/// Reads sector by sector from disc — no buffering.
/// Get the absolute starting LBA of a file on disc.
/// Used by the rip pipeline to locate m2ts content sectors.
pub fn file_start_lba(&self, session: &mut DriveSession, path: &str) -> Result<u32> {
pub fn file_start_lba(&self, reader: &mut dyn SectorReader, path: &str) -> Result<u32> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root;
for part in &parts[..parts.len() - 1] {
@@ -91,11 +91,11 @@ impl UdfFs {
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
)?;
let (data_lba, _) = self.read_icb_extent(session, entry.meta_lba)?;
let (data_lba, _) = self.read_icb_extent(reader, entry.meta_lba)?;
Ok(self.partition_start + data_lba)
}
pub fn read_file(&self, session: &mut DriveSession, path: &str) -> Result<Vec<u8>> {
pub fn read_file(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<u8>> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root;
@@ -118,7 +118,7 @@ impl UdfFs {
)?;
// Read the file's ICB to get its data extent
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
let (data_lba, data_len) = self.read_icb_extent(reader, entry.meta_lba)?;
// Read file data sector by sector
// File DATA is in the physical partition (partition_start + lba),
@@ -129,7 +129,7 @@ impl UdfFs {
for i in 0..sector_count {
let offset = (i as usize) * 2048;
read_sector(session, abs_start + i, &mut data[offset..offset + 2048])?;
read_sector(reader, abs_start + i, &mut data[offset..offset + 2048])?;
}
data.truncate(entry.size as usize);
@@ -145,7 +145,7 @@ impl UdfFs {
///
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
/// MKB_RO.inf, ContentHash*, ContentRevocation*
pub fn metadata_sector_ranges(&self, session: &mut DriveSession) -> Result<Vec<(u32, u32)>> {
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure: sector 0 through end of metadata partition
@@ -154,7 +154,7 @@ impl UdfFs {
ranges.push((0, meta_end));
// Walk tree, collect ranges for each metadata file
self.collect_file_ranges(session, &self.root, &mut ranges)?;
self.collect_file_ranges(reader, &self.root, &mut ranges)?;
// Merge overlapping/adjacent ranges and sort
ranges.sort_by_key(|r| r.0);
@@ -162,14 +162,14 @@ impl UdfFs {
Ok(merged)
}
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
fn collect_file_ranges(&self, reader: &mut dyn SectorReader, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
for child in &entry.entries {
if child.is_dir {
// Only skip STREAM — those are the multi-GB video files
if child.name.eq_ignore_ascii_case("STREAM") {
continue;
}
self.collect_file_ranges(session, child, ranges)?;
self.collect_file_ranges(reader, child, ranges)?;
} else {
// Include the ICB sector itself (in metadata partition)
ranges.push((self.meta_to_abs(child.meta_lba), 1));
@@ -179,7 +179,7 @@ impl UdfFs {
continue;
}
if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
if let Ok((data_lba, data_len)) = self.read_icb_extent(reader, child.meta_lba) {
let abs_start = self.partition_start + data_lba;
let sector_count = (data_len + 2047) / 2048;
ranges.push((abs_start, sector_count));
@@ -197,17 +197,17 @@ impl UdfFs {
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
/// and return its first allocation extent: (data_lba, data_length).
/// The data_lba is partition-relative.
fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> {
let extents = self.read_icb_extents(session, meta_lba)?;
fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> {
let extents = self.read_icb_extents(reader, meta_lba)?;
extents.first().copied().ok_or_else(|| Error::DiscRead { sector: 0 })
}
/// Read ALL allocation extents for a file from its ICB.
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents).
fn read_icb_extents(&self, session: &mut DriveSession, meta_lba: u32) -> Result<Vec<(u32, u32)>> {
fn read_icb_extents(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<Vec<(u32, u32)>> {
let mut icb = [0u8; 2048];
read_sector(session, self.meta_to_abs(meta_lba), &mut icb)?;
read_sector(reader, self.meta_to_abs(meta_lba), &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
@@ -255,7 +255,7 @@ impl UdfFs {
/// Get all absolute disc sector extents for a file.
/// Returns Vec of (absolute_lba, sector_count) covering the entire file.
pub fn file_extents(&self, session: &mut DriveSession, path: &str) -> Result<Vec<(u32, u32)>> {
pub fn file_extents(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<(u32, u32)>> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root;
for part in &parts[..parts.len() - 1] {
@@ -273,7 +273,7 @@ impl UdfFs {
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
)?;
let alloc_extents = self.read_icb_extents(session, entry.meta_lba)?;
let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?;
let mut disc_extents = Vec::new();
for (lba, byte_len) in alloc_extents {
let abs_lba = self.partition_start + lba;
@@ -293,11 +293,11 @@ impl UdfFs {
/// 3. Metadata partition file → metadata content location
/// 4. FSD → root directory ICB
/// 5. Root directory → file tree
pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
// Step 1: Anchor Volume Descriptor Pointer at sector 256
// ECMA-167 §10.2 — always at sector 256
let mut avdp = [0u8; 2048];
read_sector(session, 256, &mut avdp)?;
read_sector(reader, 256, &mut avdp)?;
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
if tag_id != 2 {
@@ -317,7 +317,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
for i in 32..64 {
let mut desc = [0u8; 2048];
read_sector(session, i, &mut desc)?;
read_sector(reader, i, &mut desc)?;
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
match desc_tag {
@@ -352,7 +352,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
// Read LVD to check partition map type
let mut lvd = [0u8; 2048];
read_sector(session, lvd_sec, &mut lvd)?;
read_sector(reader, lvd_sec, &mut lvd)?;
// Parse partition maps starting at offset 440
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
@@ -368,7 +368,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
// Read it to find where the metadata content starts
let meta_file_lba = partition_start; // lba 0 of partition
let mut meta_icb = [0u8; 2048];
read_sector(session, meta_file_lba, &mut meta_icb)?;
read_sector(reader, meta_file_lba, &mut meta_icb)?;
let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]);
if meta_tag == 266 {
@@ -401,7 +401,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
// Step 4: Read File Set Descriptor from metadata partition
// FSD is at metadata-relative lba 0 (first sector of metadata content)
let mut fsd = [0u8; 2048];
read_sector(session, metadata_start, &mut fsd)?;
read_sector(reader, metadata_start, &mut fsd)?;
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
if fsd_tag != 256 {
@@ -413,7 +413,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let root_lba = u32::from_le_bytes([fsd[404], fsd[405], fsd[406], fsd[407]]);
// Step 5: Read root directory and build file tree
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
let root = read_directory(reader, partition_start, metadata_start, root_lba, "", 0)?;
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
@@ -432,7 +432,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
/// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir
/// and points to its ICB.
fn read_directory(
session: &mut DriveSession,
reader: &mut dyn SectorReader,
part_start: u32,
meta_start: u32,
meta_lba: u32,
@@ -441,7 +441,7 @@ fn read_directory(
) -> Result<DirEntry> {
// Read ICB for this directory
let mut icb = [0u8; 2048];
read_sector(session, meta_start + meta_lba, &mut icb)?;
read_sector(reader, meta_start + meta_lba, &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
@@ -477,7 +477,7 @@ fn read_directory(
let sector_count = ((ad_len + 2047) / 2048).min(64);
let mut dir_data = vec![0u8; sector_count as usize * 2048];
for i in 0..sector_count {
read_sector(session, dir_abs + i,
read_sector(reader, dir_abs + i,
&mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048])?;
}
@@ -512,11 +512,11 @@ fn read_directory(
if !entry_name.is_empty() {
// Read the ICB to get file size
let file_size = read_file_size(session, meta_start, icb_lba).unwrap_or(0);
let file_size = read_file_size(reader, meta_start, icb_lba).unwrap_or(0);
if is_dir && depth < 3 {
// Recurse into subdirectory (max 3 levels: BDMV/PLAYLIST/*.mpls)
let subdir = read_directory(session, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
let subdir = read_directory(reader, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
entries.push(subdir);
} else {
entries.push(DirEntry {
@@ -545,9 +545,9 @@ fn read_directory(
}
/// Read file size (info_length) from an Extended File Entry ICB.
fn read_file_size(session: &mut DriveSession, meta_start: u32, meta_lba: u32) -> Result<u64> {
fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result<u64> {
let mut icb = [0u8; 2048];
read_sector(session, meta_start + meta_lba, &mut icb)?;
read_sector(reader, meta_start + meta_lba, &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
match tag {
@@ -642,7 +642,7 @@ fn parse_dstring(data: &[u8]) -> String {
/// Read a single 2048-byte sector from the drive.
/// Uses standard READ(10) — no unlock required.
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {
session.read_disc(lba, 1, buf)?;
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
reader.read_sectors(lba, 1, buf)?;
Ok(())
}
+131
View File
@@ -0,0 +1,131 @@
//! Disc scanning pipeline tests.
use std::collections::HashMap;
use libfreemkv::error::Result;
use libfreemkv::sector::SectorReader;
use libfreemkv::{Disc, DiscTitle, ScanOptions};
const SECTOR_SIZE: usize = 2048;
/// Minimal mock sector reader for disc scan tests.
struct MockSectorReader {
sectors: HashMap<u32, Vec<u8>>,
}
impl MockSectorReader {
fn new() -> Self {
Self { sectors: HashMap::new() }
}
}
impl SectorReader for MockSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
for i in 0..count as u32 {
let offset = i as usize * SECTOR_SIZE;
if let Some(data) = self.sectors.get(&(lba + i)) {
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
} else {
buf[offset..offset + SECTOR_SIZE].fill(0);
}
}
Ok(total)
}
}
// ── scan_image tests ───────────────────────────────────────────────────────
#[test]
fn scan_image_empty_reader() {
// An empty reader has no AVDP at sector 256 -> UDF parse fails
let mut reader = MockSectorReader::new();
let opts = ScanOptions::default();
let result = Disc::scan_image(&mut reader, 0, &opts);
assert!(result.is_err(), "scan_image should fail with empty reader (no AVDP)");
}
// ── DiscTitle tests ────────────────────────────────────────────────────────
#[test]
fn disc_title_empty() {
let t = DiscTitle::empty();
assert_eq!(t.playlist, "");
assert_eq!(t.playlist_id, 0);
assert_eq!(t.duration_secs, 0.0);
assert_eq!(t.size_bytes, 0);
assert!(t.clips.is_empty());
assert!(t.streams.is_empty());
assert!(t.extents.is_empty());
}
#[test]
fn disc_title_duration_display() {
let mut t = DiscTitle::empty();
// 2 hours 15 minutes = 8100 seconds
t.duration_secs = 8100.0;
assert_eq!(t.duration_display(), "2h 15m");
// 0 hours 5 minutes = 300 seconds
t.duration_secs = 300.0;
assert_eq!(t.duration_display(), "0h 05m");
// Exact hour boundary
t.duration_secs = 3600.0;
assert_eq!(t.duration_display(), "1h 00m");
// Large value: 10 hours 30 minutes
t.duration_secs = 37800.0;
assert_eq!(t.duration_display(), "10h 30m");
}
#[test]
fn disc_title_size_gb() {
let mut t = DiscTitle::empty();
// Exactly 1 GiB
t.size_bytes = 1024 * 1024 * 1024;
assert!((t.size_gb() - 1.0).abs() < 0.001);
// 50 GiB (typical BD)
t.size_bytes = 50 * 1024 * 1024 * 1024;
assert!((t.size_gb() - 50.0).abs() < 0.001);
// Zero
t.size_bytes = 0;
assert_eq!(t.size_gb(), 0.0);
}
#[test]
fn disc_title_total_sectors() {
let mut t = DiscTitle::empty();
assert_eq!(t.total_sectors(), 0);
t.extents.push(libfreemkv::Extent { start_lba: 0, sector_count: 100 });
t.extents.push(libfreemkv::Extent { start_lba: 200, sector_count: 50 });
assert_eq!(t.total_sectors(), 150);
}
// ── ScanOptions tests ──────────────────────────────────────────────────────
#[test]
fn scan_options_default() {
let opts = ScanOptions::default();
assert!(opts.keydb_path.is_none());
}
#[test]
fn scan_options_with_keydb() {
let opts = ScanOptions::with_keydb("/tmp/KEYDB.cfg");
assert_eq!(
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
"/tmp/KEYDB.cfg"
);
}
#[test]
fn scan_options_with_keydb_pathbuf() {
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
let opts = ScanOptions::with_keydb(path.clone());
assert_eq!(opts.keydb_path.unwrap(), path);
}
+531
View File
@@ -0,0 +1,531 @@
//! Integration tests for the IOStream pipeline.
use std::io::{Cursor, Read, Write, Seek, SeekFrom};
use libfreemkv::*;
use libfreemkv::mux::meta::M2tsMeta;
fn sample_disc_title() -> DiscTitle {
DiscTitle {
playlist: "Test Movie".into(),
playlist_id: 0,
duration_secs: 7200.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![
Stream::Video(VideoStream {
pid: 0x1011, codec: Codec::Hevc,
resolution: "2160p".into(), frame_rate: "23.976".into(),
hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt709,
secondary: false, label: "Main".into(),
}),
Stream::Audio(AudioStream {
pid: 0x1100, codec: Codec::TrueHd,
channels: "7.1".into(), language: "eng".into(),
sample_rate: "48kHz".into(), secondary: false,
label: "English Atmos".into(),
}),
Stream::Audio(AudioStream {
pid: 0x1101, codec: Codec::Ac3,
channels: "5.1".into(), language: "fra".into(),
sample_rate: "48kHz".into(), secondary: false,
label: "French".into(),
}),
Stream::Subtitle(SubtitleStream {
pid: 0x1200, codec: Codec::Pgs,
language: "eng".into(), forced: false,
}),
],
extents: Vec::new(),
}
}
// ── URL parsing ────────────────────────────────────────────────
#[test]
fn parse_url_disc() {
let u = parse_url("disc://");
assert_eq!(u.scheme, "disc");
assert_eq!(u.path, "");
}
#[test]
fn parse_url_disc_device() {
let u = parse_url("disc:///dev/sg4");
assert_eq!(u.scheme, "disc");
assert_eq!(u.path, "/dev/sg4");
}
#[test]
fn parse_url_mkv() {
let u = parse_url("mkv://Dune.mkv");
assert_eq!(u.scheme, "mkv");
assert_eq!(u.path, "Dune.mkv");
}
#[test]
fn parse_url_network() {
let u = parse_url("network://10.0.0.1:9000");
assert_eq!(u.scheme, "network");
assert_eq!(u.path, "10.0.0.1:9000");
}
#[test]
fn parse_url_bare_path_rejected() {
let u = parse_url("Dune.mkv");
assert_eq!(u.scheme, "unknown");
}
#[test]
fn parse_url_null() {
let u = parse_url("null://");
assert_eq!(u.scheme, "null");
assert_eq!(u.path, "");
}
#[test]
fn parse_url_m2ts_with_path() {
let u = parse_url("m2ts:///tmp/Dune.m2ts");
assert_eq!(u.scheme, "m2ts");
assert_eq!(u.path, "/tmp/Dune.m2ts");
}
#[test]
fn parse_url_m2ts_relative() {
let u = parse_url("m2ts://Dune.m2ts");
assert_eq!(u.scheme, "m2ts");
assert_eq!(u.path, "Dune.m2ts");
}
#[test]
fn open_input_bare_path_errors() {
let result = libfreemkv::open_input("Dune.mkv", &libfreemkv::InputOptions::default());
assert!(result.is_err());
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
assert!(msg.contains("not a valid stream URL"), "got: {}", msg);
}
#[test]
fn open_output_bare_path_errors() {
let dt = sample_disc_title();
let result = libfreemkv::open_output("Dune.mkv", &dt);
assert!(result.is_err());
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
assert!(msg.contains("not a valid stream URL"), "got: {}", msg);
}
#[test]
fn open_input_m2ts_empty_path_errors() {
let result = libfreemkv::open_input("m2ts://", &libfreemkv::InputOptions::default());
assert!(result.is_err());
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
assert!(msg.contains("requires a file path"), "got: {}", msg);
}
#[test]
fn open_output_null_input_errors() {
let result = libfreemkv::open_input("null://", &libfreemkv::InputOptions::default());
assert!(result.is_err());
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
assert!(msg.contains("write-only"), "got: {}", msg);
}
#[test]
fn open_output_disc_errors() {
let dt = sample_disc_title();
let result = libfreemkv::open_output("disc://", &dt);
assert!(result.is_err());
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
assert!(msg.contains("read-only"), "got: {}", msg);
}
#[test]
fn open_input_network_no_port_errors() {
let result = libfreemkv::open_input("network://10.0.0.1", &libfreemkv::InputOptions::default());
assert!(result.is_err());
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
assert!(msg.contains("missing port"), "got: {}", msg);
}
#[test]
fn parse_url_stdio() {
let u = parse_url("stdio://");
assert_eq!(u.scheme, "stdio");
assert_eq!(u.path, "");
}
// ── M2TS metadata roundtrip ───────────────────────────────────
#[test]
fn m2ts_meta_roundtrip() {
let dt = sample_disc_title();
let meta = M2tsMeta::from_title(&dt);
let restored = meta.to_title();
assert_eq!(restored.playlist, dt.playlist);
assert_eq!(restored.duration_secs, dt.duration_secs);
assert_eq!(restored.streams.len(), dt.streams.len());
// Check video
if let Stream::Video(v) = &restored.streams[0] {
assert_eq!(v.codec, Codec::Hevc);
assert_eq!(v.resolution, "2160p");
assert_eq!(v.label, "Main");
} else { panic!("expected video"); }
// Check audio
if let Stream::Audio(a) = &restored.streams[1] {
assert_eq!(a.codec, Codec::TrueHd);
assert_eq!(a.language, "eng");
assert_eq!(a.label, "English Atmos");
} else { panic!("expected audio"); }
// Check subtitle
if let Stream::Subtitle(s) = &restored.streams[3] {
assert_eq!(s.language, "eng");
assert!(!s.forced);
} else { panic!("expected subtitle"); }
}
// ── M2TS header write + read ──────────────────────────────────
#[test]
fn m2ts_header_write_read() {
let dt = sample_disc_title();
let meta = M2tsMeta::from_title(&dt);
// Write header to buffer
let mut buf = Vec::new();
libfreemkv::mux::meta::write_header(&mut buf, &meta).unwrap();
// Verify magic
assert_eq!(&buf[..4], b"FMKV");
// Verify 192-byte alignment
assert_eq!(buf.len() % 192, 0);
// Read it back
let mut cursor = Cursor::new(&buf);
let read_back = libfreemkv::mux::meta::read_header(&mut cursor).unwrap().unwrap();
assert_eq!(read_back.title, "Test Movie");
assert_eq!(read_back.duration, 7200.0);
assert_eq!(read_back.streams.len(), 4);
// Cursor should be at end of header (192-aligned)
assert_eq!(cursor.position() as usize, buf.len());
}
// ── M2tsStream write + read ───────────────────────────────────
#[test]
fn m2ts_stream_write_read() {
let dt = sample_disc_title();
// Build fake BD-TS packets
let mut ts_data = Vec::new();
for i in 0..10u8 {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
pkt[5] = 0x10;
pkt[6] = 0x11;
pkt[7] = 0x10;
pkt[8] = i;
ts_data.extend_from_slice(&pkt);
}
// Write through M2tsStream to a Cursor
let output = Cursor::new(Vec::new());
let mut stream = M2tsStream::new(output).meta(&dt);
stream.write_all(&ts_data).unwrap();
stream.finish().unwrap();
// M2tsStream consumed the cursor — we need the inner data.
// For this test, write to a shared buffer instead.
// Use a second pass: write header + data manually to verify read side.
let mut encoded = Vec::new();
let meta = M2tsMeta::from_title(&dt);
libfreemkv::mux::meta::write_header(&mut encoded, &meta).unwrap();
encoded.extend_from_slice(&ts_data);
// Read back
let cursor = Cursor::new(encoded);
let mut stream = M2tsStream::open(cursor).unwrap();
let info = stream.info();
assert_eq!(info.streams.len(), 4);
assert_eq!(info.duration_secs, 7200.0);
// Read BD-TS data
let mut read_buf = vec![0u8; 192 * 10];
let mut total = 0;
loop {
match stream.read(&mut read_buf[total..]) {
Ok(0) => break,
Ok(n) => total += n,
Err(_) => break,
}
}
assert_eq!(total, 192 * 10);
for i in 0..10u8 {
assert_eq!(read_buf[i as usize * 192 + 4], 0x47);
assert_eq!(read_buf[i as usize * 192 + 8], i);
}
}
// ── M2tsStream passthrough identity ───────────────────────────
#[test]
fn m2ts_passthrough_preserves_data() {
let dt = sample_disc_title();
// Create original BD-TS data
let mut original = Vec::new();
for i in 0..100u8 {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
pkt[5] = (i % 3) << 4;
pkt[6] = i;
for j in 8..192 { pkt[j] = i.wrapping_add(j as u8); }
original.extend_from_slice(&pkt);
}
// Build encoded: header + original data
let mut encoded = Vec::new();
let meta = M2tsMeta::from_title(&dt);
libfreemkv::mux::meta::write_header(&mut encoded, &meta).unwrap();
encoded.extend_from_slice(&original);
// Read back through M2tsStream
let cursor = Cursor::new(encoded);
let mut stream = M2tsStream::open(cursor).unwrap();
let mut decoded = vec![0u8; original.len()];
let mut total = 0;
loop {
match stream.read(&mut decoded[total..]) {
Ok(0) => break,
Ok(n) => total += n,
Err(_) => break,
}
}
// BD-TS data must be byte-identical
assert_eq!(total, original.len());
assert_eq!(decoded, original);
}
// ── IOStream trait ────────────────────────────────────────────
#[test]
fn m2ts_implements_iostream() {
let dt = sample_disc_title();
let output = Cursor::new(Vec::new());
let stream = M2tsStream::new(output).meta(&dt);
let mut boxed: Box<dyn IOStream> = Box::new(stream);
let meta = boxed.info();
assert_eq!(meta.streams.len(), 4);
let pkt = [0u8; 192];
boxed.write_all(&pkt).unwrap();
boxed.finish().unwrap();
}
#[test]
fn m2ts_read_returns_error_on_write_stream() {
let output = Cursor::new(Vec::new());
let stream = M2tsStream::new(output);
let mut boxed: Box<dyn IOStream> = Box::new(stream);
let mut buf = [0u8; 10];
assert!(boxed.read(&mut buf).is_err());
}
// ── DiscTitle::empty ──────────────────────────────────────────
#[test]
fn disc_title_empty() {
let dt = DiscTitle::empty();
assert_eq!(dt.streams.len(), 0);
assert_eq!(dt.duration_secs, 0.0);
assert!(dt.playlist.is_empty());
}
// ── Meta codec roundtrip ─────────────────────────────────────
#[test]
fn meta_codec_roundtrip() {
// Test that all codec types survive from_title -> to_title
let codecs_video = &[Codec::Hevc, Codec::H264, Codec::Vc1, Codec::Mpeg2];
let codecs_audio = &[Codec::Ac3, Codec::Ac3Plus, Codec::TrueHd, Codec::DtsHdMa, Codec::DtsHdHr, Codec::Dts, Codec::Lpcm];
let codecs_sub = &[Codec::Pgs];
let mut streams = Vec::new();
for (i, &codec) in codecs_video.iter().enumerate() {
streams.push(Stream::Video(VideoStream {
pid: (0x1011 + i) as u16, codec,
resolution: "1080p".into(), frame_rate: "23.976".into(),
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
secondary: false, label: String::new(),
}));
}
for (i, &codec) in codecs_audio.iter().enumerate() {
streams.push(Stream::Audio(AudioStream {
pid: (0x1100 + i) as u16, codec,
channels: "5.1".into(), language: "eng".into(),
sample_rate: "48kHz".into(), secondary: false,
label: String::new(),
}));
}
for (i, &codec) in codecs_sub.iter().enumerate() {
streams.push(Stream::Subtitle(SubtitleStream {
pid: (0x1200 + i) as u16, codec,
language: "eng".into(), forced: false,
}));
}
let dt = DiscTitle {
playlist: "Codec Test".into(),
playlist_id: 0,
duration_secs: 100.0,
size_bytes: 0,
clips: Vec::new(),
streams,
extents: Vec::new(),
};
let meta = M2tsMeta::from_title(&dt);
let restored = meta.to_title();
assert_eq!(restored.streams.len(), dt.streams.len());
for (orig, rest) in dt.streams.iter().zip(restored.streams.iter()) {
match (orig, rest) {
(Stream::Video(o), Stream::Video(r)) => assert_eq!(o.codec, r.codec, "video codec mismatch"),
(Stream::Audio(o), Stream::Audio(r)) => assert_eq!(o.codec, r.codec, "audio codec mismatch"),
(Stream::Subtitle(o), Stream::Subtitle(r)) => assert_eq!(o.codec, r.codec, "subtitle codec mismatch"),
_ => panic!("stream type mismatch"),
}
}
}
#[test]
fn meta_empty_streams() {
let dt = DiscTitle {
playlist: "Empty".into(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
extents: Vec::new(),
};
let meta = M2tsMeta::from_title(&dt);
assert_eq!(meta.streams.len(), 0);
let restored = meta.to_title();
assert_eq!(restored.streams.len(), 0);
assert_eq!(restored.playlist, "Empty");
}
#[test]
fn meta_all_stream_types() {
let dt = DiscTitle {
playlist: "Full".into(),
playlist_id: 0,
duration_secs: 3600.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![
Stream::Video(VideoStream {
pid: 0x1011, codec: Codec::Hevc,
resolution: "2160p".into(), frame_rate: "23.976".into(),
hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt709,
secondary: false, label: "Primary".into(),
}),
Stream::Audio(AudioStream {
pid: 0x1100, codec: Codec::TrueHd,
channels: "7.1".into(), language: "eng".into(),
sample_rate: "48kHz".into(), secondary: false,
label: "Primary Audio".into(),
}),
Stream::Subtitle(SubtitleStream {
pid: 0x1200, codec: Codec::Pgs,
language: "fra".into(), forced: true,
}),
Stream::Audio(AudioStream {
pid: 0x1110, codec: Codec::Ac3,
channels: "stereo".into(), language: "eng".into(),
sample_rate: "48kHz".into(), secondary: true,
label: "Commentary".into(),
}),
],
extents: Vec::new(),
};
let meta = M2tsMeta::from_title(&dt);
let restored = meta.to_title();
assert_eq!(restored.streams.len(), 4);
// Video preserved
if let Stream::Video(v) = &restored.streams[0] {
assert_eq!(v.codec, Codec::Hevc);
assert_eq!(v.resolution, "2160p");
assert_eq!(v.label, "Primary");
assert!(!v.secondary);
} else { panic!("expected video"); }
// Primary audio preserved
if let Stream::Audio(a) = &restored.streams[1] {
assert_eq!(a.codec, Codec::TrueHd);
assert_eq!(a.channels, "7.1");
assert!(!a.secondary);
} else { panic!("expected audio"); }
// Subtitle preserved (forced flag)
if let Stream::Subtitle(s) = &restored.streams[2] {
assert_eq!(s.language, "fra");
assert!(s.forced);
} else { panic!("expected subtitle"); }
// Secondary audio preserved
if let Stream::Audio(a) = &restored.streams[3] {
assert_eq!(a.codec, Codec::Ac3);
assert!(a.secondary);
assert_eq!(a.label, "Commentary");
} else { panic!("expected secondary audio"); }
}
// ── MkvStream tests ──────────────────────────────────────────
#[test]
fn mkvstream_write_finish() {
let output = Cursor::new(Vec::new());
let dt = sample_disc_title();
let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024);
// Write some fake BD-TS packets (they won't produce valid MKV frames
// since there is no real codec data, but it should not panic)
for i in 0..20u8 {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
// PID 0x1011 (video)
pkt[5] = 0x10;
pkt[6] = 0x11;
pkt[7] = 0x10;
pkt[8] = i;
stream.write_all(&pkt).unwrap();
}
// finish should not panic even without valid codec data
stream.finish().unwrap();
}
#[test]
fn mkvstream_meta_sets_title() {
let output = Cursor::new(Vec::new());
let dt = sample_disc_title();
let stream = MkvStream::new(output).meta(&dt);
let info = stream.info();
assert_eq!(info.playlist, "Test Movie");
assert_eq!(info.duration_secs, 7200.0);
assert_eq!(info.streams.len(), 4);
}
+458
View File
@@ -0,0 +1,458 @@
//! UDF parser tests using a MockSectorReader.
use std::collections::HashMap;
use libfreemkv::error::Result;
use libfreemkv::sector::SectorReader;
use libfreemkv::udf;
const SECTOR_SIZE: usize = 2048;
/// In-memory sector reader backed by a HashMap<LBA, sector_data>.
/// Any LBA not in the map returns zeroed sectors.
struct MockSectorReader {
sectors: HashMap<u32, Vec<u8>>,
}
impl MockSectorReader {
fn new() -> Self {
Self { sectors: HashMap::new() }
}
/// Write a full 2048-byte sector at the given LBA.
fn set_sector(&mut self, lba: u32, data: Vec<u8>) {
assert_eq!(data.len(), SECTOR_SIZE, "sector data must be exactly 2048 bytes");
self.sectors.insert(lba, data);
}
/// Write partial data into a sector (rest is zeroed).
fn set_sector_partial(&mut self, lba: u32, data: &[u8]) {
let mut sector = vec![0u8; SECTOR_SIZE];
let len = data.len().min(SECTOR_SIZE);
sector[..len].copy_from_slice(&data[..len]);
self.sectors.insert(lba, sector);
}
}
impl SectorReader for MockSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
assert!(buf.len() >= total, "buffer too small");
for i in 0..count as u32 {
let offset = i as usize * SECTOR_SIZE;
if let Some(data) = self.sectors.get(&(lba + i)) {
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
} else {
// Return zeros for unmapped sectors
buf[offset..offset + SECTOR_SIZE].fill(0);
}
}
Ok(total)
}
}
// ── Helper: build raw sector data ──────────────────────────────────────────
/// Build an AVDP sector (tag_id=2) pointing to VDS at the given LBA.
fn make_avdp_sector(vds_lba: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
// Tag ID = 2 (AVDP) at bytes [0..2]
s[0..2].copy_from_slice(&2u16.to_le_bytes());
// Main VDS extent location at bytes [16..20]
s[16..20].copy_from_slice(&vds_lba.to_le_bytes());
// Main VDS extent length at bytes [20..24] (arbitrary, say 6 sectors)
s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes());
s
}
/// Build a Primary Volume Descriptor (tag_id=1) with the given volume ID.
fn make_pvd_sector(volume_id: &str) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&1u16.to_le_bytes());
// Volume ID at offset 24, d-string format:
// compression_id(1 byte) + ASCII chars + length byte at position 55
if !volume_id.is_empty() {
let id_bytes = volume_id.as_bytes();
s[24] = 8; // compression ID = ASCII
let copy_len = id_bytes.len().min(30);
s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]);
// d-string length byte at end of 32-byte field (offset 55)
s[55] = (1 + copy_len) as u8; // compression byte + chars
}
s
}
/// Build a Partition Descriptor (tag_id=5) with partition_start.
fn make_partition_desc(partition_start: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&5u16.to_le_bytes());
// Partition start at bytes [188..192]
s[188..192].copy_from_slice(&partition_start.to_le_bytes());
s
}
/// Build a Logical Volume Descriptor (tag_id=6) with a single partition map
/// (no metadata partition -- simplest case).
fn make_lvd_sector_simple() -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&6u16.to_le_bytes());
// num_partition_maps at bytes [268..272] = 1 (no metadata partition)
s[268..272].copy_from_slice(&1u32.to_le_bytes());
s
}
/// Build a Terminating Descriptor (tag_id=8).
fn make_terminator() -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&8u16.to_le_bytes());
s
}
/// Build a File Set Descriptor (tag_id=256) with root ICB at the given meta LBA.
fn make_fsd_sector(root_meta_lba: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&256u16.to_le_bytes());
// Root directory ICB long_ad at offset 400:
// [400..404] = extent_length (one sector = 2048)
s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
// [404..408] = extent_location (LBA within metadata partition)
s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes());
s
}
/// Build an Extended File Entry (tag_id=266) for a directory with the given
/// allocation extent pointing to directory data.
fn make_dir_icb(data_meta_lba: u32, data_len: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&266u16.to_le_bytes());
// info_length at offset 56 (u64)
s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes());
// L_EA at offset 208 = 0 (no extended attributes)
s[208..212].copy_from_slice(&0u32.to_le_bytes());
// L_AD at offset 212 = 8 (one short allocation descriptor)
s[212..216].copy_from_slice(&8u32.to_le_bytes());
// Short allocation descriptor at offset 216:
// [216..220] = extent_length (type=0 in top 2 bits, length in low 30 bits)
s[216..220].copy_from_slice(&data_len.to_le_bytes());
// [220..224] = extent_position (LBA within metadata partition)
s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes());
s
}
/// Build a File Identifier Descriptor (tag_id=257) for a named entry.
/// Returns raw bytes (not padded to full sector).
fn make_fid(name: &str, icb_meta_lba: u32, is_dir: bool) -> Vec<u8> {
// FID name: compression_id(1) + ASCII bytes
let mut name_bytes = vec![8u8]; // compression ID = 8 (ASCII)
name_bytes.extend_from_slice(name.as_bytes());
let l_fi = name_bytes.len() as u8;
let file_chars: u8 = if is_dir { 0x02 } else { 0x00 };
// Fixed header = 38 bytes, L_IU = 0
let fid_len = ((38 + 0 + l_fi as usize + 3) & !3) as usize; // 4-byte aligned
let mut fid = vec![0u8; fid_len];
// tag_id = 257
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
// file_characteristics at offset 18
fid[18] = file_chars;
// L_FI at offset 19
fid[19] = l_fi;
// ICB long_ad at offset 20:
// [20..24] = extent_length (2048)
fid[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
// [24..28] = extent_location (LBA)
fid[24..28].copy_from_slice(&icb_meta_lba.to_le_bytes());
// L_IU at offset 36 = 0
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
// Name starts at offset 38
fid[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
fid
}
/// Build a parent FID (file_chars = 0x08, no name).
fn make_parent_fid() -> Vec<u8> {
let fid_len = ((38 + 0 + 0 + 3) & !3) as usize;
let mut fid = vec![0u8; fid_len];
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
fid[18] = 0x08; // parent
fid[19] = 0; // L_FI = 0
fid
}
/// Build a File Entry ICB (tag_id=261) for a file, with the given extent.
fn make_file_icb(data_lba: u32, data_len: u32, file_size: u64) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&261u16.to_le_bytes());
// info_length at offset 56 (u64)
s[56..64].copy_from_slice(&file_size.to_le_bytes());
// L_EA at offset 168 = 0
s[168..172].copy_from_slice(&0u32.to_le_bytes());
// L_AD at offset 172 = 8
s[172..176].copy_from_slice(&8u32.to_le_bytes());
// Short allocation descriptor at offset 176:
s[176..180].copy_from_slice(&data_len.to_le_bytes());
s[180..184].copy_from_slice(&data_lba.to_le_bytes());
s
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[test]
fn mock_sector_reader_roundtrip() {
let mut reader = MockSectorReader::new();
// Write a recognizable pattern to sector 100
let mut data = vec![0u8; SECTOR_SIZE];
data[0] = 0xAB;
data[1] = 0xCD;
data[2047] = 0xFF;
reader.set_sector(100, data.clone());
// Read it back
let mut buf = vec![0u8; SECTOR_SIZE];
let n = reader.read_sectors(100, 1, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 0xAB);
assert_eq!(buf[1], 0xCD);
assert_eq!(buf[2047], 0xFF);
// Reading an unmapped sector returns zeros
let mut buf2 = vec![0xFFu8; SECTOR_SIZE];
let n2 = reader.read_sectors(999, 1, &mut buf2).unwrap();
assert_eq!(n2, SECTOR_SIZE);
assert_eq!(buf2[0], 0);
assert_eq!(buf2[2047], 0);
}
#[test]
fn mock_sector_reader_multi_sector() {
let mut reader = MockSectorReader::new();
let mut s10 = vec![0u8; SECTOR_SIZE];
s10[0] = 10;
reader.set_sector(10, s10);
let mut s11 = vec![0u8; SECTOR_SIZE];
s11[0] = 11;
reader.set_sector(11, s11);
// Read 2 consecutive sectors
let mut buf = vec![0u8; SECTOR_SIZE * 2];
let n = reader.read_sectors(10, 2, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE * 2);
assert_eq!(buf[0], 10);
assert_eq!(buf[SECTOR_SIZE], 11);
}
#[test]
fn read_filesystem_no_avdp() {
// Empty reader — sector 256 is all zeros, tag_id=0 != 2
let mut reader = MockSectorReader::new();
let result = udf::read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when no AVDP at sector 256");
}
#[test]
fn read_filesystem_bad_avdp_tag() {
// Put a sector at 256 with wrong tag_id
let mut reader = MockSectorReader::new();
let mut bad = vec![0u8; SECTOR_SIZE];
bad[0..2].copy_from_slice(&99u16.to_le_bytes()); // tag_id=99, not 2
reader.set_sector(256, bad);
let result = udf::read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when AVDP tag_id is not 2");
}
#[test]
fn read_filesystem_no_partition_descriptor() {
// Valid AVDP but VDS has no Partition Descriptor (tag 5)
let mut reader = MockSectorReader::new();
reader.set_sector(256, make_avdp_sector(32));
// Put a terminator immediately at sector 32
reader.set_sector(32, make_terminator());
let result = udf::read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when no partition descriptor in VDS");
}
#[test]
fn read_filesystem_bad_fsd_tag() {
// Valid AVDP + VDS with partition desc + LVD, but FSD at metadata_start has wrong tag
let mut reader = MockSectorReader::new();
let partition_start = 512;
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("TEST_DISC"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// With 1 partition map, metadata_start = partition_start.
// FSD should be at sector partition_start but we leave it as zeros (tag_id=0 != 256).
let result = udf::read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when FSD tag_id is not 256");
}
#[test]
fn read_filesystem_minimal_valid() {
// Build a minimal valid UDF image: AVDP -> VDS -> FSD -> empty root dir
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
let root_icb_meta_lba: u32 = 1; // relative to metadata_start
let root_data_meta_lba: u32 = 2;
// Sector 256: AVDP
reader.set_sector(256, make_avdp_sector(32));
// VDS at sectors 32..35
reader.set_sector(32, make_pvd_sector("MY_DISC"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD at partition_start (since single partition map, metadata_start = partition_start)
reader.set_sector(partition_start, make_fsd_sector(root_icb_meta_lba));
// Root directory ICB at metadata_start + root_icb_meta_lba
// Points to directory data at root_data_meta_lba, length = 40 (one parent FID)
let parent_fid = make_parent_fid();
let dir_data_len = parent_fid.len() as u32;
reader.set_sector(
partition_start + root_icb_meta_lba,
make_dir_icb(root_data_meta_lba, dir_data_len),
);
// Root directory data: just a parent FID (empty directory)
reader.set_sector_partial(partition_start + root_data_meta_lba, &parent_fid);
let fs = udf::read_filesystem(&mut reader).expect("should parse minimal UDF");
assert_eq!(fs.volume_id, "MY_DISC");
assert!(fs.root.is_dir);
assert!(fs.root.entries.is_empty(), "root should have no children");
}
#[test]
fn read_filesystem_with_subdirectory() {
// Build a UDF image with root -> BDMV (dir) -> test.mpls (file)
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
// Layout (all relative to partition_start which equals metadata_start):
// meta LBA 0 = FSD
// meta LBA 1 = root ICB
// meta LBA 2 = root dir data
// meta LBA 3 = BDMV ICB
// meta LBA 4 = BDMV dir data
// meta LBA 5 = test.mpls file ICB
// meta LBA 10 = test.mpls file data (partition-relative)
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("DISC_WITH_BDMV"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD -> root ICB at meta LBA 1
reader.set_sector(partition_start + 0, make_fsd_sector(1));
// Root dir: parent FID + BDMV dir FID
let parent_fid = make_parent_fid();
let bdmv_fid = make_fid("BDMV", 3, true);
let mut root_data = Vec::new();
root_data.extend_from_slice(&parent_fid);
root_data.extend_from_slice(&bdmv_fid);
let root_data_len = root_data.len() as u32;
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data_len));
reader.set_sector_partial(partition_start + 2, &root_data);
// BDMV dir: parent FID + test.mpls file FID
let bdmv_parent = make_parent_fid();
let mpls_fid = make_fid("test.mpls", 5, false);
let mut bdmv_data = Vec::new();
bdmv_data.extend_from_slice(&bdmv_parent);
bdmv_data.extend_from_slice(&mpls_fid);
let bdmv_data_len = bdmv_data.len() as u32;
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data_len));
reader.set_sector_partial(partition_start + 4, &bdmv_data);
// test.mpls file ICB (File Entry tag 261)
reader.set_sector(partition_start + 5, make_file_icb(10, 1024, 1024));
let fs = udf::read_filesystem(&mut reader).expect("should parse UDF with subdir");
assert_eq!(fs.volume_id, "DISC_WITH_BDMV");
// Root should have one child: BDMV
assert_eq!(fs.root.entries.len(), 1);
let bdmv = &fs.root.entries[0];
assert_eq!(bdmv.name, "BDMV");
assert!(bdmv.is_dir);
// BDMV should have one child: test.mpls
assert_eq!(bdmv.entries.len(), 1);
let mpls = &bdmv.entries[0];
assert_eq!(mpls.name, "test.mpls");
assert!(!mpls.is_dir);
assert_eq!(mpls.size, 1024);
}
#[test]
fn find_dir_case_insensitive() {
// Build a UDF image with BDMV/PLAYLIST directories, then search with various cases
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("CASE_TEST"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD
reader.set_sector(partition_start + 0, make_fsd_sector(1));
// Root -> BDMV
let root_data = [make_parent_fid(), make_fid("BDMV", 3, true)].concat();
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data.len() as u32));
reader.set_sector_partial(partition_start + 2, &root_data);
// BDMV -> PLAYLIST
let bdmv_data = [make_parent_fid(), make_fid("PLAYLIST", 5, true)].concat();
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data.len() as u32));
reader.set_sector_partial(partition_start + 4, &bdmv_data);
// PLAYLIST (empty)
let playlist_data = make_parent_fid();
reader.set_sector(partition_start + 5, make_dir_icb(6, playlist_data.len() as u32));
reader.set_sector_partial(partition_start + 6, &playlist_data);
let fs = udf::read_filesystem(&mut reader).expect("should parse");
// Exact case
assert!(fs.find_dir("BDMV/PLAYLIST").is_some());
// Lower case
assert!(fs.find_dir("bdmv/playlist").is_some());
// Mixed case
assert!(fs.find_dir("Bdmv/Playlist").is_some());
assert!(fs.find_dir("BdMv/PlayList").is_some());
// Leading/trailing slashes
assert!(fs.find_dir("/BDMV/PLAYLIST/").is_some());
// Nonexistent
assert!(fs.find_dir("BDMV/STREAM").is_none());
assert!(fs.find_dir("NONEXISTENT").is_none());
}
#[test]
fn sector_reader_is_object_safe() {
// Verify SectorReader can be used as a trait object
let mut reader = MockSectorReader::new();
reader.set_sector(0, vec![42u8; SECTOR_SIZE]);
let dyn_reader: &mut dyn SectorReader = &mut reader;
let mut buf = vec![0u8; SECTOR_SIZE];
let n = dyn_reader.read_sectors(0, 1, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 42);
}