Compare commits

...
45 Commits
Author SHA1 Message Date
MattJackson add9a929a5 Bump to v0.10.2 2026-04-15 22:26:51 +00:00
MattJackson 9bb1d02245 Fix batch overflow in Disc::copy(), DVD PGC parsing, demuxer flush at EOF
- Disc::copy() hardcoded batch=64 sectors, exceeding BU40N's 60-sector
  hw limit. Now accepts batch_sectors param, defaults to 60.
- IFO PGC: playback time at offset 0x04 not 0x02, cell time at cell+4
- DiscStream: set demuxer from content_format (TS for BD, PS for DVD)
- Flush TS/PS demuxers at EOF to avoid losing last PES frame
- M2tsStream: flush demuxer at EOF
- StdioStream: FMKV metadata header for roundtrip compatibility
2026-04-15 22:26:07 +00:00
MattJackson dc706e8153 Fix integration tests for new PES-only API
Update tests/streams.rs: IOStream → PES Stream, MkvStream::new → create,
M2tsStream::new → create, NullStream::new takes title, open_input → input,
open_output → output. All 317 tests pass.
2026-04-15 19:54:09 +00:00
MattJackson 0f18906ede v0.10.1: Streams are PES, Disc::copy() for sector dumps, zero English
Architecture:
- One stream per format, bidirectional PES (read/write on same type)
- IsoStream merged into DiscStream (one type, any SectorReader)
- Disc::copy() for disc→ISO raw sector dump
- IOStream trait deleted, all byte-level Read/Write removed
- ContentReader/OpenDisc/open_title/open_input/open_output deleted
- CountingStream wrapper for progress tracking

Error codes:
- All io::Error English strings replaced with Error enum variants
- From<Error> for io::Error conversion
- Unused variants removed, new stream/mux variants added

Deleted: mkvout.rs, pesout.rs, isowriter.rs, mkv-muxer-plan.md
Updated: all docs, README stream table, CHANGELOG

238 tests, 0 clippy warnings.
2026-04-15 19:46:01 +00:00
MattJackson bd7220c62b v0.10.0: PES pipeline audit, codec_privates on DiscTitle, streams not files 2026-04-15 17:22:52 +00:00
MattJackson 404c005e0b Remove Seek/File dependencies from stream readers
Streams are streams — they take impl Read, not Read+Seek or File.

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

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

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

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

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

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

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

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

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

Fix: transfer remainder via take_remainder/set_remainder so the
new demuxer maintains packet alignment.
2026-04-15 02:17:34 +00:00
MattJackson f400e4fc72 Remove eprintln from library — library code should not print to stderr
Drive recovery is silent. Results communicated through return values.
2026-04-15 02:00:45 +00:00
MattJackson 5a9980cb6b Fix unused variable warning from handshake fix 2026-04-15 01:56:57 +00:00
MattJackson 2f48877992 Fix handshake returning fake success on failure
Previously returned HandshakeResult with zeros when all host certs
failed. Now returns None. Also propagates volume_id read failure
instead of silently using zeros.
2026-04-15 01:54:58 +00:00
MattJackson 7c59d063ee decrypt_sectors returns Result — fail instead of silent corruption
Previously used all-zeros AES key when unit_key_idx was out of range,
producing silently corrupted output. Now returns DecryptFailed error.
Also wired --raw flag through InputOptions → set_raw() on streams.
2026-04-15 01:52:36 +00:00
MattJackson e4ffe7cc91 Wire --raw through InputOptions to streams
set_raw() on IsoStream and DiscStream sets keys to None.
open_input passes raw flag to streams via InputOptions.
Streams skip decrypt when raw=true.
2026-04-15 01:38:18 +00:00
MattJackson 2e23e848df Decrypt back in streams — streams handle their own decryption
IsoStream decrypts in its read path using keys from scan.
IOStream trait gets keys() method with default DecryptKeys::None.
Pipeline no longer handles decrypt — just reads decrypted bytes.
2026-04-15 01:34:30 +00:00
MattJackson fbdfccdd83 Add keys() to IOStream trait — streams know their own decrypt keys
- IOStream::keys() default returns DecryptKeys::None
- IsoStream and DiscStream override with real AACS/CSS keys
- Pipeline calls input.keys() instead of separate scan_keys()
- Streams are self-contained: read bytes + provide keys
2026-04-14 23:42:14 +00:00
MattJackson 2f52188f77 Drive recovery, reset on open, simplified DiscStream
- SgIoTransport::reset() — open/close/TUR/escalate on every open
- Drive::read() — single read method with error recovery (min speed,
  sleep 30s, retry, phase 1/2/3 escalation)
- Removed read_timeout, read_sectors, read_range — one read() method
- DiscStream simplified — no on_error/on_success/Recovery, delegates
  all error handling to Drive::read()
- IsoStream no longer decrypts — streams return raw bytes, pipeline
  handles decryption
- reset() on all platforms (Linux real, Windows/macOS stubs)
- Watchdog thread removed — kernel handles USB timeouts
2026-04-14 23:32:22 +00:00
MattJackson 252e58cab0 Bump to 0.9.0 2026-04-13 02:13:15 +00:00
MattJackson 3b96976a7a Add decrypt module, merge to one drive.read(), Disc::decrypt_keys()
- New decrypt.rs: DecryptKeys enum (AACS/CSS/None) + decrypt_sectors()
- Single drive.read() replaces read_disc/read_content (same SCSI READ(10))
- ContentReader and DiscStream use decrypt_sectors() (no duplicated crypto)
- Disc::decrypt_keys() exposes resolved keys for disc-to-ISO
2026-04-13 02:08:58 +00:00
MattJackson 3385de5704 Add Drive::read_capacity() for raw sector dump 2026-04-13 01:45:32 +00:00
MattJackson e9c907a2ca Pin Rust 1.86 MSRV in Cargo.toml and CI workflows 2026-04-13 00:35:35 +00:00
MattJackson db1f6bc446 Fix is_multiple_of nightly API, bump to 0.8.3
Replace s.len().is_multiple_of(2) with s.len() % 2 != 0 for stable Rust.
This was fixed previously but regressed.
2026-04-13 00:31:54 +00:00
MattJackson c43ba06b5b Fix macOS build: mark MacScsiTransport as Send
IOKit COM interface pointers are Mach port references, safe to send
between threads. The Send bound added in 0.8.1 broke macOS builds.
2026-04-13 00:22:19 +00:00
MattJackson 077e4d018f Bump to 0.8.1, make profile module public, fix unused import 2026-04-13 00:17:57 +00:00
MattJackson f8b5a1eaf1 API: Drive object, typed StreamUrl, tray lock/unlock, Send traits
- Rename DriveSession → Drive across entire codebase
- find_drives() returns Vec<Drive>, find_drive() returns Option<Drive>
- resolve_device() now pub(crate) — internal only
- StreamUrl is now a typed enum (Disc, Mkv, M2ts, Iso, Network, Stdio, Null)
  with scheme() and path_str() accessors, replacing struct of Strings
- Add lock_tray() / unlock_tray() for safe disc access during rips
- Improve reset() with eject cycle that clears LibreDrive stuck state
- Add Send bounds to ScsiTransport and PlatformDriver traits
- DiscOptions uses PathBuf instead of String for device/keydb paths
- Update doc example to use new Drive API
2026-04-13 00:13:41 +00:00
MattJackson fb1c35e653 DriveStatus API + reset() + wait_ready with fallback
- DriveStatus enum: TrayOpen, NoDisc, DiscPresent, NotReady, Unknown
- drive_status(): GET EVENT STATUS NOTIFICATION with TUR fallback
- reset(): PREVENT ALLOW → START STOP → init() escalation
- wait_ready(): tries reset on Illegal Request, falls back to drive_status
- Remaining: standard READ(10) still fails in LibreDrive stuck state
2026-04-12 23:36:16 +00:00
MattJackson 9268ea2fdb WIP: LibreDrive stuck state detection in wait_ready + scan
- wait_ready: detects MMkv vendor probe when TUR returns Illegal Request
- scan: capacity fallback to 0 when READ CAPACITY fails
- Still needs: re-init to restore standard SCSI commands, or use raw reads for UDF
2026-04-11 22:09:26 +00:00
MattJackson 9078aa8718 Fix UHD remux: skip DV EL from video_pending, dynamic lookahead
- Secondary video streams (Dolby Vision EL) no longer block codec detection
- Lookahead buffer: 10 MB default, 100 MB for UHD (>15 streams)
- Verified: Dune UHD 84.6 GB remux completes at 101 MB/s
- Verified: V for Vendetta BD 21.3 GB remux completes at 131 MB/s
2026-04-11 21:43:51 +00:00
MattJackson c55e6991b8 Doc comments, format string inlining, long literal separators
- Doc comments on DriveSession, find_drives, all Error variants, Result type
- 24 format! strings inlined (clippy pedantic)
- 25 long hex literals with separators (0xFFFFFFFF → 0xFFFF_FFFF)
- README install example updated to 0.8
2026-04-11 21:04:44 +00:00
MattJackson ca931b6522 Doc comments on public API, README version fix, format string cleanup 2026-04-11 21:01:16 +00:00
MattJackson d4d98ce593 Granular SCSI query methods on DriveSession, capture uses them
- get_config_feature(code) → Option<Vec<u8>>
- report_key_rpc_state() → Option<Vec<u8>>
- mode_sense_page(page) → Option<Vec<u8>>
- read_buffer(mode, buf_id, length) → Option<Vec<u8>>

capture.rs now uses these methods — zero raw CDB construction.
CLI info.rs has zero SCSI references.
autorip ejects via library, not shell command.
2026-04-11 20:54:04 +00:00
70 changed files with 4622 additions and 4524 deletions
+13 -3
View File
@@ -6,23 +6,33 @@ on:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
with:
components: clippy, rustfmt
- run: cargo fmt --check
- run: cargo clippy -- -D warnings
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.86.0
- run: cargo test
check-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.86.0
- run: cargo check
check-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.86.0
- run: cargo check
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.86.0
- run: cargo test
publish:
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.86.0
- name: Publish to crates.io
run: cargo publish
env:
+79
View File
@@ -1,5 +1,84 @@
# Changelog
## 0.10.1 (2026-04-15)
### Architecture: streams are PES, disc.copy() for sector dumps
- **One stream per format, bidirectional PES** — MkvStream, M2tsStream, NetworkStream, StdioStream, NullStream each handle read and write
- **IsoStream merged into DiscStream** — one type for physical drives and ISO files, different SectorReader
- **Disc::copy()** — raw sector dump for disc→ISO, not a stream operation
- **IOStream deleted** — no more byte-level Read/Write on streams
- **ContentReader/OpenDisc deleted** — replaced by DiscStream + PES pipeline
- **CountingStream** — wrapper for progress tracking, no state in streams
### Error codes only — zero English in library
- All `io::Error::new(kind, "english")` replaced with `Error` enum variants
- New error variants: StreamReadOnly, StreamWriteOnly, StreamUrlInvalid, MkvInvalid, NoStreams, etc.
- `From<Error> for io::Error` — clean conversion at system boundaries
- Removed unused error variants: WriteError, ProfileNotFound, NotUnlocked, NotCalibrated, ScsiTimeout, etc.
### Deleted dead code
- `mkvout.rs`, `pesout.rs`, `isowriter.rs` — merged into parent stream types
- `lookahead.rs` usage in MkvStream — replaced by PES direct write
- ContentReader, OpenDisc, open_title() — replaced by PES pipeline
- `open_input()`, `open_output()` — replaced by `input()`, `output()`
## 0.10.0 (2026-04-15)
### PES pipeline
- **Unified Stream trait** — `read()` returns PES frames, `write()` accepts them. One trait for all streams.
- **All streams produce/consume PES frames** — DiscStream, IsoStream, MkvStream, M2tsStream, NetworkStream, StdioStream, NullStream
- **DVD PS demux** — MPEG-2 Program Stream demuxer produces PES frames
- **MKV input stream** — MKV demux produces PES frames
- **Network/stdio PES** — PES serialization over TCP and pipes
- **FileSectorReader** — ISO files implement SectorReader for unified disc/ISO handling
### PES pipeline audit (20 fixes)
- PES serialize: track/length validation, OOM cap (256 MB), stuffing compliance
- TsDemuxer: AF length validation, find_start_code verified
- PTS: marker bit validation, ns→90kHz saturating_mul, round-to-nearest
- AC3/DTS: debug_assert promoted to runtime check
- MKV: block_vint 3-4 byte support, track bounds check
- FMKV: JSON 10 MB cap, PAT section_len underflow guard
### codec_privates refactor
- **codec_privates on DiscTitle** — no separate parameter passing, no `_with_X` method variants
- **Streams-not-files** — MkvStream and M2tsStream take `impl Read`, not `File`/`Seek`
- **M2TS roundtrip fix** — TsMuxer Annex B conversion + codec_private in FMKV header
- **MKV remux fix** — MkvStream returns codec_privates from EBML header
- **Network codec_private fix** — FMKV header carries base64 codec_privates
### Cleanup
- Remove Seek/File dependencies from stream interfaces
- Remove eprintln from library code
- Fix all clippy warnings
- 342 tests pass
## 0.9.0 (2026-04-14)
### Drive recovery + decrypt architecture
- **Drive::read()** — single read method with built-in error recovery (min speed → reset → retry)
- **Decrypt in streams** — streams handle their own decryption via `decrypt_sectors()`. Pipeline just moves bytes.
- **keys() on IOStream** — streams report their own decrypt keys
- **InputOptions** — `--raw` wired through to streams, skips decrypt only
- **decrypt_sectors returns Result** — fail instead of silent corruption
- **Handshake fix** — no longer returns fake success on failure
- **Drive::read_capacity()** — for raw sector dump (disc→ISO)
- **Reset on open** — SgIoTransport resets device on every open
- **Simplified DiscStream** — removed on_error/on_success/Recovery enum
### Platform
- **Rust 1.86 MSRV** pinned in Cargo.toml and CI
- **macOS build fix** — MacScsiTransport marked Send
- **is_multiple_of** — replaced nightly API with stable equivalent
### API changes
- **Drive object** — typed DriveSession API
- **Typed StreamUrl** — URL parsing returns enum, not strings
- **DriveStatus API** — reset(), wait_ready with fallback
- **Granular SCSI queries** — individual methods on DriveSession for capture
- **Profile module public** — for external tools (bdemu)
- **Tray lock/unlock** — exposed on Drive
## 0.8.0 (2026-04-11)
### DVD support
+6 -1
View File
@@ -1,7 +1,8 @@
[package]
name = "libfreemkv"
version = "0.8.0"
version = "0.10.2"
edition = "2021"
rust-version = "1.86"
license = "AGPL-3.0-only"
description = "Open source raw disc access library for optical drives"
repository = "https://github.com/freemkv/libfreemkv"
@@ -30,3 +31,7 @@ libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
[[bench]]
name = "sgio_read"
harness = false
+32 -16
View File
@@ -18,33 +18,37 @@ Part of the [freemkv](https://github.com/freemkv) project.
```toml
[dependencies]
libfreemkv = "0.6"
libfreemkv = "0.10"
```
## Quick Start
```rust
use libfreemkv::{DriveSession, Disc, ScanOptions};
use libfreemkv::{Drive, Disc, ScanOptions};
use std::path::Path;
// Open drive — profiles are bundled, auto-identified
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
session.wait_ready()?; // wait for disc
session.init()?; // unlock + firmware upload
session.probe_disc()?; // probe disc surface for optimal speeds
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
drive.wait_ready()?; // wait for disc
drive.init()?; // unlock + firmware upload
drive.probe_disc()?; // probe disc surface for optimal speeds
// Scan disc — UDF, playlists, streams, AACS (all automatic)
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
for title in &disc.titles {
println!("{} — {} streams", title.duration_display(), title.streams.len());
}
// Read content (decrypted transparently if AACS keys available)
let mut reader = disc.open_title(&mut session, 0)?;
while let Some(unit) = reader.read_unit()? {
// 6144 bytes of content per aligned unit
// Stream pipeline — read PES frames from any source, write to any output
let opts = libfreemkv::InputOptions::default();
let mut input = libfreemkv::input("iso://Disc.iso", &opts)?;
let title = input.info().clone();
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
while let Ok(Some(frame)) = input.read() {
output.write(&frame)?;
}
output.finish()?;
```
## What It Does
@@ -63,32 +67,44 @@ while let Some(unit) = reader.read_unit()? {
| Stream | Input | Output | Transport |
|--------|-------|--------|-----------|
| DiscStream | Yes | -- | Optical drive via SCSI |
| IsoStream | Yes | -- | Blu-ray ISO image file |
| IsoStream | Yes | 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.
Streams implement `IOStream` (byte-level) and `pes::Stream` (frame-level). `input()` / `output()` resolve URL strings to PES stream instances. `open_input()` / `open_output()` resolve to byte-level IOStream 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.
## Architecture
```text
DriveSession — open any drive, identify, init (optional), read sectors
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
Drive — open, identify, init, unlock, read (with recovery)
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
├── DriveProfile — per-drive unlock parameters (bundled)
└── PlatformDriver — MediaTek (supported), Renesas (planned)
Disc — scan titles, streams, AACS state
Disc — scan titles, streams, AACS/CSS state
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
├── MPLS parser — playlists → titles + clips + streams
├── CLPI parser — clip info → EP map → sector extents
├── IFO parser — DVD title sets, PGC chains, cell addresses
├── Labels — 5 BD-J format parsers (detect + parse)
├── AACS — key resolution + content decryption
├── CSS — DVD CSS cipher (table-driven, no keys needed)
└── KEYDB — download + verify + save
Streams — unified PES pipeline
├── pes::Stream — read()/write() PES frames
├── DiscStream — sectors → decrypt → TS demux → PES
├── IsoStream — ISO file → decrypt → TS demux → PES
├── MkvStream — MKV mux/demux
├── M2tsStream — BD transport stream
├── NetworkStream — TCP with FMKV metadata header
├── StdioStream — stdin/stdout pipe
└── NullStream — discard sink
```
See [docs/](docs/) for detailed technical documentation on each module.
+71
View File
@@ -0,0 +1,71 @@
// Mimics ISO dump exactly — read + write + progress
use libfreemkv::Drive;
use std::io::Write;
use std::path::Path;
use std::time::Instant;
fn main() {
let device = std::env::args()
.skip(1)
.find(|a| !a.starts_with('-'))
.unwrap_or_else(|| {
let drives = libfreemkv::find_drives();
if drives.is_empty() {
eprintln!("No drives found");
std::process::exit(1);
}
drives[0].device_path().to_string()
});
let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| {
eprintln!("Cannot open {}: {}", device, e);
std::process::exit(1);
});
eprintln!("wait_ready...");
let _ = drive.wait_ready();
eprintln!("read_capacity...");
let cap = drive.read_capacity().unwrap();
eprintln!("capacity: {} sectors", cap);
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
let mut buf = vec![0u8; batch as usize * 2048];
// Open /dev/null writer like ISO dump does
let file = std::fs::File::create("/dev/null").unwrap();
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
eprintln!("Reading 1000 batches ({:.1} MB) with write + progress...",
1000.0 * batch as f64 * 2048.0 / 1_048_576.0);
let start = Instant::now();
let mut ok = 0u32;
let mut fail = 0u32;
let mut bytes: u64 = 0;
for i in 0..1000u32 {
let lba = i * batch as u32;
match drive.read(lba, batch, &mut buf) {
Ok(_) => {
writer.write_all(&buf).unwrap();
ok += 1;
}
Err(e) => {
fail += 1;
if fail <= 5 { eprintln!(" FAIL LBA {}: {}", lba, e); }
buf.fill(0);
writer.write_all(&buf).unwrap();
}
}
bytes += buf.len() as u64;
if i % 50 == 0 && i > 0 {
let elapsed = start.elapsed().as_secs_f64();
let mb = bytes as f64 / 1_048_576.0;
eprint!("\r {:.1} MB | {:.1} MB/s ", mb, mb / elapsed);
}
}
let elapsed = start.elapsed().as_secs_f64();
let mb = ok as f64 * batch as f64 * 2048.0 / 1_048_576.0;
eprintln!("\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s", ok, fail, mb, elapsed, mb / elapsed);
}
+2 -1
View File
@@ -11,11 +11,12 @@ Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv),
| Document | What it covers |
|----------|---------------|
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
| [Drive Access](drive-access.md) | DriveSession, SCSI transport, profiles, unlock, why raw mode is needed |
| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed |
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation |
| [API Design](api-design.md) | Stream API design, PES pipeline, input/output resolution |
## Reading Order
+5 -3
View File
@@ -189,12 +189,14 @@ In practice, AACS 2.0 UHD discs work through the backward-compatible AACS 1.0 ha
AACS decryption is transparent to the application. The `Disc::scan()` method handles everything automatically:
```rust
use libfreemkv::{DriveSession, Disc};
use libfreemkv::{Drive, Disc};
use libfreemkv::disc::ScanOptions;
use std::path::Path;
let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
let mut drive = Drive::open(Path::new("/dev/sg4")).unwrap();
drive.wait_ready().unwrap();
drive.init().unwrap();
let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
// Check encryption state
if disc.encrypted {
+120 -170
View File
@@ -12,82 +12,110 @@
```rust
// Open drive — explicit steps, app prints between them
let mut session = DriveSession::open(path)?;
session.wait_ready()?;
session.init()?;
session.probe_disc()?;
let mut drive = Drive::open(path)?;
drive.wait_ready()?;
drive.init()?;
drive.probe_disc()?;
// Scan disc
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
// Browse
disc.titles // Vec<Title>
disc.titles // Vec<DiscTitle>
disc.format // BD / UHD / DVD
disc.capacity_gb()
// Rip with events
disc.rip(&mut session, 0, output, |event| {
match event.kind {
EventKind::BytesRead { bytes, total } => ...,
EventKind::ReadError { sector, error } => ...,
EventKind::Retry { attempt } => ...,
EventKind::SpeedChange { speed_kbs } => ...,
EventKind::Complete { bytes, errors } => ...,
}
})?;
// Rip without events
disc.rip(&mut session, 0, output, event::ignore)?;
```
## Stream Chains
## PES Pipeline (primary API)
Each stream wraps the next. Builder pattern, no `.build()`.
The PES pipeline is the main way to move content. All streams produce/consume
PES frames. The pipeline just reads frames and writes frames.
### Raw m2ts
```rust
disc.rip(&mut session, 0, File::create("movie.m2ts")?, event::ignore)?;
// URL-based — any source to any destination
let opts = InputOptions::default();
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
let title = input.info().clone();
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
while let Ok(Some(frame)) = input.read() {
output.write(&frame)?;
}
output.finish()?;
```
### MKV
The `pes::Stream` trait:
```rust
let output = MkvStream::new(File::create("movie.mkv")?)
.title(&disc.titles[0])
.max_buffer(10 * 1024 * 1024);
disc.rip(&mut session, 0, output, |e| { ... })?;
pub trait Stream {
fn read(&mut self) -> io::Result<Option<PesFrame>>;
fn write(&mut self, frame: &PesFrame) -> io::Result<()>;
fn finish(&mut self) -> io::Result<()>;
fn info(&self) -> &DiscTitle;
fn codec_private(&self, track: usize) -> Option<Vec<u8>>;
fn headers_ready(&self) -> bool;
}
```
### MKV with progress (CLI)
## IOStream (byte-level API)
For raw byte copies (disc→ISO, resume, benchmarks). Lower level than PES.
```rust
let output = ProgressStream::new(
MkvStream::new(File::create("movie.mkv")?)
.title(&disc.titles[0])
.max_buffer(10 * 1024 * 1024),
total_bytes,
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
);
disc.rip(&mut session, 0, output, |e| { ... })?;
let opts = InputOptions::default();
let mut input = open_input("iso://Disc.iso", &opts)?;
let mut output = open_output("mkv://Movie.mkv", input.info())?;
io::copy(&mut *input, &mut *output)?;
output.finish()?;
```
### Future: transcode
## Streams
All streams implement `IOStream` (byte-level) and/or `pes::Stream` (frame-level).
URL-based resolvers open any stream by string.
| Stream | Input | Output | URL | Transport |
|--------|-------|--------|-----|-----------|
| DiscStream | Yes | -- | `disc://` `disc:///dev/sg4` | Optical drive via SCSI |
| IsoStream | Yes | 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) |
All URLs require a `scheme://path` format. Bare paths are rejected.
```rust
let output = ProgressStream::new(
TranscodeStream::new(
MkvStream::new(File::create("movie.mkv")?)
.title(&disc.titles[0])
.max_buffer(50 * 1024 * 1024),
)
.codec(H265)
.quality(22),
total_bytes,
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
);
// PES pipeline (frame-level)
let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream
let input = libfreemkv::input("iso://Dune.iso", &opts)?; // IsoStream
let output = libfreemkv::output("mkv://Dune.mkv", &title)?; // MkvOutputStream
let output = libfreemkv::output("m2ts://Dune.m2ts", &title)?; // M2tsOutputStream
let output = libfreemkv::output("network://10.0.0.1:9000", &title)?; // NetworkOutputStream
let output = libfreemkv::output("null://", &title)?; // NullOutputStream
disc.rip(&mut session, 0, output, |e| { ... })?;
// IOStream (byte-level)
let input = open_input("disc://", &opts)?; // DiscStream
let input = open_input("iso://Dune.iso", &opts)?; // IsoStream
let output = open_output("iso://Copy.iso", &meta)?; // IsoStream (write)
let output = open_output("mkv://Dune.mkv", &meta)?; // MkvStream
let output = open_output("m2ts://Dune.m2ts", &meta)?; // M2tsStream
let output = open_output("null://", &meta)?; // NullStream
```
### 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, codec_privates, and full stream layout
(PIDs, codecs, languages, labels). This allows the receiving end to set up
demuxing and track metadata without scanning the TS.
## Events
Lib fires events during operations. App provides a callback. No display, no text.
@@ -107,105 +135,8 @@ pub enum EventKind {
}
```
Events report what happened. App decides what to do. GUI shows a dialog. CLI prints a line. Server logs to file.
## Error Codes
Lib errors are codes, not messages. Like HTTP status codes.
```rust
pub enum Error {
// Drive
DriveNotFound,
DriveOpenFailed,
DriveNotReady,
// Unlock
UnlockFailed,
NoProfile,
// AACS
AacsNoKeys,
AacsCertVerifyFailed,
AacsAgidAllocFailed,
AacsHandshakeFailed,
AacsVidMacFailed,
// Disc
DiscReadError { sector: u64 },
MplsParseError,
ClpiParseError,
UdfFileNotFound { path: String },
// Mux
LookaheadOverflow,
MuxWriteError,
// SCSI
ScsiError { sense: u8 },
}
```
App maps codes to localized strings. Lib never contains display text.
## Streams
All streams implement the `IOStream` trait (Read + Write). URL-based resolver opens any stream by string.
| 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) |
All URLs require a `scheme://path` format. Bare paths are rejected.
```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)
2. Found it? Write MKV header, flush buffer, switch to streaming
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.
Events report what happened. App decides what to do. GUI shows a dialog. CLI
prints a line. Server logs to file.
## File Layout
@@ -214,38 +145,57 @@ libfreemkv/src/
├── lib.rs Public exports
├── error.rs Error codes (no English)
├── event.rs Event types for callbacks
├── drive.rs DriveSession (open, init, read)
├── disc.rs Disc (scan, rip, titles)
├── scsi/ SCSI transport (Linux, macOS)
├── drive/ Drive (open, init, read with recovery)
│ ├── mod.rs Drive struct, init, read, reset, eject
│ ├── capture.rs Drive profile capture for contribution
│ ├── linux.rs Linux drive discovery
│ ├── macos.rs macOS drive discovery
│ └── windows.rs Windows drive discovery
├── disc/ Disc (scan, titles, AACS setup)
├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI)
├── platform/ Drive unlock (MT1959 A/B)
├── aacs/ AACS decryption
├── udf.rs UDF filesystem parser
├── mpls.rs Playlist parser
├── clpi.rs Clip info parser
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
├── css/ DVD CSS cipher
├── decrypt.rs Unified decrypt dispatcher (AACS/CSS/None)
├── pes.rs PES frame types, Stream trait
├── sector.rs SectorReader trait
├── udf.rs UDF 2.50 filesystem parser
├── mpls.rs MPLS playlist parser
├── clpi.rs CLPI clip info parser
├── ifo.rs DVD IFO parser
├── labels/ BD-J label extraction (5 format parsers)
├── keydb.rs KEYDB download, parse, save
├── identity.rs DriveId from INQUIRY
├── profile.rs Bundled drive profiles
├── speed.rs DriveSpeed enum
├── mux/
│ ├── 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)
│ ├── resolve.rs URL parser + open_input/open_output + input/output
│ ├── meta.rs FMKV header format
│ ├── disc.rs DiscStream (optical drive → PES)
│ ├── iso.rs IsoStream (ISO image read/write)
│ ├── isowriter.rs ISO image writer (UDF, AVDP, multi-extent)
│ ├── mkvstream.rs MkvStream (bidirectional Matroska, IOStream)
│ ├── mkvout.rs MkvOutputStream (PES → MKV)
│ ├── m2ts.rs M2tsStream (BD-TS, IOStream)
│ ├── pesout.rs PES output streams (M2ts, Network, Stdio, Null)
│ ├── 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
│ ├── tsreader.rs TS reader utilities
│ ├── tsmux.rs TS muxer (PES → BD-TS packets)
│ ├── ps.rs MPEG-2 PS demuxer (DVD)
│ ├── 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)
│ └── codec/ Frame parsers (H.264, HEVC, MPEG-2, VC-1, AC3, EAC3, DTS, TrueHD, LPCM, PGS)
└── ...
freemkv/src/
├── 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
├── pipe.rs PES pipeline — source → dest copy
├── disc_info.rs Disc/file info display
├── info.rs Drive info + profile submission
├── strings.rs i18n string table
├── output.rs Verbosity-filtered output
+63 -35
View File
@@ -13,9 +13,9 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
format handling live in the library. CLI binaries are thin wrappers that call
`DriveSession::open()` and `Disc::scan()`.
`Drive::open()` and `Disc::scan()`.
2. **No external files.** 206 drive profiles are compiled into the binary via
2. **No external files.** Bundled drive profiles are compiled into the binary via
`include_str!`. No configuration directory, no runtime file lookups for drive
support.
@@ -23,12 +23,16 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
available. Callers read cleartext sectors without knowing whether the disc
was encrypted.
4. **Structured errors, no English.** Every error has a numeric code (E1000-E7000).
4. **Structured errors, no English.** Every error has a numeric code (E1000-E8000).
The library never formats user-facing messages -- applications do that.
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
policy level. If a profile exists, the library uses it.
6. **Streams are dumb pipes.** Streams read/write PES frames. They don't know
about encryption, transport format, or source type. Decrypt is a stream-internal
concern; the pipeline just moves frames.
---
## Module Map
@@ -37,26 +41,39 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
libfreemkv (lib.rs)
│
├── Drive Access
│ ├── drive DriveSession — open, identify, unlock, read
│ ├── scsi ScsiTransport trait + SG_IO implementation
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
│ ├── scsi ScsiTransport trait + platform backends (SG_IO, IOKit, SPTI)
│ ├── platform/ Platform trait — per-chipset command handlers
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, hp)
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
│ ├── profile DriveProfile loading, matching, bundled JSON
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
│ └── speed DriveSpeed enum, SET CD SPEED CDB builder
│ ├── speed DriveSpeed enum, SET CD SPEED CDB builder
│ └── event Event system for drive status callbacks
│
├── Disc Scanning
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
│ ├── mpls MPLS playlist parser — clips, streams, STN table
│ ├── clpi CLPI clip info parser — EP map, sector extents
│ └── jar BD-J JAR label extraction (audio/subtitle names)
│ ├── ifo DVD IFO parser — title sets, PGC chains, cell addresses
│ └── labels/ BD-J label extraction (5 formats: Paramount, Criterion, Pixelogic, CTRM, Deluxe)
│
├── Encryption
│ ├── aacs KEYDB parsing, VUK lookup, MKB processing, unit decryption
│ └── aacs_handshake ECDH bus authentication, Volume ID, Read Data Key
│ ├── aacs/ AACS handshake, KEYDB, VUK lookup, MKB, unit decryption
│ ├── css DVD CSS cipher — table-driven, no external keys needed
│ └── decrypt decrypt_sectors() — unified AACS/CSS/None dispatcher
│
└── error Error enum with numeric codes E1000-E7000
├── Streaming
│ ├── mux/ Stream implementations (Disc, ISO, MKV, M2TS, Network, Stdio, Null)
│ ├── pes PES frame types, Stream trait (read/write frames)
│ └── sector SectorReader trait — abstracts disc vs ISO vs file
│
├── Support
│ ├── keydb KEYDB.cfg download, parse, verify, save
│ ├── error Error enum with numeric codes E1000-E8000
│ └── profile Bundled drive profiles
│
└── lib.rs Public API re-exports
```
---
@@ -64,27 +81,28 @@ libfreemkv (lib.rs)
## Drive Access Flow
```
DriveSession::open("/dev/sr0")
Drive::open(Path::new("/dev/sg4"))
│
├─ scsi::open() Open /dev/sr0 via SG_IO
├─ scsi::open() Open /dev/sg4 via SG_IO
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
├─ profile::find_by_drive_id() Match against 206 bundled profiles
├─ profile::find_by_drive_id() Match against bundled profiles
├─ Platform::new() Instantiate chipset driver (Mt1959)
└─ Platform::unlock() Activate raw disc access mode
└─ Drive ready for init/unlock/read
```
After open, the session provides:
- `read_sectors(lba, count, buf)` -- raw sector reads (through platform driver)
- `read_disc(lba, count, buf)` -- standard READ(10) for filesystem data
- `scsi_execute(cdb, dir, buf, timeout)` -- arbitrary SCSI commands
- `status()`, `calibrate()`, `read_config()`, `read_register()`
After open:
- `init()` -- unlock + firmware upload + speed calibration
- `probe_disc()` -- probe disc surface for optimal speeds
- `read(lba, count, buf)` -- single read method with built-in error recovery
- `wait_ready()` -- wait for disc insertion
- `eject()` -- eject tray
---
## Disc Scanning Flow
```
Disc::scan(&mut session, &ScanOptions)
Disc::scan(&mut drive, &ScanOptions)
│
├─ READ CAPACITY Get disc size in sectors
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
@@ -92,14 +110,24 @@ Disc::scan(&mut session, &ScanOptions)
│ ├─ mpls::parse() Extract play items, STN streams
│ └─ For each clip:
│ └─ clpi::parse() EP map → sector extents for the clip's time range
├─ labels::detect() Parse BD-J JARs for stream labels
├─ Detect AACS Check for /AACS directory on disc
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
```
For DVD:
```
Disc::scan_dvd(&mut drive, &ScanOptions)
│
├─ ifo::parse() Parse VIDEO_TS.IFO — title sets, PGC chains
├─ CSS detection Check disc structure flag
└─ CSS key cracking Table-driven, no KEYDB needed
```
The result is a `Disc` with:
- `titles: Vec<Title>` -- sorted by duration, each with streams and sector extents
- `aacs: Option<AacsState>` -- decryption keys if available
- `encrypted: bool` -- whether the disc uses AACS
- `titles: Vec<DiscTitle>` -- sorted by duration, each with streams, sector extents, codec_privates
- `decrypt_keys()` -- DecryptKeys for content decryption
- `encrypted: bool` -- whether the disc uses AACS/CSS
---
@@ -114,13 +142,14 @@ Four key resolution paths, tried in order:
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
The AACS handshake (`aacs_handshake`) performs ECDH key agreement over the
The AACS handshake (`aacs/handshake`) performs ECDH key agreement over the
AACS 1.0 160-bit elliptic curve to obtain:
- **Volume ID** -- needed for VUK derivation (paths 2-4)
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
`ContentReader` handles this transparently.
`ContentReader` handles this transparently. Streams that read sectors
(DiscStream, IsoStream) decrypt internally — the pipeline sees clean bytes.
---
@@ -138,6 +167,7 @@ is baked into the library.
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
| E8xxx | KEYDB errors | `KeydbError` (download, parse, save) |
---
@@ -145,9 +175,9 @@ is baked into the library.
| Platform | Transport | Status |
|----------|-----------|--------|
| Linux | SG_IO ioctl on `/dev/sr*` | Implemented |
| macOS | IOKit SCSI passthrough | Planned |
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Planned |
| Linux | SG_IO ioctl on `/dev/sg*` | Supported |
| macOS | IOKit SCSITask | Supported |
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
implementing `execute()` for that OS and wiring it into `scsi::open()`.
@@ -158,11 +188,11 @@ implementing `execute()` for that OS and wiring it into `scsi::open()`.
| Chipset | Drives | Status |
|---------|--------|--------|
| MediaTek MT1959 | LG, ASUS, hp | Implemented (206 profiles) |
| MediaTek MT1959 | LG, ASUS, HP | Supported (bundled profiles) |
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
The `Platform` trait abstracts chipset-specific commands. Each chipset implements
10 handlers (unlock, config, register, calibrate, keepalive, status, probe,
handlers (unlock, config, register, calibrate, keepalive, status, probe,
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
chipset-specific mode and buffer ID bytes.
@@ -174,7 +204,5 @@ chipset-specific mode and buffer ID bytes.
cargo build --release
```
Linux builds produce a static library and two binaries (`freemkv-info`,
`freemkv-test`). The `libc` dependency is Linux-only. On non-Linux platforms,
the library compiles but `scsi::open()` returns a platform-not-supported error
until the IOKit/SPTI backends are implemented.
Produces a Rust library crate. The `libc` dependency is unix-only (gated).
All three platforms build and pass CI.
+56 -41
View File
@@ -9,12 +9,18 @@ This is the starting point for understanding the library.
Insert disc
│
▼
1. Open drive (drive.rs)
1. Open drive (drive/mod.rs)
│ INQUIRY → identify drive
│ Match bundled profile → chipset, unlock parameters
│
▼
2. AACS handshake (aacs_handshake.rs) — optional, separate transport
2. Init drive (drive/mod.rs → platform/mt1959)
│ Firmware upload (if needed, 10s recovery wait)
│ Unlock → vendor-specific command activates raw read mode
│ Speed calibration → probe_disc()
│
▼
3. AACS handshake (aacs/handshake.rs) — optional
│ Allocate AGID
│ Exchange certificates + nonces (ECDH)
│ Derive bus key
@@ -22,11 +28,6 @@ Insert disc
│ (fails gracefully if drive doesn't support AACS for this disc)
│
▼
3. Unlock drive (drive.rs → platform/mt1959.rs)
│ Vendor-specific command activates raw read mode
│ Required — drive firmware blocks all reads without it
│
▼
4. Read UDF filesystem (udf.rs)
│ Sector 256: AVDP → find Volume Descriptor Sequence
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
@@ -35,80 +36,94 @@ Insert disc
│ → docs/udf.md
│
▼
5. Read AACS files from disc (aacs.rs)
5. Read AACS files from disc (aacs/mod.rs)
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
│ MKB via SCSI → for key derivation fallback
│
▼
6. Resolve AACS keys (aacs.rs → resolve_keys)
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
│ Path 2: KEYDB media key + Volume ID → VUK
│ Path 3: MKB + processing keys → media key → VUK
│ Path 4: MKB + device keys → subset-difference tree → VUK
│ VUK → decrypt unit keys from Unit_Key_RO.inf
6. Resolve encryption keys (decrypt.rs → resolve_encryption)
│ BD AACS:
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
│ Path 2: KEYDB media key + Volume ID → VUK
│ Path 3: MKB + processing keys → media key → VUK
│ Path 4: MKB + device keys → subset-difference tree → VUK
│ VUK → decrypt unit keys from Unit_Key_RO.inf
│ DVD CSS:
│ Table-driven cipher — no KEYDB needed
│ → docs/aacs.md
│
▼
7. Parse playlists (mpls.rs)
7. Parse playlists (mpls.rs) — BD/UHD only
│ BDMV/PLAYLIST/*.mpls → titles with play items
│ Each play item: clip ID, in/out timestamps
│ STN table: video, audio, subtitle streams with codec + language
│ → docs/mpls.md
│
▼
8. Parse clip info (clpi.rs)
8. Parse clip info (clpi.rs) — BD/UHD only
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
│ Coarse + fine entries → full PTS and SPN
│ SPN → byte offset → sector extents for reading
│ → docs/clpi.md
│
▼
9. Parse BD-J labels (jar.rs) — optional
9. Parse BD-J labels (labels/) — optional
│ BDMV/JAR/*.jar → Java class constant pool strings
│ 5 format parsers: Paramount, Criterion, Pixelogic, CTRM, Deluxe
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
│
▼
10. Read + decrypt content (disc.rs → ContentReader)
│ For each aligned unit (6144 bytes = 3 sectors):
│ Read 3 sectors from disc
│ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC)
│ If encrypted: unit decrypt (per-unit key derivation + AES-CBC)
│ Output decrypted content
10. Stream content (mux/disc.rs → DiscStream)
│ Read sectors → decrypt → TS demux → PES frames
│ Or: read sectors → decrypt → raw bytes (for ISO output)
│ Drive::read() handles error recovery (min speed → reset → retry)
│
▼
Decrypted m2ts stream → ready for muxing/backup
PES frames → output stream (MKV, M2TS, network, etc.)
```
## API Summary
```rust
// Steps 1 + 3 (open + unlock)
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
// Open + init drive
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
drive.wait_ready()?;
drive.init()?;
drive.probe_disc()?;
// Steps 2 + 4-9 (AACS + scan)
let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?;
// Scan disc (UDF + playlists + AACS — all automatic)
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
// Step 10 (read + decrypt)
let mut reader = disc.open_title(&mut session, 0)?;
while let Some(unit) = reader.read_unit()? {
output.write_all(&unit)?;
// Stream pipeline — PES frames from any source to any output
let opts = InputOptions::default();
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
let title = input.info().clone();
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
while let Ok(Some(frame)) = input.read() {
output.write(&frame)?;
}
output.finish()?;
```
Three lines. Everything else is internal.
## Module Reference
| Module | Doc | Purpose |
|--------|-----|---------|
| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read |
| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport |
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, read (with recovery) |
| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) |
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt |
| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication |
| disc.rs | -- | High-level scan + read API |
| jar.rs | -- | BD-J audio track labels |
| error.rs | -- | Error codes (E1xxx-E7xxx) |
| ifo.rs | -- | DVD IFO parser |
| aacs/ | [aacs.md](aacs.md) | Key resolution + content decrypt + bus handshake |
| css/ | -- | DVD CSS cipher |
| decrypt.rs | -- | Unified decrypt dispatcher (AACS/CSS/None) |
| disc/ | -- | High-level scan + read API |
| labels/ | -- | BD-J stream labels (5 format parsers) |
| mux/ | -- | Stream implementations (7 stream types) |
| pes.rs | -- | PES frame types + Stream trait |
| sector.rs | -- | SectorReader trait |
| keydb.rs | -- | KEYDB download, parse, save |
| error.rs | -- | Error codes (E1xxx-E8xxx) |
| event.rs | -- | Drive event system |
+61 -85
View File
@@ -5,51 +5,55 @@ optical drives.
---
## DriveSession
## Drive
`DriveSession` is the primary API. It owns the SCSI transport, the matched
`Drive` is the primary API. It owns the SCSI transport, the matched
drive profile, and the chipset-specific platform driver.
### Opening a Drive
```rust
// Full open: identify → match profile → unlock
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
// No-unlock open: identify → match profile only
let mut session = DriveSession::open_no_unlock(Path::new("/dev/sr0"))?;
// Explicit profile (skip auto-detection)
let mut session = DriveSession::open_with_profile(Path::new("/dev/sr0"), profile)?;
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
```
**`open()`** performs the full sequence: open device, send INQUIRY, match
profile, instantiate platform driver, and unlock. Unlock failures are silently
ignored (unencrypted discs do not need it). After `open()`, both raw sector
reads and standard READ(10) work immediately.
`open()` performs: open device → send INQUIRY → match profile → instantiate
platform driver. The drive is ready for `wait_ready()` and `init()`.
**`open_no_unlock()`** skips the unlock step. This is required when AACS bus
authentication must happen before unlock. The handshake uses standard SCSI
commands that work without raw mode. After authentication completes, the caller
can invoke `session.unlock()` manually.
**`open_with_profile()`** bypasses profile auto-detection. Useful for testing
or when a custom profile is loaded from an external source.
### Session Operations
### Drive Operations
| Method | Description |
|--------|-------------|
| `unlock()` | Activate raw disc access mode via platform driver |
| `is_unlocked()` | Check if raw mode is active |
| `calibrate()` | Build speed lookup table for the current disc |
| `read_sectors(lba, count, buf)` | Raw sector read (requires unlock + calibrate) |
| `read_disc(lba, count, buf)` | Standard READ(10) with 5s timeout |
| `status()` | Query drive status and feature flags |
| `read_config()` | Read drive configuration block (1888 bytes) |
| `read_register(index)` | Read 16-byte hardware register |
| `probe(sub_cmd, addr, len)` | Generic READ BUFFER with caller parameters |
| `scsi_execute(cdb, dir, buf, timeout)` | Send an arbitrary SCSI CDB |
| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) |
| `init()` | Firmware upload + unlock + speed calibration |
| `probe_disc()` | Probe disc surface for optimal speeds |
| `read(lba, count, buf)` | Read sectors with built-in error recovery |
| `reset()` | Close/reopen device, TUR, escalate if needed |
| `lock_tray()` | Prevent tray ejection during rip |
| `unlock_tray()` | Allow tray ejection (also runs on Drop) |
| `eject()` | Eject disc tray |
| `drive_status()` | Query physical state (disc present, tray open, etc.) |
| `has_profile()` | Whether a bundled profile matched |
| `close()` | Consume Drive, cleanup (also runs via Drop) |
### init() Sequence
`init()` orchestrates the full drive unlock:
1. Platform driver `run_init()` — sends vendor-specific SCSI commands
2. If firmware upload needed: upload, wait 10s for drive reset, retry
3. Speed calibration after unlock
4. Max 3 attempts before giving up
### read() with Recovery
`Drive::read()` is the single read method. On error:
1. Set minimum speed immediately
2. Reset device (close/reopen/TUR)
3. Wait 2s for drive to settle
4. Retry at min speed, min batch (3 sectors)
5. If still failing: skip sectors, zero-fill, log
6. Stay at min speed for 500 MB after error (recovery window)
---
@@ -58,7 +62,7 @@ or when a custom profile is loaded from an external source.
### Trait
```rust
pub trait ScsiTransport {
pub trait ScsiTransport: Send {
fn execute(
&mut self,
cdb: &[u8],
@@ -66,20 +70,24 @@ pub trait ScsiTransport {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
fn reset(&mut self, device: &str) -> Result<()>;
}
```
All drive communication goes through this trait. The library never opens file
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
### Linux: SG_IO
### Platform Backends
The `SgIoTransport` implementation:
| Platform | Implementation | Device |
|----------|---------------|--------|
| Linux | `SgIoTransport` — `ioctl(fd, SG_IO, &hdr)` | `/dev/sg*` |
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
| Windows | `WindowsScsiTransport` — SPTI | `\\.\CdRomN` |
1. Opens the device path with `O_RDWR | O_NONBLOCK`.
2. Constructs an `sg_io_hdr` struct with the CDB, data buffer, and timeout.
3. Calls `ioctl(fd, SG_IO, &hdr)`.
4. Returns `ScsiResult` with status, bytes transferred, and sense data.
The Linux backend opens with `O_RDWR | O_NONBLOCK`, constructs `sg_io_hdr`,
and returns `ScsiResult` with status, bytes transferred, and sense data.
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
sense buffer and returns `Error::ScsiError`.
@@ -119,8 +127,8 @@ date for drives where Feature 010C is unavailable.
## Drive Profiles
Profiles are JSON objects compiled into the binary (`profiles.json`,
206 entries). Each profile contains:
Profiles are JSON objects compiled into the binary (`profiles.json`).
Each profile contains:
| Field | Purpose |
|-------|---------|
@@ -148,7 +156,7 @@ let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
### MediaTek MT1959
Covers all LG, ASUS, and hp optical drives. Two sub-variants share identical
Covers all LG, ASUS, and HP optical drives. Two sub-variants share identical
logic with different SCSI parameters:
| Variant | READ BUFFER mode | Buffer ID |
@@ -156,7 +164,7 @@ logic with different SCSI parameters:
| MT1959-A | 0x01 | 0x44 |
| MT1959-B | 0x02 | 0x77 |
The Platform trait maps to 10 command handlers:
The Platform trait maps to command handlers:
| Handler | Function | Description |
|---------|----------|-------------|
@@ -183,64 +191,32 @@ Optical drive firmware restricts what applications can read from disc. Without
unlock:
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
playlists, and CLPI clip info are readable without unlock. The `read_disc()`
method uses standard READ(10) and works on any drive.
playlists, and CLPI clip info are readable without unlock. Standard READ(10)
works on any drive.
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
SCSI errors (sense key 0x05, illegal request) when an application attempts to
read sectors containing encrypted m2ts content without prior AACS
authentication via the bus key.
- **The kernel sr driver blocks block-device reads.** On Linux, the kernel's
SCSI CD-ROM driver (`sr`) refuses to expose encrypted disc content through
`/dev/sr0` as a block device. Even if you open the block device directly,
reads to encrypted regions fail.
- **Raw mode bypasses firmware restrictions.** After unlock, the drive accepts
READ(10) with the raw read flag (CDB byte 1 = 0x08) for all sectors,
regardless of encryption status. This is how raw sector ripping works.
regardless of encryption status.
### open() vs open_no_unlock()
### AACS Before Unlock
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
These must execute before unlock because:
1. The AACS handshake establishes a bus key via ECDH.
2. The bus key encrypts the Volume ID and Read Data Key responses.
3. The Volume ID is needed to derive the Volume Unique Key (VUK).
4. The VUK is needed to decrypt unit keys from `Unit_Key_RO.inf`.
If `open()` unlocks first, some drives reject the subsequent AACS commands.
The correct sequence for encrypted discs is:
```rust
// 1. Open without unlock
let mut session = DriveSession::open_no_unlock(device)?;
// 2. AACS handshake (uses standard SCSI, no unlock needed)
let auth = aacs_handshake::aacs_authenticate(&mut session, &key, &cert)?;
let vid = aacs_handshake::read_volume_id(&mut session, &mut auth)?;
// 3. Now unlock for raw reads
session.unlock()?;
session.calibrate()?;
// 4. Read and decrypt content
session.read_sectors(lba, count, &mut buf)?;
```
In practice, `Disc::scan()` handles this internally. The default `open()` call
unlocks immediately and is correct for most use cases -- the scan re-opens a
second session with `open_no_unlock()` for the AACS handshake when needed.
On some drives these must execute before unlock. The `Disc::scan()` handles
this internally — it manages the handshake/unlock ordering automatically.
---
## Speed Control
After `calibrate()`, the platform driver maintains a 64-entry speed lookup table
built by probing the disc surface. On each `read_sectors()` call, the driver:
After `probe_disc()`, the platform driver maintains a speed lookup table
built by probing the disc surface. On each `read()` call, the driver:
1. Looks up the optimal speed for the target LBA in the table.
1. Looks up the optimal speed for the target LBA.
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
3. Performs the READ(10).
-240
View File
@@ -1,240 +0,0 @@
# MKV Native Muxer — Architecture
## Goal
Replace raw m2ts output with in-pipeline MKV muxing.
Disc → TS demux → MKV mux → .mkv file. One pass, no temp files.
## Data Flow
```
ContentReader::read_batch() returns &[u8] of raw BD transport stream
↓
TsDemuxer::feed(batch) parses 192-byte BD-TS packets, extracts PES
↓
PES reassembly per PID builds complete PES packets with PTS/DTS
↓
ElementaryStreamParser per track finds frame boundaries, extracts codec headers
↓
MkvMuxer::write_frame(track, pts, data) writes EBML clusters + blocks
↓
.mkv file on disk
```
## Current Integration Point
```rust
// rip.rs line ~287
match reader.read_batch() {
Ok(Some(batch)) => {
writer.write_all(batch)?; // ← replace with muxer.feed(batch)
}
}
```
Becomes:
```rust
match reader.read_batch() {
Ok(Some(batch)) => {
muxer.feed(batch)?;
}
}
```
## Components
### 1. BD Transport Stream Demuxer (`ts.rs`)
BD uses 192-byte packets (not standard 188):
```
[0-3] TP_extra_header: 2-bit copy_permission + 30-bit arrival_time_stamp
[4] Sync byte: 0x47
[5] TEI + PUSI + priority + PID[12:8]
[6] PID[7:0]
[7] Scrambling + adaptation + continuity_counter
[8..] Adaptation field (if present) + payload
```
API:
```rust
pub struct TsDemuxer {
pes_assemblers: HashMap<u16, PesAssembler>, // PID → assembler
}
impl TsDemuxer {
pub fn new(pids: &[u16]) -> Self;
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket>;
}
pub struct PesPacket {
pub pid: u16,
pub pts: Option<i64>, // 90kHz ticks
pub dts: Option<i64>, // 90kHz ticks
pub data: Vec<u8>, // elementary stream data
}
```
### 2. Elementary Stream Parsers (`codec/`)
Each codec parser finds frame boundaries and extracts initialization data.
**H.264 (`codec/h264.rs`):**
- Parse NAL units (start code 00 00 01 or 00 00 00 01)
- Extract SPS + PPS for codecPrivate
- Frame boundary = Access Unit Delimiter (NAL type 9) or SPS
**HEVC (`codec/hevc.rs`):**
- Parse NAL units
- Extract VPS + SPS + PPS for codecPrivate
- Frame boundary = VCL NAL with first_slice_segment_in_pic_flag
**AC3/EAC3 (`codec/ac3.rs`):**
- Syncword 0x0B77
- Parse frame size from header
- No codecPrivate needed (or minimal)
**DTS (`codec/dts.rs`):**
- Syncword 0x7FFE8001
- Parse frame size
- No codecPrivate needed
**TrueHD (`codec/truehd.rs`):**
- Major sync: 0xF8726FBA
- Access unit = major sync + minor syncs
- AC3 core embedded in first substream
**LPCM (`codec/lpcm.rs`):**
- Fixed frame sizes based on sample rate + channels
- Header describes format
**PGS (`codec/pgs.rs`):**
- Segment types: PCS, WDS, PDS, ODS, END
- Each segment is a complete unit
- No codecPrivate needed
### 3. MKV/EBML Muxer (`mkv.rs`)
Matroska uses EBML (Extensible Binary Meta Language).
**EBML primitives:**
- Variable-length element ID (1-4 bytes)
- Variable-length size (1-8 bytes)
- Data: uint, int, float, string, UTF-8, binary, date
**MKV structure:**
```
EBML Header
Segment
├── SeekHead (index of top-level elements)
├── Info (title, duration, muxing app)
├── Tracks (one entry per stream)
│ ├── TrackEntry (video)
│ │ ├── CodecID: "V_MPEG4/ISO/AVC" or "V_MPEGH/ISO/HEVC"
│ │ ├── CodecPrivate: SPS+PPS (H.264) or VPS+SPS+PPS (HEVC)
│ │ └── Video: PixelWidth, PixelHeight, DisplayWidth, DisplayHeight
│ ├── TrackEntry (audio)
│ │ ├── CodecID: "A_AC3" or "A_TRUEHD" or "A_DTS"
│ │ └── Audio: SamplingFrequency, Channels, BitDepth
│ └── TrackEntry (subtitle)
│ └── CodecID: "S_HDMV/PGS"
├── Chapters (optional, from MPLS chapter marks)
├── Cluster (every ~5 seconds)
│ ├── Timestamp (cluster base time)
│ ├── SimpleBlock (track, relative_ts, data)
│ ├── SimpleBlock ...
│ └── ...
├── Cluster ...
├── Cues (seek index, written at end)
└── Tags (metadata)
```
**API:**
```rust
pub struct MkvMuxer<W: Write + Seek> {
writer: W,
tracks: Vec<MkvTrack>,
cluster_start: Option<i64>,
cue_points: Vec<CuePoint>,
}
impl<W: Write + Seek> MkvMuxer<W> {
pub fn new(writer: W, tracks: &[MkvTrack]) -> Result<Self>;
pub fn write_frame(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Result<()>;
pub fn finish(self) -> Result<()>; // writes Cues + fixes SeekHead
}
```
### 4. Pipeline Glue (`mux.rs`)
Ties everything together:
```rust
pub struct MuxPipeline<W: Write + Seek> {
demuxer: TsDemuxer,
parsers: HashMap<u16, Box<dyn CodecParser>>,
muxer: MkvMuxer<W>,
pid_to_track: HashMap<u16, usize>,
}
impl<W: Write + Seek> MuxPipeline<W> {
pub fn new(writer: W, streams: &[Stream]) -> Result<Self>;
pub fn feed(&mut self, ts_data: &[u8]) -> Result<()>;
pub fn finish(self) -> Result<()>;
}
```
## File Layout
```
libfreemkv/src/
├── mux/
│ ├── mod.rs MuxPipeline (glue)
│ ├── ts.rs BD-TS demuxer (192-byte packets)
│ ├── ebml.rs EBML primitives (write variable-length ints)
│ ├── mkv.rs MKV muxer (Segment, Tracks, Clusters)
│ └── codec/
│ ├── mod.rs CodecParser trait
│ ├── h264.rs H.264 NAL parser
│ ├── hevc.rs HEVC NAL parser
│ ├── ac3.rs AC3/EAC3 frame parser
│ ├── dts.rs DTS frame parser
│ ├── truehd.rs TrueHD/Atmos parser
│ ├── lpcm.rs LPCM frame parser
│ └── pgs.rs PGS subtitle parser
```
## MKV Codec IDs
| Our Codec | MKV CodecID | codecPrivate |
|-----------|------------|--------------|
| H264 | V_MPEG4/ISO/AVC | AVCDecoderConfigurationRecord (SPS+PPS) |
| Hevc | V_MPEGH/ISO/HEVC | HEVCDecoderConfigurationRecord (VPS+SPS+PPS) |
| Vc1 | V_MS/VFW/FOURCC | BITMAPINFOHEADER |
| Mpeg2 | V_MPEG2 | sequence_header |
| Ac3 | A_AC3 | none |
| Ac3Plus | A_EAC3 | none |
| TrueHd | A_TRUEHD | none |
| DtsHdMa | A_DTS | none (core + extension) |
| Dts | A_DTS | none |
| Lpcm | A_PCM/INT/BIG | none |
| Pgs | S_HDMV/PGS | none |
## Timestamps
BD uses 90kHz PTS/DTS. MKV uses nanoseconds.
Conversion: `ns = pts * 1_000_000_000 / 90_000` = `pts * 100_000 / 9`
MKV TimestampScale default = 1,000,000 (1ms precision).
For BD content, 1ms is sufficient.
## Build Order
1. `ebml.rs` — EBML write primitives (smallest, no dependencies)
2. `ts.rs` — BD-TS demuxer (parse 192-byte packets, PES assembly)
3. `codec/ac3.rs` — simplest codec parser (fixed syncword)
4. `mkv.rs` — MKV muxer (header, tracks, clusters, blocks)
5. `mux.rs` — pipeline glue
6. Test with AC3-only stream (simplest case)
7. `codec/h264.rs` — video parser (NAL units, SPS/PPS)
8. Full BD rip test (video + audio + subs)
9. Remaining codecs (HEVC, DTS, TrueHD, PGS, LPCM, VC-1)
+70
View File
@@ -0,0 +1,70 @@
// Minimal ISO dumper — find exact stall point
use libfreemkv::Drive;
use std::io::{BufWriter, Write};
use std::path::Path;
use std::time::Instant;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("Usage: iso_dump <device> <output>");
std::process::exit(1);
}
let mut drive = Drive::open(Path::new(&args[1])).unwrap();
drive.wait_ready().unwrap();
let _ = drive.init();
let _ = drive.probe_disc();
// AACS handshake — required to read past the protected area
eprint!("Scanning disc... ");
let _ = libfreemkv::Disc::scan(&mut drive, &libfreemkv::ScanOptions::default());
eprintln!("OK");
let cap = drive.read_capacity().unwrap();
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
eprintln!("Device: {} | {} sectors | batch {}", args[1], cap, batch);
let file = std::fs::File::create(&args[2]).unwrap();
let mut w = BufWriter::with_capacity(4 * 1024 * 1024, file);
let mut buf = vec![0u8; batch as usize * 2048];
let mut lba: u32 = 0;
let start = Instant::now();
let mut last = Instant::now();
let mut bytes: u64 = 0;
let mut last_bytes: u64 = 0;
while lba < cap {
let count = ((cap - lba) as u16).min(batch);
let n = count as usize * 2048;
// Tiny yield between reads — test if pacing prevents firmware throttle
std::thread::yield_now();
let t0 = Instant::now();
let ok = drive.read(lba, count, &mut buf[..n]).is_ok();
let read_ms = t0.elapsed().as_millis();
// Flag slow reads
if read_ms > 2000 {
eprintln!("\n SLOW READ: LBA {} took {}ms (ok={})", lba, read_ms, ok);
}
if !ok { buf[..n].fill(0); }
w.write_all(&buf[..n]).unwrap();
lba += count as u32;
bytes += n as u64;
if last.elapsed().as_millis() >= 1000 {
let delta = bytes - last_bytes;
let speed = delta as f64 / last.elapsed().as_secs_f64() / 1_048_576.0;
let avg = bytes as f64 / start.elapsed().as_secs_f64() / 1_048_576.0;
let pct = bytes as f64 / (cap as f64 * 2048.0) * 100.0;
eprint!("\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ", pct, lba, speed, avg, bytes as f64 / 1e9);
last_bytes = bytes;
last = Instant::now();
}
}
w.flush().unwrap();
eprintln!("\nDone: {:.1} GB in {:.0}s", bytes as f64 / 1e9, start.elapsed().as_secs_f64());
}
+8 -11
View File
@@ -18,7 +18,7 @@
//! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility
//! (full P-256/SHA-256 AACS 2.0 handshake prepared but rarely needed)
use crate::drive::DriveSession;
use crate::drive::Drive;
use crate::error::{Error, Result};
use crate::scsi::DataDirection;
use num_bigint::BigUint;
@@ -26,14 +26,14 @@ use num_traits::{One, Zero};
use sha1::{Digest, Sha1};
/// Execute a SCSI command that reads data from the device.
fn scsi_read(session: &mut DriveSession, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
fn scsi_read(session: &mut Drive, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
let mut buf = vec![0u8; len];
session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(buf)
}
/// Execute a SCSI command that writes data to the device.
fn scsi_write(session: &mut DriveSession, cdb: &[u8], data: &[u8]) -> Result<()> {
fn scsi_write(session: &mut Drive, cdb: &[u8], data: &[u8]) -> Result<()> {
let mut buf = data.to_vec();
session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?;
Ok(())
@@ -765,7 +765,7 @@ pub struct AacsAuth {
/// Requires a host private key (20 bytes) and host certificate (92 bytes)
/// from the KEYDB.cfg HC entry.
pub fn aacs_authenticate(
session: &mut DriveSession,
session: &mut Drive,
host_priv_key: &[u8; 20],
host_cert: &[u8],
) -> Result<AacsAuth> {
@@ -890,7 +890,7 @@ pub fn aacs_authenticate(
/// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials
/// are not available.
pub fn aacs2_authenticate(
session: &mut DriveSession,
session: &mut Drive,
host_priv_key_v1: &[u8; 20],
host_cert_v1: &[u8],
host_priv_key_v2: Option<&[u8; 32]>,
@@ -914,7 +914,7 @@ pub fn aacs2_authenticate(
/// Native AACS 2.0 handshake using P-256/SHA-256.
/// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs).
fn aacs2_authenticate_p256(
session: &mut DriveSession,
session: &mut Drive,
host_priv_key: &[u8; 32],
host_cert: &[u8],
) -> Result<AacsAuth> {
@@ -1029,7 +1029,7 @@ fn aacs2_authenticate_p256(
}
/// Read Volume ID after successful authentication.
pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> {
pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 16]> {
// REPORT DISC STRUCTURE format 0x80
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsVidRead)?;
@@ -1050,10 +1050,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
}
/// Read data keys after successful authentication (for AACS 2.0 bus encryption).
pub fn read_data_keys(
session: &mut DriveSession,
auth: &mut AacsAuth,
) -> Result<([u8; 16], [u8; 16])> {
pub fn read_data_keys(session: &mut Drive, auth: &mut AacsAuth) -> Result<([u8; 16], [u8; 16])> {
// REPORT DISC STRUCTURE format 0x84
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsDataKey)?;
+3 -3
View File
@@ -57,7 +57,7 @@ pub struct DiscEntry {
/// Parse a hex string like "0xABCD..." into bytes.
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
if !s.len().is_multiple_of(2) {
if s.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
@@ -167,7 +167,7 @@ impl KeyDb {
.to_string();
// Try with 0x prefix and without
self.disc_entries
.get(&format!("0x{}", hash))
.get(&format!("0x{hash}"))
.or_else(|| self.disc_entries.get(&hash))
.and_then(|e| e.vuk)
}
@@ -180,7 +180,7 @@ impl KeyDb {
.trim_start_matches("0x")
.to_string();
self.disc_entries
.get(&format!("0x{}", hash))
.get(&format!("0x{hash}"))
.or_else(|| self.disc_entries.get(&hash))
}
+11 -9
View File
@@ -55,7 +55,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
let mut s = String::with_capacity(42);
s.push_str("0x");
for b in hash {
s.push_str(&format!("{:02X}", b));
s.push_str(&format!("{b:02X}"));
}
s
}
@@ -188,9 +188,13 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
// Try each processing key against each UV/cvalue pair
for pk in processing_keys {
for i in 0..num_uvs {
if (i + 1) * 16 > cvalues.len() { continue; }
if (i + 1) * 16 > cvalues.len() {
continue;
}
let record_start = i * 5;
if record_start + 5 > uvs.len() { continue; }
if record_start + 5 > uvs.len() {
continue;
}
let _u_mask_shift = uvs[record_start];
let uv = &uvs[record_start + 1..record_start + 5];
let cv = &cvalues[i * 16..(i + 1) * 16];
@@ -341,7 +345,7 @@ fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
/// Compute v_mask from a UV value.
fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFFFFFF;
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
@@ -411,7 +415,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
continue;
}
let u_mask: u32 = 0xFFFFFFFF << u_mask_shift;
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
let v_mask = calc_v_mask(uv);
if ((device_number & u_mask) == (uv & u_mask))
@@ -419,7 +423,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
{
// Found matching subset-difference — find the right device key
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFFFFFF << dk.u_mask_shift;
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
// Derive processing key via tree traversal
@@ -448,9 +452,7 @@ const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive(
session: &mut crate::drive::DriveSession,
) -> crate::error::Result<Vec<u8>> {
pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
let cdb = [
+1 -1
View File
@@ -46,7 +46,7 @@ impl ClipInfo {
/// Reconstruct full SPN from coarse + fine entry.
pub fn full_spn(coarse: &EpCoarse, fine: &EpFine) -> u32 {
(coarse.spn_coarse & 0xFFFE0000) + fine.spn_fine
(coarse.spn_coarse & 0xFFFE_0000) + fine.spn_fine
}
/// Get all EP entries as (PTS, SPN) pairs, fully resolved.
+76
View File
@@ -0,0 +1,76 @@
//! Decrypt-on-read layer.
//!
//! Decrypts sectors in-place using resolved keys from disc scanning.
//! Handles AACS 1.0, AACS 2.0, and CSS transparently.
//! The caller never sees encrypted data unless explicitly bypassed.
use crate::aacs;
use crate::css;
/// Resolved decryption state from disc scanning.
/// Passed to `decrypt_sectors()` — the caller doesn't need to know
/// which encryption scheme is in use.
#[derive(Clone)]
pub enum DecryptKeys {
/// No encryption on this disc.
None,
/// AACS (Blu-ray / UHD). Unit keys + optional read data key.
Aacs {
unit_keys: Vec<(u32, [u8; 16])>,
read_data_key: Option<[u8; 16]>,
},
/// CSS (DVD). Title key for sector descrambling.
Css { title_key: [u8; 5] },
}
impl DecryptKeys {
/// True if there are keys to decrypt with.
pub fn is_encrypted(&self) -> bool {
!matches!(self, DecryptKeys::None)
}
}
/// Decrypt a buffer of sectors in-place.
///
/// For AACS: processes in 6144-byte aligned units (3 sectors).
/// For CSS: processes per 2048-byte sector.
/// For None: no-op.
///
/// `unit_key_idx` selects which AACS unit key to use (0 for most discs).
///
/// Returns `Err` if decryption was expected but keys are missing or invalid.
/// Never produces silently corrupted output.
pub fn decrypt_sectors(
buf: &mut [u8],
keys: &DecryptKeys,
unit_key_idx: usize,
) -> Result<(), crate::error::Error> {
match keys {
DecryptKeys::None => {}
DecryptKeys::Aacs {
unit_keys,
read_data_key,
} => {
let uk = match unit_keys.get(unit_key_idx) {
Some((_, k)) => *k,
None => {
return Err(crate::error::Error::DecryptFailed);
}
};
let rdk = read_data_key.as_ref();
let unit_len = aacs::ALIGNED_UNIT_LEN;
for chunk in buf.chunks_mut(unit_len) {
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
aacs::decrypt_unit_full(chunk, &uk, rdk);
}
}
}
DecryptKeys::Css { title_key } => {
for chunk in buf.chunks_mut(2048) {
css::lfsr::descramble_sector(title_key, chunk);
}
}
}
Ok(())
}
+5 -4
View File
@@ -104,8 +104,8 @@ impl Disc {
1 | 6 | 7 => Some(Stream::Video(VideoStream {
pid: s.pid,
codec,
resolution: format_resolution(s.video_format, s.video_rate),
frame_rate: format_framerate(s.video_rate),
resolution: Resolution::from_video_format(s.video_format),
frame_rate: FrameRate::from_video_rate(s.video_rate),
hdr: match s.dynamic_range {
1 => HdrFormat::Hdr10,
2 => HdrFormat::DolbyVision,
@@ -137,9 +137,9 @@ impl Disc {
Some(Stream::Audio(AudioStream {
pid: s.pid,
codec,
channels: format_channels(s.audio_format),
channels: AudioChannels::from_audio_format(s.audio_format),
language: s.language.clone(),
sample_rate: format_samplerate(s.audio_rate),
sample_rate: SampleRate::from_audio_rate(s.audio_rate),
secondary: s.stream_type == 5,
label: String::new(),
}))
@@ -187,6 +187,7 @@ impl Disc {
chapters,
extents,
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
})
}
+8 -32
View File
@@ -20,20 +20,13 @@ impl Disc {
let mut title_number: u16 = 0;
for ts in &dvd_info.title_sets {
// Map DvdVideoAttr to Stream::Video
let video_codec = match ts.video.codec.as_str() {
"mpeg2" => Codec::Mpeg2,
"mpeg1" => Codec::Mpeg2, // treat MPEG-1 as MPEG-2 for container purposes
_ => Codec::Mpeg2,
};
let video_stream = Stream::Video(VideoStream {
pid: 0xE0, // DVD video PID (standard MPEG PS video stream)
codec: video_codec,
resolution: ts.video.resolution.clone(),
codec: ts.video.codec,
resolution: ts.video.resolution,
frame_rate: match ts.video.standard.as_str() {
"PAL" => "25".to_string(),
_ => "29.97".to_string(),
"PAL" => FrameRate::F25,
_ => FrameRate::F29_97,
},
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
@@ -47,31 +40,13 @@ impl Disc {
.iter()
.enumerate()
.map(|(i, a)| {
let codec = match a.codec.as_str() {
"ac3" => Codec::Ac3,
"dts" => Codec::Dts,
"lpcm" => Codec::Lpcm,
"mpeg1" | "mpeg2" => Codec::Mpeg2,
_ => Codec::Unknown(0),
};
let channels = match a.channels {
1 => "mono".to_string(),
2 => "stereo".to_string(),
6 => "5.1".to_string(),
8 => "7.1".to_string(),
n => format!("{}ch", n),
};
let sample_rate = match a.sample_rate {
48000 => "48kHz".to_string(),
96000 => "96kHz".to_string(),
sr => format!("{}kHz", sr / 1000),
};
let codec = a.codec;
Stream::Audio(AudioStream {
pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs
codec,
channels,
channels: AudioChannels::from_count(a.channels),
language: a.language.clone(),
sample_rate,
sample_rate: SampleRate::from_hz(a.sample_rate),
secondary: false,
label: String::new(),
})
@@ -136,6 +111,7 @@ impl Disc {
chapters: Vec::new(),
extents,
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
}
}
+10 -12
View File
@@ -17,7 +17,7 @@ impl Disc {
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
/// Only available when scanning from a real drive (not ISO images).
pub(super) fn do_handshake(
session: &mut crate::drive::DriveSession,
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> Option<HandshakeResult> {
use crate::aacs::{self, KeyDb};
@@ -25,12 +25,14 @@ impl Disc {
let keydb_path = opts.resolve_keydb()?;
let keydb = KeyDb::load(&keydb_path).ok()?;
let mut last_error = None;
for hc in &keydb.host_certs {
const MAX_CERT_ATTEMPTS: usize = 16;
for hc in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS) {
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
Ok(mut auth) => {
let volume_id =
aacs::handshake::read_volume_id(session, &mut auth).unwrap_or([0u8; 16]);
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
Ok(vid) => vid,
Err(_) => return None, // handshake succeeded but can't read VID
};
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
.ok()
.map(|(rdk, _)| rdk);
@@ -39,17 +41,13 @@ impl Disc {
read_data_key,
});
}
Err(e) => {
// Try next host cert
last_error = Some(e);
Err(_) => {
continue;
}
}
}
last_error.map(|_e| HandshakeResult {
volume_id: [0u8; 16],
read_data_key: None,
})
// All host certs failed — return None, not a fake success
None
}
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
+626 -449
View File
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
//! Drive data capture — read hardware information via SCSI.
use crate::drive::Drive;
use crate::error::Result;
/// Raw data captured from a drive's SCSI responses.
#[derive(Debug, Clone)]
pub struct DriveCapture {
/// Raw INQUIRY response (96 bytes)
pub inquiry: Vec<u8>,
/// Raw GET_CONFIG 010C response
pub gc_010c: Vec<u8>,
/// GET_CONFIG feature responses: (feature_code, feature_name, data)
pub features: Vec<CapturedFeature>,
/// REPORT_KEY RPC state
pub rpc_state: Option<Vec<u8>>,
/// MODE SENSE page 2A (capabilities)
pub mode_2a: Option<Vec<u8>>,
/// READ_BUFFER 0xF1 (Pioneer vendor data)
pub rb_f1: Option<Vec<u8>>,
/// READ_BUFFER mode 6 (MTK vendor data)
pub rb_mode6: Option<Vec<u8>>,
}
/// A single GET CONFIGURATION feature response from the drive.
#[derive(Debug, Clone)]
pub struct CapturedFeature {
pub code: u16,
pub name: &'static str,
pub data: Vec<u8>,
}
/// Feature codes to capture.
const FEATURES: &[(u16, &str)] = &[
(0x0000, "Profile List"),
(0x0001, "Core"),
(0x0003, "Removable Medium"),
(0x0010, "Random Readable"),
(0x001D, "Multi-Read"),
(0x001E, "CD Read"),
(0x001F, "DVD Read"),
(0x0040, "BD Read"),
(0x0041, "BD Write"),
(0x0100, "Power Management"),
(0x0102, "Embedded Changer"),
(0x0107, "Real Time Streaming"),
(0x0108, "Serial Number"),
(0x010C, "Firmware Information"),
(0x010D, "AACS"),
];
/// Capture all available drive data via SCSI commands.
/// Returns raw responses — no formatting, no zipping, no presentation.
pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
let id = &session.drive_id;
// Already have INQUIRY from drive open
let inquiry = id.raw_inquiry.clone();
let gc_010c = id.raw_gc_010c.clone();
// Capture GET_CONFIG features using Drive's query methods
let mut features = Vec::new();
for &(code, name) in FEATURES {
if let Some(data) = session.get_config_feature(code) {
features.push(CapturedFeature { code, name, data });
}
}
// Vendor-specific READ_BUFFER queries
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
// Standard queries
let rpc_state = session.report_key_rpc_state();
let mode_2a = session.mode_sense_page(0x2A);
Ok(DriveCapture {
inquiry,
gc_010c,
features,
rpc_state,
mode_2a,
rb_f1,
rb_mode6,
})
}
/// Mask a string for privacy (letters->A, digits->0).
pub fn mask_string(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphabetic() {
'A'
} else if c.is_ascii_digit() {
'0'
} else {
c
}
})
.collect()
}
/// Mask bytes for privacy.
pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
data.iter()
.map(|&b| {
if b.is_ascii_alphabetic() {
b'A'
} else if b.is_ascii_digit() {
b'0'
} else {
b
}
})
.collect()
}
+10 -7
View File
@@ -6,10 +6,15 @@ 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);
let path = format!("/dev/sg{i}");
if !std::path::Path::new(&path).exists() {
continue;
}
// Skip stale device nodes — sysfs entry must exist
let sysfs = format!("/sys/class/scsi_generic/sg{i}/device/model");
if !std::path::Path::new(&sysfs).exists() {
continue;
}
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
@@ -21,6 +26,7 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
drives
}
#[allow(dead_code)]
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
if path.contains("/sg") {
if !std::path::Path::new(path).exists() {
@@ -39,18 +45,15 @@ pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
&& sg_id.product_id == sr_id.product_id
&& sg_id.serial_number == sr_id.serial_number
{
let warning = format!(
"{} is a block device (sr) — using {} (sg) for raw access",
path, sg_path
);
let warning =
format!("{path} is a block device (sr) — using {sg_path} (sg) for raw access");
return Ok((sg_path, Some(warning)));
}
}
return Ok((
path.to_string(),
Some(format!(
"{} is a block device (sr) — no matching sg device found",
path
"{path} is a block device (sr) — no matching sg device found"
)),
));
}
+3 -1
View File
@@ -7,7 +7,9 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
for i in 0..16 {
let path = format!("/dev/disk{}", i);
if !std::path::Path::new(&path).exists() { continue; }
if !std::path::Path::new(&path).exists() {
continue;
}
match crate::scsi::open(std::path::Path::new(&path)) {
Ok(mut transport) => {
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
+432 -48
View File
@@ -6,6 +6,8 @@
//! 3. `init()` — activate custom firmware. Removes riplock.
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
pub mod capture;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
@@ -22,16 +24,46 @@ use crate::scsi::ScsiTransport;
use crate::sector::SectorReader;
use std::path::Path;
pub struct DriveSession {
/// Physical state of the drive tray and disc.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DriveStatus {
/// Tray is open
TrayOpen,
/// Tray closed, no disc
NoDisc,
/// Tray closed, disc present and ready
DiscPresent,
/// Drive is loading or spinning up
NotReady,
/// Could not determine status
Unknown,
}
// SCSI opcodes used in drive control
const SCSI_TEST_UNIT_READY: u8 = 0x00;
const SCSI_START_STOP_UNIT: u8 = 0x1B;
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
const SCSI_MODE_SENSE: u8 = 0x5A;
const SCSI_REPORT_KEY: u8 = 0xA4;
/// Recovery state after a read error — stay at min speed for N bytes.
const RECOVERY_WINDOW: u64 = 500 * 1024 * 1024; // 500 MB
/// Optical disc drive session -- open, identify, unlock, and read.
pub struct Drive {
scsi: Box<dyn ScsiTransport>,
driver: Option<Box<dyn PlatformDriver>>,
pub profile: Option<DriveProfile>,
pub platform: Option<profile::Platform>,
pub drive_id: DriveId,
device_path: String,
/// Bytes remaining in the min-speed recovery window.
/// After a read error, we stay at min speed for RECOVERY_WINDOW bytes.
recovery_bytes_remaining: u64,
}
impl DriveSession {
impl Drive {
pub fn open(device: &Path) -> Result<Self> {
let mut transport = crate::scsi::open(device)?;
let profiles = profile::load_bundled()?;
@@ -47,37 +79,193 @@ impl DriveSession {
None => (None, None, None),
};
Ok(DriveSession {
Ok(Drive {
scsi: transport,
driver,
platform,
profile,
drive_id,
device_path: device.to_string_lossy().to_string(),
recovery_bytes_remaining: 0,
})
}
/// Close the drive cleanly. Unlocks tray, flushes SCSI state, closes fd.
/// Also runs automatically on Drop as a safety net.
pub fn close(self) {
// cleanup() runs here via Drop
}
/// Shared cleanup — called by Drop (and thus by close).
fn cleanup(&mut self) {
self.unlock_tray();
}
// NOTE: Debug aid — remove after fd issue is resolved
pub fn device_path_owned(&self) -> String {
self.device_path.clone()
}
/// Whether this drive has a known profile (unlock parameters available).
pub fn has_profile(&self) -> bool {
self.profile.is_some()
}
pub fn wait_ready(&mut self) -> Result<()> {
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut tried_reset = false;
for _ in 0..60 {
let mut buf = [0u8; 0];
if self
.scsi
.as_mut()
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5000)
.is_ok()
{
return Ok(());
match self.scsi.as_mut().execute(
&tur,
crate::scsi::DataDirection::None,
&mut buf,
5_000,
) {
Ok(_) => return Ok(()),
Err(Error::ScsiError { sense_key: 5, .. }) if !tried_reset => {
// Illegal Request on TUR — drive may be stuck from a previous session.
// Try reset() which attempts multiple recovery approaches.
tried_reset = true;
if self.reset().is_ok() {
return Ok(());
}
// If reset failed but disc is present, proceed anyway —
// the scan path will handle errors individually.
if self.drive_status() == DriveStatus::DiscPresent {
return Ok(());
}
}
Err(_) => {}
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(Error::DeviceNotFound {
path: format!("{}: drive not ready after 30s", self.device_path),
Err(Error::DeviceNotReady {
path: self.device_path.clone(),
})
}
/// Query the physical state of the drive — disc present, tray open, etc.
/// Uses GET EVENT STATUS NOTIFICATION which works regardless of firmware state.
pub fn drive_status(&mut self) -> DriveStatus {
// GET EVENT STATUS NOTIFICATION: polled, media event class (0x10)
let cdb = [SCSI_GET_EVENT_STATUS, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x08, 0x00];
let mut buf = [0u8; 8];
match self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
) {
Ok(r) if r.bytes_transferred >= 6 => {
let media_status = buf[5];
// Bits 1-0: door/tray state
// Bit 1: media present, Bit 0: tray open
match media_status & 0x03 {
0x00 => DriveStatus::NoDisc, // tray closed, no disc
0x01 => DriveStatus::TrayOpen, // tray open
0x02 => DriveStatus::DiscPresent, // tray closed, disc present
0x03 => DriveStatus::DiscPresent, // tray closed, disc present
_ => DriveStatus::Unknown,
}
}
_ => {
// Fallback: try TUR
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut empty = [0u8; 0];
match self.scsi.as_mut().execute(
&tur,
crate::scsi::DataDirection::None,
&mut empty,
5_000,
) {
Ok(_) => DriveStatus::DiscPresent,
Err(Error::ScsiError { sense_key: 2, .. }) => DriveStatus::NotReady,
Err(Error::ScsiError { sense_key: 6, .. }) => DriveStatus::NotReady, // UNIT ATTENTION
_ => DriveStatus::Unknown,
}
}
}
}
/// Attempt to reset the drive to a clean state.
///
/// Escalates through increasingly aggressive recovery:
/// 1. Unlock tray + stop/start — handles normal stuck states
/// 2. Eject cycle — clears LibreDrive firmware stuck state (proven on BU40N)
/// 3. Re-init — firmware re-upload if profile available
///
/// Note: step 2 physically ejects the tray. On slimline drives the user
/// must push it back in manually. Returns Ok(()) if TUR succeeds after
/// any step, even if the drive reports "tray open" (that's a valid state).
pub fn reset(&mut self) -> Result<()> {
let mut buf = [0u8; 0];
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
// 1. Unlock + stop/start
self.unlock_tray();
let stop = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x00, 0x00];
let _ =
self.scsi
.as_mut()
.execute(&stop, crate::scsi::DataDirection::None, &mut buf, 5_000);
std::thread::sleep(std::time::Duration::from_millis(500));
let start = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x01, 0x00];
let _ =
self.scsi
.as_mut()
.execute(&start, crate::scsi::DataDirection::None, &mut buf, 5_000);
std::thread::sleep(std::time::Duration::from_millis(2000));
if self
.scsi
.as_mut()
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
.is_ok()
{
return Ok(());
}
// 2. Eject cycle — clears MT1959 LibreDrive stuck state.
// After eject, TUR returning "Not Ready — tray open" (sense key 2)
// counts as success: the drive is functional, just needs disc reinserted.
self.unlock_tray();
let eject = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x02, 0x00];
let _ =
self.scsi
.as_mut()
.execute(&eject, crate::scsi::DataDirection::None, &mut buf, 30_000);
std::thread::sleep(std::time::Duration::from_millis(2000));
match self
.scsi
.as_mut()
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
{
Ok(_) => return Ok(()),
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), // tray open = valid
_ => {}
}
// 3. If still stuck and we have a profile, try re-init
if self.driver.is_some() {
self.init()?;
std::thread::sleep(std::time::Duration::from_millis(1000));
match self.scsi.as_mut().execute(
&tur,
crate::scsi::DataDirection::None,
&mut buf,
5_000,
) {
Ok(_) => return Ok(()),
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()),
_ => {}
}
}
Err(Error::DeviceResetFailed {
path: self.device_path.clone(),
})
}
@@ -119,6 +307,104 @@ impl DriveSession {
}
}
/// Query a specific GET CONFIGURATION feature by code.
/// Returns the feature data (without the 8-byte header), or None if not available.
pub fn get_config_feature(&mut self, feature_code: u16) -> Option<Vec<u8>> {
let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION,
0x02,
(feature_code >> 8) as u8,
feature_code as u8,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
];
let mut buf = vec![0u8; 256];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
if r.bytes_transferred > 8 {
Some(buf[8..r.bytes_transferred].to_vec())
} else {
None
}
}
/// Read REPORT KEY RPC state (region playback control).
pub fn report_key_rpc_state(&mut self) -> Option<Vec<u8>> {
let cdb = [
SCSI_REPORT_KEY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00,
];
let mut buf = vec![0u8; 8];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
if r.bytes_transferred > 0 {
Some(buf[..r.bytes_transferred].to_vec())
} else {
None
}
}
/// Read MODE SENSE page data.
pub fn mode_sense_page(&mut self, page: u8) -> Option<Vec<u8>> {
let cdb = [SCSI_MODE_SENSE, 0x00, page, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00];
let mut buf = vec![0u8; 252];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
if r.bytes_transferred > 0 {
Some(buf[..r.bytes_transferred].to_vec())
} else {
None
}
}
/// Read vendor-specific READ BUFFER data.
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
let mut buf = vec![0u8; length as usize];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
if r.bytes_transferred > 0 {
Some(buf[..r.bytes_transferred].to_vec())
} else {
None
}
}
pub fn is_ready(&self) -> bool {
match self.driver {
Some(ref d) => d.is_ready(),
@@ -126,7 +412,16 @@ impl DriveSession {
}
}
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
/// Read sectors from the disc with automatic error recovery.
///
/// On failure: drops to min speed, waits with escalating patience
/// (5s, 10s, 15s, 30s, 60s), resets drive between attempts.
/// After recovery, stays at min speed for 500 MB before ramping up.
///
/// Returns Err only after all attempts exhausted — user should clean
/// the disc and resume.
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let timeout_ms = if self.recovery_bytes_remaining > 0 { 30_000 } else { 10_000 };
let cdb = [
crate::scsi::SCSI_READ_10,
0x00,
@@ -139,33 +434,86 @@ impl DriveSession {
count as u8,
0x00,
];
let result =
self.scsi
.as_mut()
.execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
Ok(result.bytes_transferred)
// Normal read
if let Ok(result) = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, timeout_ms,
) {
if self.recovery_bytes_remaining > 0 {
let bytes_read = count as u64 * 2048;
self.recovery_bytes_remaining =
self.recovery_bytes_remaining.saturating_sub(bytes_read);
if self.recovery_bytes_remaining == 0 {
self.set_speed(0xFFFF);
}
}
return Ok(result.bytes_transferred);
}
// Phase 1: gentle — sleep 30s, retry. 5 times.
self.set_speed(0);
for _ in 0..5 {
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(result) = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000,
) {
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
}
}
// Phase 2: fresh start — close, reset, open, init.
let device = std::path::PathBuf::from(&self.device_path);
std::thread::sleep(std::time::Duration::from_secs(5));
let _ = crate::scsi::reset(&device);
std::thread::sleep(std::time::Duration::from_secs(5));
self.scsi = crate::scsi::open(&device)?;
let _ = self.init();
let _ = self.wait_ready();
self.set_speed(0);
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
for _ in 0..5 {
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(result) = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000,
) {
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
}
}
// Both phases failed.
self.recovery_bytes_remaining = RECOVERY_WINDOW;
Err(Error::DiscRead { sector: lba as u64 })
}
pub fn read_content(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
/// Read the disc capacity in sectors (2048 bytes each).
pub fn read_capacity(&mut self) -> Result<u32> {
let cdb = [
crate::scsi::SCSI_READ_10,
crate::scsi::SCSI_READ_CAPACITY,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
(lba >> 24) as u8,
(lba >> 16) as u8,
(lba >> 8) as u8,
lba as u8,
0x00,
(count >> 8) as u8,
count as u8,
0x00,
];
let result = self.scsi.as_mut().execute(
let mut buf = [0u8; 8];
self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
30_000,
&mut buf,
5_000,
)?;
Ok(result.bytes_transferred)
let last_lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
Ok(last_lba + 1)
}
pub fn set_speed(&mut self, speed_kbs: u16) {
@@ -174,16 +522,31 @@ impl DriveSession {
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
}
/// Lock the tray so the disc cannot be ejected during a rip.
pub fn lock_tray(&mut self) {
let prevent = [SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, 0x00, 0x00, 0x00, 0x01, 0x00];
let mut buf = [0u8; 0];
let _ =
self.scsi
.as_mut()
.execute(&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000);
}
/// Unlock the tray so the user can manually eject the disc.
pub fn unlock_tray(&mut self) {
let allow = [SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut buf = [0u8; 0];
let _ =
self.scsi
.as_mut()
.execute(&allow, crate::scsi::DataDirection::None, &mut buf, 5_000);
}
/// Eject the disc tray. Unlocks first, then ejects.
pub fn eject(&mut self) -> Result<()> {
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
self.unlock_tray();
let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0];
let mut buf = [0u8; 0];
let _ = self.scsi.as_mut().execute(
&allow_cdb,
crate::scsi::DataDirection::None,
&mut buf,
5_000,
);
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
self.scsi.as_mut().execute(
&eject_cdb,
crate::scsi::DataDirection::None,
@@ -204,13 +567,36 @@ impl DriveSession {
}
}
impl SectorReader for DriveSession {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.read_disc(lba, count, buf)
impl Drop for Drive {
fn drop(&mut self) {
self.cleanup();
// SgIoTransport::drop() runs next, calling libc::close(fd)
}
}
pub fn find_drives() -> Vec<(String, DriveId)> {
impl SectorReader for Drive {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.read(lba, count, buf)
}
}
/// Find all optical drives connected to this system.
/// Returns opened Drive objects ready for use.
pub fn find_drives() -> Vec<Drive> {
discover_drives()
.into_iter()
.filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
.collect()
}
/// Find the first optical drive.
/// Returns an opened Drive ready for use.
pub fn find_drive() -> Option<Drive> {
find_drives().into_iter().next()
}
/// Internal: discover drive paths + IDs without opening full Drive objects.
fn discover_drives() -> Vec<(String, DriveId)> {
#[cfg(target_os = "linux")]
{
linux::find_drives()
@@ -225,11 +611,9 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
}
}
pub fn find_drive() -> Option<String> {
find_drives().into_iter().next().map(|(path, _)| path)
}
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
/// Resolve a device path to its raw SCSI device, with optional warning message.
#[allow(dead_code)]
pub(crate) fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
#[cfg(target_os = "linux")]
{
linux::resolve_device(path)
+116 -127
View File
@@ -15,32 +15,40 @@
//! | E6xxx | Disc format errors |
//! | E7xxx | AACS errors |
//! | E8xxx | Keydb errors |
//! | E9xxx | Mux errors |
//! | E9xxx | Stream/mux errors |
// ── Error codes ─────────────────────────────────────────────────────────────
// Device (1xxx)
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
pub const E_DEVICE_PERMISSION: u16 = 1001;
pub const E_DEVICE_NOT_READY: u16 = 1002;
pub const E_DEVICE_RESET_FAILED: u16 = 1003;
// Profile (2xxx)
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
pub const E_PROFILE_NOT_FOUND: u16 = 2001;
pub const E_PROFILE_PARSE: u16 = 2002;
// Unlock (3xxx)
pub const E_UNLOCK_FAILED: u16 = 3000;
pub const E_SIGNATURE_MISMATCH: u16 = 3001;
pub const E_NOT_UNLOCKED: u16 = 3002;
pub const E_NOT_CALIBRATED: u16 = 3003;
// SCSI (4xxx)
pub const E_SCSI_ERROR: u16 = 4000;
pub const E_SCSI_TIMEOUT: u16 = 4001;
// I/O (5xxx)
pub const E_IO_ERROR: u16 = 5000;
pub const E_WRITE_ERROR: u16 = 5001;
// Disc format (6xxx)
pub const E_DISC_READ: u16 = 6000;
pub const E_MPLS_PARSE: u16 = 6001;
pub const E_CLPI_PARSE: u16 = 6002;
pub const E_UDF_NOT_FOUND: u16 = 6003;
pub const E_DISC_NO_TITLES: u16 = 6004;
pub const E_DISC_TITLE_RANGE: u16 = 6005;
pub const E_DISC_NO_EXTENTS: u16 = 6006;
pub const E_IFO_PARSE: u16 = 6007;
pub const E_MKV_INVALID: u16 = 6008;
pub const E_NO_STREAMS: u16 = 6009;
// AACS (7xxx)
pub const E_AACS_NO_KEYS: u16 = 7000;
pub const E_AACS_CERT_SHORT: u16 = 7001;
@@ -54,7 +62,8 @@ pub const E_AACS_KEY_VERIFY: u16 = 7008;
pub const E_AACS_VID_READ: u16 = 7009;
pub const E_AACS_VID_MAC: u16 = 7010;
pub const E_AACS_DATA_KEY: u16 = 7011;
pub const E_AACS_VUK_DERIVE: u16 = 7012;
pub const E_DECRYPT_FAILED: u16 = 7013;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
pub const E_KEYDB_HTTP: u16 = 8001;
@@ -62,79 +71,62 @@ pub const E_KEYDB_INVALID: u16 = 8002;
pub const E_KEYDB_WRITE: u16 = 8003;
pub const E_KEYDB_PARSE: u16 = 8004;
pub const E_KEYDB_LOAD: u16 = 8005;
// Mux (9xxx)
pub const E_MUX_LOOKAHEAD: u16 = 9000;
pub const E_MUX_WRITE: u16 = 9001;
// Stream/mux (9xxx)
pub const E_STREAM_READ_ONLY: u16 = 9000;
pub const E_STREAM_WRITE_ONLY: u16 = 9001;
pub const E_STREAM_URL_INVALID: u16 = 9002;
pub const E_STREAM_URL_MISSING_PATH: u16 = 9003;
pub const E_STREAM_URL_MISSING_PORT: u16 = 9004;
pub const E_PES_FRAME_TOO_LARGE: u16 = 9005;
pub const E_PES_INVALID_MAGIC: u16 = 9006;
pub const E_ISO_TOO_LARGE: u16 = 9007;
pub const E_NO_METADATA: u16 = 9008;
// ── Error enum ──────────────────────────────────────────────────────────────
/// Structured error with numeric code and context data. No English text.
#[derive(Debug)]
pub enum Error {
// Device
DeviceNotFound {
path: String,
},
DevicePermission {
path: String,
},
// Device (1xxx)
DeviceNotFound { path: String },
DevicePermission { path: String },
DeviceNotReady { path: String },
DeviceResetFailed { path: String },
// Profile
// Profile (2xxx)
UnsupportedDrive {
vendor_id: String,
product_id: String,
product_revision: String,
},
ProfileNotFound {
vendor_id: String,
product_revision: String,
vendor_specific: String,
},
ProfileParse,
// Unlock
// Unlock (3xxx)
UnlockFailed,
SignatureMismatch {
expected: [u8; 4],
got: [u8; 4],
},
NotUnlocked,
NotCalibrated,
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
// SCSI
// SCSI (4xxx)
ScsiError {
opcode: u8,
status: u8,
sense_key: u8,
},
ScsiTimeout {
opcode: u8,
},
// I/O
IoError {
source: std::io::Error,
},
WriteError,
// I/O (5xxx)
IoError { source: std::io::Error },
// Disc format
DiscRead {
sector: u64,
},
// Disc format (6xxx)
DiscRead { sector: u64 },
MplsParse,
ClpiParse,
UdfNotFound {
path: String,
},
DiscNoTitles,
DiscTitleRange {
index: usize,
count: usize,
},
DiscNoExtents,
UdfNotFound { path: String },
DiscTitleRange { index: usize, count: usize },
IfoParse,
MkvInvalid,
NoStreams,
// AACS
// AACS (7xxx)
AacsNoKeys,
AacsCertShort,
AacsAgidAlloc,
@@ -147,27 +139,26 @@ pub enum Error {
AacsVidRead,
AacsVidMac,
AacsDataKey,
AacsVukDerive,
DecryptFailed,
// Keydb
KeydbConnect {
host: String,
},
KeydbHttp {
status: u16,
},
// Keydb (8xxx)
KeydbConnect { host: String },
KeydbHttp { status: u16 },
KeydbInvalid,
KeydbWrite {
path: String,
},
KeydbWrite { path: String },
KeydbParse,
KeydbLoad {
path: String,
},
KeydbLoad { path: String },
// Mux
MuxLookahead,
MuxWrite,
// Stream/mux (9xxx)
StreamReadOnly,
StreamWriteOnly,
StreamUrlInvalid { url: String },
StreamUrlMissingPath { scheme: String },
StreamUrlMissingPort { addr: String },
PesFrameTooLarge { size: usize },
PesInvalidMagic,
IsoTooLarge { path: String },
NoMetadata,
}
impl Error {
@@ -175,25 +166,22 @@ impl Error {
match self {
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY,
Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED,
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND,
Error::ProfileParse => E_PROFILE_PARSE,
Error::UnlockFailed => E_UNLOCK_FAILED,
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
Error::NotUnlocked => E_NOT_UNLOCKED,
Error::NotCalibrated => E_NOT_CALIBRATED,
Error::ScsiError { .. } => E_SCSI_ERROR,
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
Error::IoError { .. } => E_IO_ERROR,
Error::WriteError => E_WRITE_ERROR,
Error::DiscRead { .. } => E_DISC_READ,
Error::MplsParse => E_MPLS_PARSE,
Error::ClpiParse => E_CLPI_PARSE,
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
Error::DiscNoTitles => E_DISC_NO_TITLES,
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
Error::DiscNoExtents => E_DISC_NO_EXTENTS,
Error::IfoParse => E_IFO_PARSE,
Error::MkvInvalid => E_MKV_INVALID,
Error::NoStreams => E_NO_STREAMS,
Error::AacsNoKeys => E_AACS_NO_KEYS,
Error::AacsCertShort => E_AACS_CERT_SHORT,
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
@@ -206,15 +194,22 @@ impl Error {
Error::AacsVidRead => E_AACS_VID_READ,
Error::AacsVidMac => E_AACS_VID_MAC,
Error::AacsDataKey => E_AACS_DATA_KEY,
Error::AacsVukDerive => E_AACS_VUK_DERIVE,
Error::DecryptFailed => E_DECRYPT_FAILED,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID,
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
Error::KeydbParse => E_KEYDB_PARSE,
Error::KeydbLoad { .. } => E_KEYDB_LOAD,
Error::MuxLookahead => E_MUX_LOOKAHEAD,
Error::MuxWrite => E_MUX_WRITE,
Error::StreamReadOnly => E_STREAM_READ_ONLY,
Error::StreamWriteOnly => E_STREAM_WRITE_ONLY,
Error::StreamUrlInvalid { .. } => E_STREAM_URL_INVALID,
Error::StreamUrlMissingPath { .. } => E_STREAM_URL_MISSING_PATH,
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE,
Error::NoMetadata => E_NO_METADATA,
}
}
}
@@ -225,67 +220,38 @@ impl std::fmt::Display for Error {
match self {
Error::DeviceNotFound { path } => write!(f, "E{}: {}", self.code(), path),
Error::DevicePermission { path } => write!(f, "E{}: {}", self.code(), path),
Error::DeviceNotReady { path } => write!(f, "E{}: {}", self.code(), path),
Error::DeviceResetFailed { path } => write!(f, "E{}: {}", self.code(), path),
Error::UnsupportedDrive {
vendor_id,
product_id,
product_revision,
} => write!(
f,
"E{}: {} {} {}",
self.code(),
vendor_id.trim(),
product_id.trim(),
product_revision.trim()
),
Error::ProfileNotFound {
vendor_id,
product_revision,
vendor_specific,
} => write!(
f,
"E{}: {} {} {}",
self.code(),
vendor_id.trim(),
product_revision.trim(),
vendor_specific.trim()
f, "E{}: {} {} {}",
self.code(), vendor_id.trim(), product_id.trim(), product_revision.trim()
),
Error::SignatureMismatch { expected, got } => write!(
f,
"E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
self.code(),
expected[0],
expected[1],
expected[2],
expected[3],
got[0],
got[1],
got[2],
got[3]
expected[0], expected[1], expected[2], expected[3],
got[0], got[1], got[2], got[3]
),
Error::ScsiError {
opcode,
status,
sense_key,
} => write!(
f,
"E{}: 0x{:02x}/0x{:02x}/0x{:02x}",
self.code(),
opcode,
status,
sense_key
),
Error::ScsiTimeout { opcode } => write!(f, "E{}: 0x{:02x}", self.code(), opcode),
Error::ScsiError { opcode, status, sense_key } => {
write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key)
}
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
Error::DiscTitleRange { index, count } => {
write!(f, "E{}: {}/{}", self.code(), index, count)
}
Error::DiscTitleRange { index, count } => write!(f, "E{}: {}/{}", self.code(), index, count),
Error::KeydbConnect { host } => write!(f, "E{}: {}", self.code(), host),
Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
Error::KeydbLoad { path } => write!(f, "E{}: {}", self.code(), path),
// Simple codes — no extra data
Error::StreamUrlInvalid { url } => write!(f, "E{}: {}", self.code(), url),
Error::StreamUrlMissingPath { scheme } => write!(f, "E{}: {}", self.code(), scheme),
Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr),
Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size),
Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path),
_ => write!(f, "E{}", self.code()),
}
}
@@ -306,4 +272,27 @@ impl From<std::io::Error> for Error {
}
}
impl From<Error> for std::io::Error {
fn from(e: Error) -> Self {
let code = e.code();
let msg = e.to_string();
// Map our error categories to io::ErrorKind
let kind = match code {
1000..=1999 => std::io::ErrorKind::NotFound,
2000..=2999 => std::io::ErrorKind::Unsupported,
3000..=3999 => std::io::ErrorKind::PermissionDenied,
4000..=4999 => std::io::ErrorKind::Other,
5000..=5999 => std::io::ErrorKind::Other,
6000..=6999 => std::io::ErrorKind::InvalidData,
7000..=7999 => std::io::ErrorKind::PermissionDenied,
8000..=8999 => std::io::ErrorKind::Other,
9000..=9001 => std::io::ErrorKind::Unsupported,
9002..=9009 => std::io::ErrorKind::InvalidInput,
_ => std::io::ErrorKind::Other,
};
std::io::Error::new(kind, msg)
}
}
/// Convenience alias for `Result<T, Error>`.
pub type Result<T> = std::result::Result<T, Error>;
+19
View File
@@ -25,6 +25,25 @@ pub struct Event {
/// Types of events the lib can fire.
#[derive(Debug)]
pub enum EventKind {
// ── Init sequence events ────────────────────────────────────────
/// Drive opened successfully.
DriveOpened { device: String },
/// Drive is ready (disc spun up).
DriveReady,
/// Firmware init completed.
InitComplete { success: bool },
/// Disc probe completed.
ProbeComplete { success: bool },
/// Disc scan completed.
ScanComplete { titles: usize },
// ── Read events ─────────────────────────────────────────────────
/// Bytes successfully read and written to output.
BytesRead {
/// Bytes written so far.
+78 -69
View File
@@ -7,6 +7,7 @@
//! The parser reads IFO files via UDF and extracts enough information
//! to build DiscTitle structs (parallel to MPLS for Blu-ray).
use crate::disc::{Codec, Resolution};
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::udf::UdfFs;
@@ -61,8 +62,8 @@ pub struct DvdCell {
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DvdVideoAttr {
pub codec: String,
pub resolution: String,
pub codec: Codec,
pub resolution: Resolution,
pub aspect: String,
pub standard: String,
}
@@ -70,7 +71,7 @@ pub struct DvdVideoAttr {
/// DVD audio stream attributes.
#[derive(Debug, Clone)]
pub struct DvdAudioAttr {
pub codec: String,
pub codec: Codec,
pub channels: u8,
pub sample_rate: u32,
pub language: String,
@@ -265,7 +266,7 @@ fn parse_vts(
vts_number: u8,
titles_info: &[(u16, u8)],
) -> Result<DvdTitleSet> {
let path = format!("/VIDEO_TS/VTS_{:02}_0.IFO", vts_number);
let path = format!("/VIDEO_TS/VTS_{vts_number:02}_0.IFO");
let vts_data = udf.read_file(reader, &path)?;
// Validate VTS magic
@@ -349,41 +350,15 @@ fn parse_video_attr(data: &[u8]) -> Result<DvdVideoAttr> {
_ => "4:3",
};
let resolution = match (b0 >> 4) & 0x03 {
0 => {
if standard == "PAL" {
"720x576"
} else {
"720x480"
}
}
1 => {
if standard == "PAL" {
"704x576"
} else {
"704x480"
}
}
2 => {
if standard == "PAL" {
"352x576"
} else {
"352x480"
}
}
3 => {
if standard == "PAL" {
"352x288"
} else {
"352x240"
}
}
_ => "720x480",
let resolution = if standard == "PAL" {
Resolution::R576i
} else {
Resolution::R480i
};
Ok(DvdVideoAttr {
codec: "mpeg2".to_string(),
resolution: resolution.to_string(),
codec: Codec::Mpeg2,
resolution,
aspect: aspect.to_string(),
standard: standard.to_string(),
})
@@ -396,12 +371,12 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
let coding_mode = (b0 >> 5) & 0x07;
let codec = match coding_mode {
0 => "ac3",
2 => "mpeg1",
3 => "mpeg2",
4 => "lpcm",
6 => "dts",
_ => "unknown",
0 => Codec::Ac3,
2 => Codec::Mpeg1,
3 => Codec::Mp2,
4 => Codec::Lpcm,
6 => Codec::Dts,
_ => Codec::Unknown(coding_mode),
};
let sample_rate_flag = (b0 >> 3) & 0x03;
@@ -434,7 +409,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
};
Ok(DvdAudioAttr {
codec: codec.to_string(),
codec,
channels,
sample_rate,
language,
@@ -513,26 +488,20 @@ fn parse_pgcit(
/// Parse a single PGC (Program Chain) to extract duration and cells.
fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle> {
// PGC needs at least 0xE6 bytes for the cell info offsets
if pgc_offset + 0xE6 > data.len() {
// PGC needs at least 0xE8 bytes for the cell playback info offset
if pgc_offset + 0xEA > data.len() {
return Err(Error::IfoParse);
}
// Playback time at offset 2-5 (4 BCD bytes)
let time_bytes = sub_slice(data, pgc_offset + 2, 4)?;
// PGC layout:
// 0x00-0x01: misc flags
// 0x02: nr_of_programs
// 0x03: nr_of_cells
// 0x04-0x07: playback_time (4 BCD bytes)
let num_cells = byte_at(data, pgc_offset + 0x03)? as usize;
let time_bytes = sub_slice(data, pgc_offset + 0x04, 4)?;
let duration_secs = bcd_to_secs(time_bytes);
// Number of cells: the user spec says byte 0x03, but in the standard IFO
// format bytes 0x02-0x05 are the BCD playback time. The real cell count
// lives at PGC offset 0x07. We read from 0x03 as primary (per spec) and
// fall back to 0x07 if that yields zero.
let num_cells_primary = byte_at(data, pgc_offset + 0x03)? as usize;
let num_cells = if num_cells_primary == 0 {
byte_at(data, pgc_offset + 0x07).unwrap_or(0) as usize
} else {
num_cells_primary
};
// Cell playback info table offset (relative to PGC start)
let cell_playback_offset = be_u16(data, pgc_offset + 0xE8)? as usize;
@@ -561,9 +530,10 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
let cell_base = pgc_offset + cell_playback_offset;
let mut total = 0.0;
for i in 0..cells.len() {
// Cell playback info: 24 bytes per cell, BCD time at offset 4-7
let co = cell_base + i * 24;
if co + 4 <= data.len() {
total += bcd_to_secs(&data[co..co + 4]);
if co + 8 <= data.len() {
total += bcd_to_secs(&data[co + 4..co + 8]);
}
}
total
@@ -688,15 +658,15 @@ mod tests {
assert_eq!(title.cells.len(), 1);
let video = DvdVideoAttr {
codec: "mpeg2".to_string(),
resolution: "720x480".to_string(),
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
aspect: "16:9".to_string(),
standard: "NTSC".to_string(),
};
assert_eq!(video.codec, "mpeg2");
assert_eq!(video.codec, Codec::Mpeg2);
let audio = DvdAudioAttr {
codec: "ac3".to_string(),
codec: Codec::Ac3,
channels: 6,
sample_rate: 48000,
language: "en".to_string(),
@@ -720,6 +690,45 @@ mod tests {
assert_eq!(info.title_sets.len(), 1);
}
#[test]
fn pgc_parses_duration_from_correct_offset() {
// Build a minimal PGC: 0xEA bytes minimum
// PGC layout: 0x02 = nr_programs, 0x03 = nr_cells, 0x04-0x07 = BCD time
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1; // 1 program
pgc[0x03] = 2; // 2 cells
// 1h 59m 30s at 29.97fps, 0 frames
pgc[0x04] = 0x01; // hours BCD
pgc[0x05] = 0x59; // minutes BCD
pgc[0x06] = 0x30; // seconds BCD
pgc[0x07] = 0b11_000000; // 29.97fps, 0 frames
// Cell playback info offset at PGC+0xE8
let cell_offset: u16 = 0xEA; // right after minimum header
pgc[0xE8] = (cell_offset >> 8) as u8;
pgc[0xE9] = cell_offset as u8;
// Add 2 cells (24 bytes each)
pgc.resize(pgc.len() + 48, 0);
// Cell 0: sectors 100-200
let co = 0xEA;
pgc[co + 8] = 0; pgc[co + 9] = 0; pgc[co + 10] = 0; pgc[co + 11] = 100; // first sector
pgc[co + 20] = 0; pgc[co + 21] = 0; pgc[co + 22] = 0; pgc[co + 23] = 200; // last sector
// Cell 1: sectors 300-400
let co = 0xEA + 24;
pgc[co + 8] = 0; pgc[co + 9] = 0; pgc[co + 10] = 1; pgc[co + 11] = 44; // first sector = 300
pgc[co + 20] = 0; pgc[co + 21] = 0; pgc[co + 22] = 1; pgc[co + 23] = 144; // last sector = 400
let title = parse_pgc(&pgc, 0, 5).unwrap();
let expected = 1.0 * 3600.0 + 59.0 * 60.0 + 30.0;
assert!((title.duration_secs - expected).abs() < 0.1,
"expected ~{expected}s, got {}s", title.duration_secs);
assert_eq!(title.chapters, 5);
assert_eq!(title.cells.len(), 2);
assert_eq!(title.cells[0].first_sector, 100);
assert_eq!(title.cells[0].last_sector, 200);
assert_eq!(title.cells[1].first_sector, 300);
assert_eq!(title.cells[1].last_sector, 400);
}
#[test]
fn video_attr_parsing() {
// Build minimal data with video attrs at 0x200
@@ -730,8 +739,8 @@ mod tests {
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, "NTSC");
assert_eq!(attr.aspect, "16:9");
assert_eq!(attr.resolution, "720x480");
assert_eq!(attr.codec, "mpeg2");
assert_eq!(attr.resolution, Resolution::R480i);
assert_eq!(attr.codec, Codec::Mpeg2);
}
#[test]
@@ -743,7 +752,7 @@ mod tests {
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, "PAL");
assert_eq!(attr.aspect, "4:3");
assert_eq!(attr.resolution, "720x576");
assert_eq!(attr.resolution, Resolution::R576i);
}
#[test]
@@ -759,7 +768,7 @@ mod tests {
data[3] = b'n';
let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.codec, "ac3");
assert_eq!(attr.codec, Codec::Ac3);
assert_eq!(attr.sample_rate, 48000);
assert_eq!(attr.channels, 6);
assert_eq!(attr.language, "en");
@@ -777,7 +786,7 @@ mod tests {
data[3] = b'r';
let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.codec, "dts");
assert_eq!(attr.codec, Codec::Dts);
assert_eq!(attr.sample_rate, 96000);
assert_eq!(attr.channels, 2);
assert_eq!(attr.language, "fr");
+3 -3
View File
@@ -71,6 +71,7 @@ pub fn save(data: &[u8]) -> Result<UpdateResult> {
})
}
/// Result of a KEYDB update -- path written, entry count, and byte size.
#[derive(Debug)]
pub struct UpdateResult {
pub path: PathBuf,
@@ -82,7 +83,7 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
let (mut host, mut port, mut path) = parse_url(url)?;
for _ in 0..5 {
let addr = format!("{}:{}", host, port);
let addr = format!("{host}:{port}");
let mut stream =
TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect { host: host.clone() })?;
stream
@@ -90,8 +91,7 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
.ok();
let request = format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n",
path, host
"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n"
);
stream
.write_all(request.as_bytes())
+2 -2
View File
@@ -174,8 +174,8 @@ fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
}
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].trim().to_string())
+1 -1
View File
@@ -140,7 +140,7 @@ fn find_feature_playlist(xml: &str) -> Option<String> {
/// Extract an XML attribute value from an element string.
fn extract_attr(element: &str, name: &str) -> Option<String> {
let needle = format!("{}=\"", name);
let needle = format!("{name}=\"");
let start = element.find(&needle)? + needle.len();
let end = element[start..].find('"')? + start;
Some(element[start..end].to_string())
+28 -21
View File
@@ -6,28 +6,32 @@
//! # Quick Start
//!
//! ```no_run
//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive};
//! use std::path::Path;
//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
//!
//! let device = find_drive().expect("no optical drive found");
//! let mut session = DriveSession::open(Path::new(&device)).unwrap();
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
//! let mut drive = find_drive().expect("no optical drive found");
//! drive.wait_ready().unwrap();
//! drive.init().unwrap();
//! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
//!
//! for title in &disc.titles {
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
//! }
//!
//! // Read content (decrypted automatically if AACS keys available)
//! let mut reader = disc.open_title(&mut session, 0).unwrap();
//! while let Some(unit) = reader.read_unit().unwrap() {
//! // 6144 bytes of decrypted content per unit
//! // Stream via PES pipeline
//! let opts = libfreemkv::InputOptions::default();
//! let mut input = libfreemkv::input("disc://", &opts).unwrap();
//! let title = input.info().clone();
//! let mut output = libfreemkv::output("mkv://Movie.mkv", &title).unwrap();
//! while let Ok(Some(frame)) = input.read() {
//! output.write(&frame).unwrap();
//! }
//! output.finish().unwrap();
//! ```
//!
//! # Architecture
//!
//! ```text
//! DriveSession -- open, identify, unlock, read sectors
//! Drive -- open, identify, unlock, read sectors
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
//! ├── DriveProfile -- per-drive unlock parameters (206 bundled)
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
@@ -70,6 +74,7 @@
pub mod aacs;
pub(crate) mod clpi;
pub mod css;
pub mod decrypt;
pub mod disc;
pub mod drive;
pub mod error;
@@ -80,34 +85,36 @@ pub mod keydb;
pub(crate) mod labels;
pub(crate) mod mpls;
pub mod mux;
pub mod pes;
pub(crate) mod platform;
pub(crate) mod profile;
pub mod profile;
pub mod scsi;
pub(crate) mod sector;
pub mod sector;
pub(crate) mod speed;
pub(crate) mod udf;
pub use drive::{find_drive, find_drives, resolve_device, DriveSession};
pub use drive::capture::{
capture_drive_data, mask_bytes, mask_string, CapturedFeature, DriveCapture,
};
pub use drive::{find_drive, find_drives, Drive, DriveStatus};
pub use error::{Error, Result};
pub use event::{Event, EventKind};
pub use identity::DriveId;
pub use profile::DriveProfile;
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
pub use decrypt::{decrypt_sectors, DecryptKeys};
pub use disc::{
AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc,
DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream,
VideoStream,
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat,
Disc, DiscFormat, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
ScanOptions, Stream, SubtitleStream, VideoStream,
};
pub use mux::DiscOptions;
pub use mux::DiscStream;
pub use mux::IOStream;
pub use mux::IsoStream;
pub use mux::M2tsStream;
pub use mux::MkvStream;
pub use mux::NetworkStream;
pub use mux::NullStream;
pub use mux::StdioStream;
pub use mux::{open_input, open_output, parse_url, InputOptions};
pub use mux::{input, output, parse_url, InputOptions, StreamUrl};
pub use scsi::ScsiTransport;
pub use sector::SectorReader;
pub use speed::DriveSpeed;
+6 -2
View File
@@ -99,7 +99,9 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> {
/// bsid is at byte 5, bits 7..3.
/// AC-3: bsid <= 10, E-AC-3: bsid >= 11 (typically 16).
pub fn get_bsid(data: &[u8]) -> u8 {
debug_assert!(data.len() >= 6);
if data.len() < 6 {
return 0;
}
(data[5] >> 3) & 0x1F
}
@@ -107,7 +109,9 @@ pub fn get_bsid(data: &[u8]) -> u8 {
/// frmsiz is at bits [2:0] of byte 2 concatenated with byte 3.
/// Frame size = (frmsiz + 1) * 2 bytes.
pub fn eac3_frame_size(data: &[u8]) -> usize {
debug_assert!(data.len() >= 4);
if data.len() < 4 {
return 0;
}
let frmsiz = ((data[2] as usize & 0x07) << 8) | (data[3] as usize);
(frmsiz + 1) * 2
}
+3 -1
View File
@@ -82,7 +82,9 @@ pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
/// The size field is at bytes 6-8 of the extension:
/// ((ext[6] & 0x1F) << 11) | (ext[7] << 3) | (ext[8] >> 5) + 1
pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize {
debug_assert!(ext.len() >= 9);
if ext.len() < 9 {
return 0;
}
let raw =
((ext[6] as usize & 0x1F) << 11) | ((ext[7] as usize) << 3) | ((ext[8] as usize) >> 5);
raw + 1
+1 -1
View File
@@ -87,7 +87,7 @@ pub fn format_palette(palette: &[[u8; 4]]) -> Vec<u8> {
let mut parts: Vec<String> = Vec::with_capacity(palette.len());
for color in palette {
let [r, g, b] = ycbcr_to_rgb(color);
parts.push(format!("{:02x}{:02x}{:02x}", r, g, b));
parts.push(format!("{r:02x}{g:02x}{b:02x}"));
}
let line = format!("palette: {}\n", parts.join(", "));
line.into_bytes()
+9 -6
View File
@@ -111,12 +111,12 @@ impl CodecParser for H264Parser {
// pictureParameterSetNALUnit = pps
let mut record = vec![
1, // configurationVersion
sps[1], // profile
sps[2], // compatibility
sps[3], // level
0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
0xE1, // 3 bits reserved (111) + 5 bits numSPS (1)
1, // configurationVersion
sps[1], // profile
sps[2], // compatibility
sps[3], // level
0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
0xE1, // 3 bits reserved (111) + 5 bits numSPS (1)
(sps.len() >> 8) as u8,
sps.len() as u8,
];
@@ -180,6 +180,9 @@ pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
if data.len() < from + 3 {
return None;
}
// Range excludes last 2 bytes since we read 3 bytes at each position.
// data.len()-2 as exclusive upper bound means last checked index is data.len()-3,
// which accesses data[len-3], data[len-2], data[len-1] — all valid.
(from..data.len() - 2).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
}
+15 -37
View File
@@ -52,8 +52,9 @@ impl CodecParser for HevcParser {
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
let data = &pes.data;
let mut keyframe = false;
let mut frame_data = Vec::new();
// Scan NAL units
// Single-pass NAL scan: extract params, detect keyframes, build length-prefixed output
let mut pos = 0;
while let Some(sc_pos) = find_start_code(data, pos) {
if let Some(nal_start) = skip_start_code(data, sc_pos) {
@@ -68,45 +69,22 @@ impl CodecParser for HevcParser {
let nal_type = (data[nal_start] >> 1) & 0x3F;
match nal_type {
NAL_VPS => self.vps = Some(data[nal_start..end].to_vec()),
NAL_SPS => self.sps = Some(data[nal_start..end].to_vec()),
NAL_PPS => self.pps = Some(data[nal_start..end].to_vec()),
NAL_VPS => { self.vps = Some(data[nal_start..end].to_vec()); }
NAL_SPS => { self.sps = Some(data[nal_start..end].to_vec()); }
NAL_PPS => { self.pps = Some(data[nal_start..end].to_vec()); }
NAL_AUD => {} // Skip access unit delimiters
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
keyframe = true;
let nal = &data[nal_start..end];
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
frame_data.extend_from_slice(nal);
}
_ => {
// All other NAL types (slices, SEI, DV RPU, etc.) pass through
let nal = &data[nal_start..end];
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
frame_data.extend_from_slice(nal);
}
_ => {}
}
}
pos = next;
} else {
break;
}
}
// Convert Annex B to length-prefixed NALUs.
// Skip VPS/SPS/PPS/AUD — they're in codecPrivate.
let mut frame_data = Vec::new();
let mut pos = 0;
while let Some(sc_pos) = find_start_code(&pes.data, pos) {
if let Some(nal_start) = skip_start_code(&pes.data, sc_pos) {
let next = find_start_code(&pes.data, nal_start).unwrap_or(pes.data.len());
let mut end = next;
while end > nal_start && pes.data[end - 1] == 0x00 {
end -= 1;
}
if nal_start < pes.data.len() {
let nal_type = (pes.data[nal_start] >> 1) & 0x3F;
// Skip parameter sets and AUD
if nal_type != NAL_VPS
&& nal_type != NAL_SPS
&& nal_type != NAL_PPS
&& nal_type != NAL_AUD
{
let nal = &pes.data[nal_start..end];
let len = nal.len() as u32;
frame_data.extend_from_slice(&len.to_be_bytes());
frame_data.extend_from_slice(nal);
}
}
pos = next;
+4 -2
View File
@@ -31,9 +31,11 @@ pub struct Frame {
pub data: Vec<u8>,
}
/// Convert 90kHz PTS to nanoseconds.
/// Convert 90kHz PTS to nanoseconds (round to nearest).
pub fn pts_to_ns(pts: i64) -> i64 {
pts * 100_000 / 9
// pts * 1_000_000_000 / 90_000 = pts * 100_000 / 9
// Add half-divisor for rounding: (pts * 100_000 + 4) / 9
(pts * 100_000 + 4) / 9
}
/// Trait for codec-specific elementary stream parsers.
+2 -1
View File
@@ -172,7 +172,8 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> {
}
fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
(from..data.len().saturating_sub(2)).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
(from..data.len().saturating_sub(2))
.find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
}
#[cfg(test)]
+299 -430
View File
@@ -1,487 +1,356 @@
//! DiscStream — read BD-TS data from an optical disc drive.
//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
//!
//! Read-only stream. Wraps DriveSession + Disc.
//! Handles drive init, AACS decryption, and sector reading.
//! One stream type for all disc sources. The source is a SectorReader —
//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care.
//!
//! Reading state (extent index, offset, batch size, error recovery) is stored
//! directly on the struct so that successive `read()` calls advance through
//! the disc instead of restarting from byte 0.
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
use super::IOStream;
use crate::disc::{
detect_max_batch_sectors, ContentFormat, Disc, DiscTitle, Extent, MIN_BATCH_SECTORS,
RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER,
detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions,
};
use crate::drive::DriveSession;
use crate::error::Error;
use crate::speed::DriveSpeed;
use std::io::{self, Read, Write};
use std::path::Path;
use crate::sector::SectorReader;
use std::io;
/// AACS decryption parameters needed at read time.
/// Extracted from `AacsState` so we don't need `Clone` on the full struct.
struct AacsDecrypt {
unit_keys: Vec<(u32, [u8; 16])>,
read_data_key: Option<[u8; 16]>,
}
/// Options for opening a disc stream.
#[derive(Default)]
pub struct DiscOptions {
/// Device path (e.g. "/dev/sg4"). None = auto-detect.
pub device: Option<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>,
}
/// Optical disc stream. Read-only — yields decrypted BD-TS bytes.
/// Disc stream. Reads sectors from any source → PES frames.
///
/// Embeds the reading state that `ContentReader` would normally hold, so that
/// successive `read()` calls advance through the disc correctly.
/// Sources: physical drive, ISO file, or any SectorReader.
/// Decrypt, demux, and codec parsing happen internally.
pub struct DiscStream {
disc_title: DiscTitle,
disc: Disc,
session: DriveSession,
// Read buffer: holds one decoded batch
batch_buf: Vec<u8>,
batch_pos: usize,
eof: bool,
reader: Box<dyn SectorReader>,
title: DiscTitle,
disc: Option<Disc>,
decrypt_keys: crate::decrypt::DecryptKeys,
// ── Reading state (replaces ContentReader) ──
// Extents to read
extents: Vec<Extent>,
// Position
current_extent: usize,
current_offset: u32,
#[allow(dead_code)]
content_format: ContentFormat,
aacs: Option<AacsDecrypt>,
css: Option<crate::css::CssState>,
unit_key_idx: usize,
// Buffer
read_buf: Vec<u8>,
/// Current batch size in sectors (adapts on errors)
buf_valid: usize,
// Batch size for reads
batch_sectors: u16,
/// Maximum batch size detected from kernel limits
max_batch_sectors: u16,
/// Consecutive successful batch reads
ok_streak: u32,
/// Consecutive errors at current position
error_streak: u32,
/// Total read errors encountered
pub errors: u32,
pub errors: u64,
eof: bool,
// PES output
ts_demuxer: Option<super::ts::TsDemuxer>,
ps_demuxer: Option<super::ps::PsDemuxer>,
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
pid_to_track: Vec<(u16, usize)>,
}
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(),
})?,
/// Open from a physical drive. Caller must have already called
/// drive.wait_ready(), drive.init(), drive.probe_disc().
/// Drive is moved into the stream — caller manages lock/unlock before/after.
pub fn open_drive(
drive: crate::drive::Drive,
keydb_path: Option<&str>,
title_index: usize,
) -> crate::error::Result<(Self, Disc)> {
let scan_opts = match keydb_path {
Some(kp) => ScanOptions::with_keydb(kp),
None => ScanOptions::default(),
};
let mut drive = drive;
let disc = Disc::scan(&mut drive, &scan_opts)?;
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 {
return Err(crate::error::Error::DiscTitleRange {
index: title_index,
count: disc.titles.len(),
});
}
let disc_title = disc.titles[title_index].clone();
let extents = disc_title.extents.clone();
let content_format = disc_title.content_format;
let aacs = disc.aacs.as_ref().map(|a| AacsDecrypt {
unit_keys: a.unit_keys.clone(),
read_data_key: a.read_data_key,
});
let css = disc.css.clone();
let max_batch = detect_max_batch_sectors(session.device_path());
let title = disc.titles[title_index].clone();
let keys = disc.decrypt_keys();
let max_batch = detect_max_batch_sectors(drive.device_path());
let content_format = disc.content_format;
Ok(Self {
disc_title,
disc,
session,
batch_buf: Vec::new(),
batch_pos: 0,
eof: false,
let mut stream = Self::from_reader(Box::new(drive), title, keys, max_batch);
// Set demuxer based on content format
match content_format {
crate::disc::ContentFormat::MpegPs => {
stream.ps_demuxer = Some(super::ps::PsDemuxer::new());
}
crate::disc::ContentFormat::BdTs => {
let pids: Vec<u16> = stream.pid_to_track.iter().map(|(pid, _)| *pid).collect();
if !pids.is_empty() {
stream.ts_demuxer = Some(super::ts::TsDemuxer::new(&pids));
}
}
}
Ok((stream, disc))
}
/// Open from an ISO file.
pub fn open_iso(
path: &str,
title_index: Option<usize>,
opts: &ScanOptions,
) -> io::Result<Self> {
let mut reader = super::iso::IsoSectorReader::open(path)?;
let capacity = reader.capacity();
let disc = Disc::scan_image(&mut reader, capacity, opts)
.map_err(|e| -> io::Error { e.into() })?;
if disc.titles.is_empty() {
return Err(crate::error::Error::NoStreams.into());
}
let idx = title_index.unwrap_or(0);
if idx >= disc.titles.len() {
return Err(crate::error::Error::DiscTitleRange {
index: idx,
count: disc.titles.len(),
}.into());
}
let title = disc.titles[idx].clone();
let keys = disc.decrypt_keys();
let batch: u16 = 64;
let mut stream = Self::from_reader(Box::new(reader), title, keys, batch);
// Set demuxer based on content format
match disc.content_format {
crate::disc::ContentFormat::MpegPs => {
stream.ps_demuxer = Some(super::ps::PsDemuxer::new());
}
crate::disc::ContentFormat::BdTs => {
let pids: Vec<u16> = stream.pid_to_track.iter().map(|(pid, _)| *pid).collect();
if !pids.is_empty() {
stream.ts_demuxer = Some(super::ts::TsDemuxer::new(&pids));
}
}
}
stream.disc = Some(disc);
Ok(stream)
}
/// Create from any SectorReader + title + keys.
pub fn from_reader(
reader: Box<dyn SectorReader>,
title: DiscTitle,
decrypt_keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
) -> Self {
let extents = title.extents.clone();
let mut pids = Vec::new();
let mut parsers = Vec::new();
let mut pid_to_track = Vec::new();
for (idx, s) in title.streams.iter().enumerate() {
let (pid, codec) = match s {
crate::disc::Stream::Video(v) => (v.pid, v.codec),
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
};
pids.push(pid);
pid_to_track.push((pid, idx));
parsers.push((pid, super::codec::parser_for_codec(codec)));
}
Self {
reader,
title,
disc: None,
decrypt_keys,
extents,
current_extent: 0,
current_offset: 0,
content_format,
aacs,
css,
unit_key_idx: 0,
read_buf: Vec::with_capacity(max_batch as usize * 2048),
batch_sectors: max_batch,
max_batch_sectors: max_batch,
ok_streak: 0,
error_streak: 0,
read_buf: Vec::with_capacity(batch_sectors as usize * 2048),
buf_valid: 0,
batch_sectors,
errors: 0,
})
}
/// Get the full Disc (for listing all titles, etc.)
pub fn disc(&self) -> &Disc {
&self.disc
}
/// Read sectors from the drive into `self.read_buf`.
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<(), Error> {
self.session.read_content(lba, count, &mut self.read_buf)?;
Ok(())
}
/// Fill the internal read buffer with the next batch of sectors,
/// handling error recovery (halve batch, slow drive, retry, skip).
///
/// Returns `true` if data was read, `false` at end-of-title.
fn fill_buffer(&mut self) -> Result<bool, Error> {
loop {
if self.current_extent >= self.extents.len() {
return Ok(false);
}
let ext_start = self.extents[self.current_extent].start_lba;
let ext_sectors = self.extents[self.current_extent].sector_count;
let remaining = ext_sectors.saturating_sub(self.current_offset);
// Align to 3 sectors (one aligned unit)
let sectors_to_read = remaining.min(self.batch_sectors as u32) as u16;
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
if sectors_to_read == 0 {
self.current_extent += 1;
self.current_offset = 0;
continue;
}
let lba = ext_start + self.current_offset;
let byte_count = sectors_to_read as usize * 2048;
self.read_buf.resize(byte_count, 0);
match self.read_sectors(lba, sectors_to_read) {
Ok(_) => {
self.current_offset += sectors_to_read as u32;
self.error_streak = 0;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
// Ramp up batch size after consecutive successes
self.ok_streak += 1;
if self.batch_sectors < self.max_batch_sectors
&& self.ok_streak >= RAMP_BATCH_AFTER
{
self.batch_sectors = (self.batch_sectors * 2).min(self.max_batch_sectors);
self.ok_streak = 0;
}
// Restore max speed after sustained success at full batch
if self.batch_sectors == self.max_batch_sectors
&& self.ok_streak >= RAMP_SPEED_AFTER
{
self.session.set_speed(0xFFFF);
self.ok_streak = 0;
}
return Ok(true);
}
Err(_) => {
self.errors += 1;
self.error_streak += 1;
self.ok_streak = 0;
// First error: re-init (drive may have re-locked)
if self.error_streak == 1 {
let _ = self.session.init();
let _ = self.session.probe_disc();
}
// Repeated errors: slow down
if self.error_streak >= SLOW_SPEED_AFTER {
self.session.set_speed(DriveSpeed::BD2x.to_kbps());
self.error_streak = 0;
}
if self.batch_sectors > MIN_BATCH_SECTORS {
self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
std::thread::sleep(std::time::Duration::from_millis(100));
} else {
// At minimum batch -- retry once with longer pause
std::thread::sleep(std::time::Duration::from_millis(500));
self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() {
self.error_streak = 0;
self.current_offset += MIN_BATCH_SECTORS as u32;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
return Ok(true);
}
// Still failing -- skip this unit (zero-fill)
self.current_offset += 3;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
self.read_buf.resize(crate::aacs::ALIGNED_UNIT_LEN, 0);
self.read_buf.fill(0);
return Ok(true);
}
}
}
eof: false,
// Demuxer set by caller — open_drive() checks content_format,
// open_iso() always uses TS (Blu-ray ISO).
ts_demuxer: None,
ps_demuxer: None,
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
}
}
/// Decrypt the contents of `self.read_buf` in-place and copy the
/// decrypted data into `self.batch_buf`.
fn decrypt_and_buffer(&mut self) {
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
let total_bytes = self.read_buf.len();
/// Skip decryption — return raw encrypted bytes.
pub fn set_raw(&mut self) {
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
}
if let Some(ref aacs) = self.aacs {
let uk = aacs
.unit_keys
.get(self.unit_key_idx)
.map(|(_, k)| *k)
.unwrap_or([0u8; 16]);
let rdk = aacs.read_data_key.as_ref();
/// Get the scanned Disc (for listing all titles).
pub fn disc(&self) -> Option<&Disc> {
self.disc.as_ref()
}
let num_units = total_bytes / unit_len;
for i in 0..num_units {
let start = i * unit_len;
let end = start + unit_len;
let unit = &mut self.read_buf[start..end];
if crate::aacs::is_unit_encrypted(unit) {
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
}
}
} else if let Some(ref css) = self.css {
for chunk in self.read_buf[..total_bytes].chunks_mut(2048) {
crate::css::lfsr::descramble_sector(&css.title_key, chunk);
}
fn fill_extents(&mut self) -> bool {
if self.current_extent >= self.extents.len() {
return false;
}
// No encryption: read_buf is already plaintext
let ext_start = self.extents[self.current_extent].start_lba;
let ext_sectors = self.extents[self.current_extent].sector_count;
// Swap buffers instead of copying — the old batch_buf becomes
// read_buf and will be overwritten on the next read.
std::mem::swap(&mut self.batch_buf, &mut self.read_buf);
self.batch_pos = 0;
let remaining = ext_sectors.saturating_sub(self.current_offset);
let sectors = remaining.min(self.batch_sectors as u32) as u16;
let sectors = sectors - (sectors % 3);
if sectors == 0 {
self.current_extent += 1;
self.current_offset = 0;
return self.fill_extents();
}
let lba = ext_start + self.current_offset;
let bytes = sectors as usize * 2048;
self.read_buf.resize(bytes, 0);
match self.reader.read_sectors(lba, sectors, &mut self.read_buf[..bytes]) {
Ok(_) => {
self.buf_valid = bytes;
self.current_offset += sectors as u32;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
true
}
Err(_) => false,
}
}
}
impl IOStream for DiscStream {
fn info(&self) -> &DiscTitle {
&self.disc_title
}
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn total_bytes(&self) -> Option<u64> {
Some(self.disc_title.size_bytes)
}
}
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);
impl crate::pes::Stream for DiscStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
if self.eof {
return Ok(0);
return Ok(None);
}
// Fill the read buffer with the next batch of sectors
let has_data = self
.fill_buffer()
.map_err(|e| io::Error::other(e.to_string()))?;
loop {
if !self.fill_extents() {
self.eof = true;
// Flush demuxer — last PES packet may still be in the assembler
if let Some(ref mut demuxer) = self.ts_demuxer {
for pes in &demuxer.flush() {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame)
);
}
}
}
}
}
// PS demuxer flush (DVD)
if let Some(ref mut demuxer) = self.ps_demuxer {
for ps in &demuxer.flush() {
let track = match ps.stream_id {
0xE0..=0xEF => 0,
0xC0..=0xDF => 1,
0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1),
_ => continue,
};
if track < self.title.streams.len() {
let pts_ns = ps.pts.map(|p| (p as i64) * 1_000_000_000 / 90_000).unwrap_or(0);
self.pending_frames.push_back(crate::pes::PesFrame {
track,
pts: pts_ns,
keyframe: true,
data: ps.data.clone(),
});
}
}
}
return Ok(self.pending_frames.pop_front());
}
if !has_data {
self.eof = true;
return Ok(0);
let bytes = self.buf_valid;
if let Err(e) = crate::decrypt::decrypt_sectors(
&mut self.read_buf[..bytes],
&self.decrypt_keys,
0,
) {
return Err(e.into());
}
if let Some(ref mut demuxer) = self.ts_demuxer {
let packets = demuxer.feed(&self.read_buf[..bytes]);
for pes in &packets {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame)
);
}
}
}
}
} else if let Some(ref mut demuxer) = self.ps_demuxer {
let packets = demuxer.feed(&self.read_buf[..bytes]);
for ps in &packets {
let track = match ps.stream_id {
0xE0..=0xEF => 0,
0xC0..=0xDF => 1,
0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1),
_ => continue,
};
if track < self.title.streams.len() {
let pts_ns = ps.pts.map(|p| (p as i64) * 1_000_000_000 / 90_000).unwrap_or(0);
self.pending_frames.push_back(crate::pes::PesFrame {
track,
pts: pts_ns,
keyframe: true,
data: ps.data.clone(),
});
}
}
}
self.buf_valid = 0;
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
}
}
// Decrypt in-place and move to batch_buf
self.decrypt_and_buffer();
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(crate::error::Error::StreamReadOnly.into())
}
// Now drain into the caller's buffer
let n = self.batch_buf.len().min(buf.len());
buf[..n].copy_from_slice(&self.batch_buf[..n]);
self.batch_pos = n;
Ok(n)
}
}
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(())
}
}
#[cfg(test)]
mod tests {
use crate::disc::Extent;
/// Build a minimal DiscStream with fake extents for testing state advancement.
/// We cannot call `DiscStream::open()` without a real drive, so we construct
/// one manually and then call the internal `fill_buffer` / `read` path
/// through a helper that simulates the session reads.
///
/// Instead we test the state-machine logic directly: given a set of extents
/// and a current_extent/current_offset, verify that repeated reads advance
/// through the extents correctly.
#[test]
fn state_advances_across_extents() {
// Simulate two extents of 6 sectors each (2 aligned units each).
let extents = [
Extent {
start_lba: 100,
sector_count: 6,
},
Extent {
start_lba: 200,
sector_count: 6,
},
];
// Walk through the extents manually using the same arithmetic
// that fill_buffer uses, and verify we visit every sector.
let batch_sectors: u16 = 6;
let mut current_extent: usize = 0;
let mut current_offset: u32 = 0;
let mut lbas_read = Vec::new();
while current_extent < extents.len() {
let ext_start = extents[current_extent].start_lba;
let ext_sectors = extents[current_extent].sector_count;
let remaining = ext_sectors.saturating_sub(current_offset);
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
if sectors_to_read == 0 {
current_extent += 1;
current_offset = 0;
continue;
}
let lba = ext_start + current_offset;
lbas_read.push((lba, sectors_to_read));
current_offset += sectors_to_read as u32;
if current_offset >= ext_sectors {
current_extent += 1;
current_offset = 0;
}
}
fn finish(&mut self) -> io::Result<()> { Ok(()) }
assert_eq!(lbas_read.len(), 2, "should read two batches");
assert_eq!(lbas_read[0], (100, 6), "first batch starts at LBA 100");
assert_eq!(lbas_read[1], (200, 6), "second batch starts at LBA 200");
}
/// Verify that small extents that are not aligned to 3 sectors are skipped
/// (moved past) rather than causing an infinite loop.
#[test]
fn unaligned_extent_is_skipped() {
let extents = [
Extent {
start_lba: 50,
sector_count: 2, // < 3, cannot form an aligned unit
},
Extent {
start_lba: 300,
sector_count: 9,
},
];
let batch_sectors: u16 = 9;
let mut current_extent: usize = 0;
let mut current_offset: u32 = 0;
let mut lbas_read = Vec::new();
fn info(&self) -> &DiscTitle { &self.title }
while current_extent < extents.len() {
let ext_start = extents[current_extent].start_lba;
let ext_sectors = extents[current_extent].sector_count;
let remaining = ext_sectors.saturating_sub(current_offset);
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
if sectors_to_read == 0 {
current_extent += 1;
current_offset = 0;
continue;
}
let lba = ext_start + current_offset;
lbas_read.push((lba, sectors_to_read));
current_offset += sectors_to_read as u32;
if current_offset >= ext_sectors {
current_extent += 1;
current_offset = 0;
}
}
assert_eq!(lbas_read.len(), 1, "only second extent is readable");
assert_eq!(lbas_read[0], (300, 9));
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self.pid_to_track.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
/// Verify that multiple reads from the same extent produce advancing offsets.
#[test]
fn multiple_batches_within_one_extent() {
let extents = [Extent {
start_lba: 1000,
sector_count: 18, // 6 aligned units = 3 batches of 6 sectors
}];
let batch_sectors: u16 = 6;
let mut current_extent: usize = 0;
let mut current_offset: u32 = 0;
let mut lbas_read = Vec::new();
while current_extent < extents.len() {
let ext_start = extents[current_extent].start_lba;
let ext_sectors = extents[current_extent].sector_count;
let remaining = ext_sectors.saturating_sub(current_offset);
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
if sectors_to_read == 0 {
current_extent += 1;
current_offset = 0;
continue;
fn headers_ready(&self) -> bool {
for (idx, s) in self.title.streams.iter().enumerate() {
if let crate::disc::Stream::Video(v) = s {
if !v.secondary && self.codec_private(idx).is_none() {
return false;
}
}
let lba = ext_start + current_offset;
lbas_read.push((lba, sectors_to_read));
current_offset += sectors_to_read as u32;
if current_offset >= ext_sectors {
current_extent += 1;
current_offset = 0;
}
}
assert_eq!(lbas_read.len(), 3, "three batches from one extent");
assert_eq!(lbas_read[0], (1000, 6));
assert_eq!(lbas_read[1], (1006, 6));
assert_eq!(lbas_read[2], (1012, 6));
true
}
}
+16 -22
View File
@@ -171,10 +171,7 @@ pub fn read_id(r: &mut impl Read) -> io::Result<(u32, usize)> {
4,
))
} else {
Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid EBML ID",
))
Err(crate::error::Error::MkvInvalid.into())
}
}
@@ -203,7 +200,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
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 {
if val == 0x1F_FFFF {
return Ok((u64::MAX, 3));
}
Ok((val, 3))
@@ -212,7 +209,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
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 {
if val == 0x0FFF_FFFF {
return Ok((u64::MAX, 4));
}
Ok((val, 4))
@@ -256,7 +253,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
| (b[4] as u64) << 16
| (b[5] as u64) << 8
| b[6] as u64;
if val == 0x00FFFFFFFFFFFFFF {
if val == 0x00FF_FFFF_FFFF_FFFF {
return Ok((u64::MAX, 8));
}
Ok((val, 8))
@@ -325,10 +322,7 @@ pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
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",
))
Err(crate::error::Error::MkvInvalid.into())
}
// ============================================================
@@ -336,7 +330,7 @@ pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
// ============================================================
// EBML Header
pub const EBML: u32 = 0x1A45DFA3;
pub const EBML: u32 = 0x1A45_DFA3;
pub const EBML_VERSION: u32 = 0x4286;
pub const EBML_READ_VERSION: u32 = 0x42F7;
pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2;
@@ -346,24 +340,24 @@ pub const EBML_DOC_TYPE_VERSION: u32 = 0x4287;
pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
// Segment
pub const SEGMENT: u32 = 0x18538067;
pub const SEGMENT: u32 = 0x1853_8067;
// Seek Head
pub const SEEK_HEAD: u32 = 0x114D9B74;
pub const SEEK_HEAD: u32 = 0x114D_9B74;
pub const SEEK: u32 = 0x4DBB;
pub const SEEK_ID: u32 = 0x53AB;
pub const SEEK_POSITION: u32 = 0x53AC;
// Segment Info
pub const INFO: u32 = 0x1549A966;
pub const TIMESTAMP_SCALE: u32 = 0x2AD7B1;
pub const INFO: u32 = 0x1549_A966;
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
pub const DURATION: u32 = 0x4489;
pub const MUXING_APP: u32 = 0x4D80;
pub const WRITING_APP: u32 = 0x5741;
pub const TITLE: u32 = 0x7BA9;
// Tracks
pub const TRACKS: u32 = 0x1654AE6B;
pub const TRACKS: u32 = 0x1654_AE6B;
pub const TRACK_ENTRY: u32 = 0xAE;
pub const TRACK_NUMBER: u32 = 0xD7;
pub const TRACK_UID: u32 = 0x73C5;
@@ -371,11 +365,11 @@ pub const TRACK_TYPE: u32 = 0x83;
pub const FLAG_LACING: u32 = 0x9C;
pub const FLAG_DEFAULT: u32 = 0x88;
pub const FLAG_FORCED: u32 = 0x55AA;
pub const LANGUAGE: u32 = 0x22B59C;
pub const LANGUAGE: u32 = 0x22_B59C;
pub const CODEC_ID: u32 = 0x86;
pub const CODEC_PRIVATE: u32 = 0x63A2;
pub const TRACK_NAME: u32 = 0x536E;
pub const DEFAULT_DURATION: u32 = 0x23E383;
pub const DEFAULT_DURATION: u32 = 0x23_E383;
// Video
pub const VIDEO: u32 = 0xE0;
@@ -396,12 +390,12 @@ pub const CHANNELS: u32 = 0x9F;
pub const BIT_DEPTH: u32 = 0x6264;
// Cluster
pub const CLUSTER: u32 = 0x1F43B675;
pub const CLUSTER: u32 = 0x1F43_B675;
pub const CLUSTER_TIMESTAMP: u32 = 0xE7;
pub const SIMPLE_BLOCK: u32 = 0xA3;
// Cues
pub const CUES: u32 = 0x1C53BB6B;
pub const CUES: u32 = 0x1C53_BB6B;
pub const CUE_POINT: u32 = 0xBB;
pub const CUE_TIME: u32 = 0xB3;
pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
@@ -409,7 +403,7 @@ pub const CUE_TRACK: u32 = 0xF7;
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
// Chapters
pub const CHAPTERS: u32 = 0x1043A770;
pub const CHAPTERS: u32 = 0x1043_A770;
pub const EDITION_ENTRY: u32 = 0x45B9;
pub const CHAPTER_ATOM: u32 = 0xB6;
pub const CHAPTER_UID: u32 = 0x73C4;
+13 -273
View File
@@ -1,26 +1,16 @@
//! IsoStream — read/write Blu-ray ISO disc images.
//! ISO sector reader — file-backed SectorReader for Blu-ray ISO 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 UDF 2.50 filesystem containing the m2ts stream data.
//! The resulting ISO can be mounted or read back via IsoStream.
//! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048.
//! Used by DiscStream::open_iso() and Disc::scan_image().
use super::isowriter::IsoWriter;
use super::IOStream;
use crate::disc::{Disc, DiscTitle, ScanOptions};
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
const SECTOR_SIZE: u64 = 2048;
/// Maximum sectors to batch-read at once (64 sectors = 128 KB).
const BATCH_SECTORS: usize = 64;
/// File-backed sector reader for ISO images.
pub struct IsoSectorReader {
file: File,
@@ -28,11 +18,17 @@ pub struct IsoSectorReader {
}
impl IsoSectorReader {
pub fn open(path: &str) -> io::Result<Self> {
pub fn open(path: &str) -> std::io::Result<Self> {
let file = File::open(Path::new(path))
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
.map_err(|e| std::io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
let size = file.metadata()?.len();
let capacity = (size / SECTOR_SIZE) as u32;
let sectors = size / SECTOR_SIZE;
if sectors > u32::MAX as u64 {
return Err(crate::error::Error::IsoTooLarge {
path: path.to_string(),
}.into());
}
let capacity = sectors as u32;
Ok(Self { file, capacity })
}
@@ -54,225 +50,6 @@ impl SectorReader for IsoSectorReader {
}
}
/// Blu-ray ISO image stream.
///
/// Read: opens ISO, parses UDF (same as DiscStream), streams BD-TS content.
/// Write: creates UDF 2.50 ISO with BDMV/STREAM/*.m2ts.
pub struct IsoStream {
disc_title: DiscTitle,
disc: Option<Disc>,
// Read side
reader: Option<IsoSectorReader>,
extents: Vec<(u32, u32)>,
extent_idx: usize,
sectors_remaining: u32,
/// Batch buffer: holds up to BATCH_SECTORS sectors (128 KB) at once.
batch_buf: Vec<u8>,
buf_pos: usize,
buf_len: usize,
eof: bool,
// Write side
iso_writer: Option<IsoWriter<io::BufWriter<File>>>,
write_started: 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::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),
extents,
extent_idx: 0,
sectors_remaining,
batch_buf: vec![0u8; BATCH_SECTORS * SECTOR_SIZE as usize],
buf_pos: 0,
buf_len: 0,
eof: false,
iso_writer: None,
write_started: false,
})
}
/// Create an ISO file for writing.
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 buf_writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
let iso_writer = IsoWriter::new(buf_writer, "FREEMKV", "00001.m2ts");
Ok(IsoStream {
disc_title: DiscTitle::empty(),
disc: None,
reader: None,
extents: Vec::new(),
extent_idx: 0,
sectors_remaining: 0,
batch_buf: Vec::new(),
buf_pos: 0,
buf_len: 0,
eof: false,
iso_writer: Some(iso_writer),
write_started: false,
})
}
/// Set metadata (for write mode). Must be called before writing data.
pub fn meta(mut self, dt: &DiscTitle) -> Self {
self.disc_title = dt.clone();
// Update the ISO writer's volume ID and m2ts filename from title metadata
if let Some(writer) = self.iso_writer.take() {
let vol_id = if dt.playlist.is_empty() {
"FREEMKV".to_string()
} else {
dt.playlist
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == ' ')
.collect::<String>()
};
let m2ts_name = format!("{:05}.m2ts", dt.playlist_id.max(1));
self.iso_writer = Some(writer.with_names(&vol_id, &m2ts_name));
}
self
}
/// Get the full Disc (for listing all titles).
pub fn disc(&self) -> Option<&Disc> {
self.disc.as_ref()
}
/// Read up to BATCH_SECTORS sectors at once into the batch buffer.
fn read_next_batch(&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;
// Read up to BATCH_SECTORS, but no more than remaining in this extent
let count = (self.sectors_remaining as usize).min(BATCH_SECTORS) as u16;
reader
.read_sectors(lba, count, &mut self.batch_buf)
.map_err(|e| io::Error::other(e.to_string()))?;
self.buf_pos = 0;
self.buf_len = count as usize * SECTOR_SIZE as usize;
self.sectors_remaining -= count as u32;
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.iso_writer {
w.finish()?;
}
Ok(())
}
fn total_bytes(&self) -> Option<u64> {
// Read mode: size is known from disc scan
if self.reader.is_some() {
Some(self.disc_title.size_bytes)
} else {
None
}
}
}
impl Read for IsoStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.eof {
return Ok(0);
}
if self.buf_pos < self.buf_len {
let n = (self.buf_len - self.buf_pos).min(buf.len());
buf[..n].copy_from_slice(&self.batch_buf[self.buf_pos..self.buf_pos + n]);
self.buf_pos += n;
return Ok(n);
}
if self.read_next_batch()? {
let n = self.buf_len.min(buf.len());
buf[..n].copy_from_slice(&self.batch_buf[..n]);
self.buf_pos = n;
Ok(n)
} else {
self.eof = true;
Ok(0)
}
}
}
impl Write for IsoStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let w = match self.iso_writer.as_mut() {
Some(w) => w,
None => {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"iso:// opened for reading — cannot write",
))
}
};
if !self.write_started {
w.start()?;
self.write_started = true;
}
w.write_data(buf)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -315,41 +92,4 @@ mod tests {
std::fs::remove_file(&dir).ok();
}
#[test]
fn iso_write_creates_valid_udf() {
let path = std::env::temp_dir().join("freemkv_test_iso_write.iso");
let mut stream = IsoStream::create(path.to_str().unwrap()).unwrap();
// Write some fake BD-TS content
let mut content = Vec::new();
for i in 0..100u8 {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
pkt[5] = i;
content.extend_from_slice(&pkt);
}
stream.write_all(&content).unwrap();
stream.finish().unwrap();
// Verify the ISO has valid UDF structure
let file = File::open(&path).unwrap();
let size = file.metadata().unwrap().len();
assert!(size > 288 * SECTOR_SIZE); // at least header + some data
// Read back and verify AVDP at sector 256
let mut reader = IsoSectorReader::open(path.to_str().unwrap()).unwrap();
let mut avdp = [0u8; 2048];
reader.read_sectors(256, 1, &mut avdp).unwrap();
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
assert_eq!(tag_id, 2, "AVDP tag should be 2");
// Verify VRS at sector 16
let mut vrs = [0u8; 2048];
reader.read_sectors(16, 1, &mut vrs).unwrap();
assert_eq!(&vrs[1..6], b"BEA01");
std::fs::remove_file(&path).ok();
}
}
-678
View File
@@ -1,678 +0,0 @@
//! UDF ISO writer — creates Blu-ray disc images.
//!
//! Writes a minimal UDF 2.50 filesystem containing BDMV/STREAM/*.m2ts.
//! The ISO can be mounted or read back via IsoStream.
//!
//! Layout:
//! Sector 0-15: System area (zeros)
//! Sector 16-18: Volume Recognition Sequence (BEA01, NSR03, TEA01)
//! Sector 32-37: Volume Descriptor Sequence
//! Sector 256: Anchor Volume Descriptor Pointer
//! Sector 260-271: Metadata partition (FSD, ICBs, directories)
//! Sector 288+: File data (m2ts content)
//! Last-256: Reserve AVDP
use std::io::{self, Seek, SeekFrom, Write};
const SECTOR_SIZE: u64 = 2048;
// Layout constants
const VRS_START: u32 = 16; // Volume Recognition Sequence
const VDS_START: u32 = 32; // Volume Descriptor Sequence
const AVDP_SECTOR: u32 = 256; // Anchor Volume Descriptor Pointer
const PARTITION_START: u32 = 257; // Physical partition start
const METADATA_START: u32 = 260; // Metadata partition content
const FSD_SECTOR: u32 = 260; // File Set Descriptor
const ROOT_ICB_SECTOR: u32 = 261; // Root directory ICB
const ROOT_DIR_SECTOR: u32 = 262; // Root directory data
const BDMV_ICB_SECTOR: u32 = 263; // BDMV/ ICB
const BDMV_DIR_SECTOR: u32 = 264; // BDMV/ directory data
const STREAM_ICB_SECTOR: u32 = 265; // BDMV/STREAM/ ICB
const STREAM_DIR_SECTOR: u32 = 266; // BDMV/STREAM/ directory data
const M2TS_ICB_SECTOR: u32 = 267; // m2ts file ICB
const DATA_START: u32 = 288; // Start of file data (aligned)
/// Write a complete BD ISO image.
///
/// Writes UDF structure, then streams m2ts content from the writer.
/// Call `start()` first, then write BD-TS bytes, then call `finish()`.
pub struct IsoWriter<W: Write + Seek> {
writer: W,
volume_id: String,
m2ts_name: String,
data_start_sector: u32,
bytes_written: u64,
}
impl<W: Write + Seek> IsoWriter<W> {
/// Create a new ISO writer. Call `start()` to write the UDF header.
pub fn new(writer: W, volume_id: &str, m2ts_name: &str) -> Self {
Self {
writer,
volume_id: volume_id.to_string(),
m2ts_name: m2ts_name.to_string(),
data_start_sector: DATA_START,
bytes_written: 0,
}
}
/// Update volume ID and m2ts filename. Must be called before `start()`.
pub fn with_names(mut self, volume_id: &str, m2ts_name: &str) -> Self {
self.volume_id = volume_id.to_string();
self.m2ts_name = m2ts_name.to_string();
self
}
/// Write UDF filesystem header. After this, write m2ts content bytes.
pub fn start(&mut self) -> io::Result<()> {
// System area: sectors 0-15 (zeros)
let zero_sector = [0u8; SECTOR_SIZE as usize];
for _ in 0..VRS_START {
self.writer.write_all(&zero_sector)?;
}
// Volume Recognition Sequence
self.write_vrs()?;
// Pad sectors 19-31
for _ in 19..VDS_START {
self.writer.write_all(&zero_sector)?;
}
// Volume Descriptor Sequence (sectors 32-37)
self.write_vds()?;
// Pad sectors 38-255
for _ in 38..AVDP_SECTOR {
self.writer.write_all(&zero_sector)?;
}
// AVDP at sector 256
self.write_avdp()?;
// Partition area: metadata file ICB at partition_start
self.write_metadata_file_icb()?;
// Pad to metadata start
for _ in (PARTITION_START + 1)..METADATA_START {
self.writer.write_all(&zero_sector)?;
}
// Metadata partition
self.write_fsd()?;
self.write_root_icb()?;
self.write_root_dir()?;
self.write_bdmv_icb()?;
self.write_bdmv_dir()?;
self.write_stream_icb()?;
self.write_stream_dir()?;
self.write_m2ts_icb(0)?; // placeholder size, updated in finish()
// Pad to data start
for _ in (M2TS_ICB_SECTOR + 1)..self.data_start_sector {
self.writer.write_all(&zero_sector)?;
}
Ok(())
}
/// Write m2ts content bytes. Call after `start()`.
pub fn write_data(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.writer.write(buf)?;
self.bytes_written += n as u64;
Ok(n)
}
/// Finalize the ISO: pad to sector boundary, update file sizes, write reserve AVDP.
pub fn finish(&mut self) -> io::Result<()> {
// Pad to sector boundary
let remainder = (self.bytes_written % SECTOR_SIZE) as usize;
if remainder > 0 {
let pad = SECTOR_SIZE as usize - remainder;
let zeros = vec![0u8; pad];
self.writer.write_all(&zeros)?;
self.bytes_written += pad as u64;
}
let total_data_sectors = (self.bytes_written / SECTOR_SIZE) as u32;
let total_sectors = self.data_start_sector + total_data_sectors;
// Seek back and update m2ts file ICB with actual size
self.writer
.seek(SeekFrom::Start(M2TS_ICB_SECTOR as u64 * SECTOR_SIZE))?;
self.write_m2ts_icb(self.bytes_written)?;
// Seek to end and write reserve AVDP
let reserve_sector = if total_sectors > 512 {
total_sectors - 256
} else {
total_sectors.saturating_sub(1).max(AVDP_SECTOR + 1)
};
self.writer
.seek(SeekFrom::Start(reserve_sector as u64 * SECTOR_SIZE))?;
self.write_avdp()?;
self.writer.flush()?;
Ok(())
}
// ── UDF structure writers ──────────────────────────────────────────────
fn write_vrs(&mut self) -> io::Result<()> {
// BEA01 at sector 16
let mut bea = [0u8; SECTOR_SIZE as usize];
bea[0] = 0; // structure type
bea[1..6].copy_from_slice(b"BEA01");
bea[6] = 1; // structure version
self.writer.write_all(&bea)?;
// NSR03 at sector 17 (UDF 2.50)
let mut nsr = [0u8; SECTOR_SIZE as usize];
nsr[0] = 0;
nsr[1..6].copy_from_slice(b"NSR03");
nsr[6] = 1;
self.writer.write_all(&nsr)?;
// TEA01 at sector 18
let mut tea = [0u8; SECTOR_SIZE as usize];
tea[0] = 0;
tea[1..6].copy_from_slice(b"TEA01");
tea[6] = 1;
self.writer.write_all(&tea)?;
Ok(())
}
fn write_vds(&mut self) -> io::Result<()> {
// Primary Volume Descriptor (tag 1) at sector 32
let mut pvd = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut pvd, 1, VDS_START);
// Volume Identifier at offset 24 (32-byte d-string)
write_dstring(&mut pvd[24..56], &self.volume_id);
self.writer.write_all(&pvd)?;
// Partition Descriptor (tag 5) at sector 33
let mut pd = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut pd, 5, VDS_START + 1);
// Partition starting location at offset 188
pd[188..192].copy_from_slice(&PARTITION_START.to_le_bytes());
// Partition length (large enough for everything)
let part_len: u32 = 0xFFFFFFFF;
pd[192..196].copy_from_slice(&part_len.to_le_bytes());
self.writer.write_all(&pd)?;
// Logical Volume Descriptor (tag 6) at sector 34
let mut lvd = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut lvd, 6, VDS_START + 2);
// Logical block size at offset 212
lvd[212..216].copy_from_slice(&2048u32.to_le_bytes());
// Number of partition maps at offset 268
lvd[268..272].copy_from_slice(&2u32.to_le_bytes());
// Partition map 1: Type 1 (physical), 6 bytes
lvd[440] = 1; // type
lvd[441] = 6; // length
// Partition map 2: Type 2 (metadata), 64 bytes
lvd[446] = 2; // type
lvd[447] = 64; // length
// Entity ID for metadata partition
lvd[450..473].copy_from_slice(b"*UDF Metadata Partition");
self.writer.write_all(&lvd)?;
// Unallocated Space Descriptor (tag 7) at sector 35
let mut usd = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut usd, 7, VDS_START + 3);
self.writer.write_all(&usd)?;
// Implementation Use Volume Descriptor (tag 4) at sector 36
let mut iuvd = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut iuvd, 4, VDS_START + 4);
self.writer.write_all(&iuvd)?;
// Terminating Descriptor (tag 8) at sector 37
let mut td = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut td, 8, VDS_START + 5);
self.writer.write_all(&td)?;
Ok(())
}
fn write_avdp(&mut self) -> io::Result<()> {
let mut avdp = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut avdp, 2, AVDP_SECTOR);
// Main VDS extent_ad: {length, location} per UDF spec
avdp[16..20].copy_from_slice(&(6u32 * 2048).to_le_bytes()); // length
avdp[20..24].copy_from_slice(&VDS_START.to_le_bytes()); // location
// Reserve VDS extent_ad (same as main for simplicity)
avdp[24..28].copy_from_slice(&(6u32 * 2048).to_le_bytes()); // length
avdp[28..32].copy_from_slice(&VDS_START.to_le_bytes()); // location
self.writer.write_all(&avdp)?;
Ok(())
}
fn write_metadata_file_icb(&mut self) -> io::Result<()> {
// Extended File Entry (tag 266) at partition_start
// Points to metadata content at METADATA_START
let mut icb = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut icb, 266, PARTITION_START);
// ICB tag at offset 16
icb[16..20].copy_from_slice(&0u32.to_le_bytes()); // prior recorded
icb[20..22].copy_from_slice(&0u16.to_le_bytes()); // strategy type
icb[22..24].copy_from_slice(&0u16.to_le_bytes()); // strategy parameter
// File type at offset 27: 250 = metadata file
icb[27] = 250;
// Information length at offset 56
let meta_len: u64 = 12 * SECTOR_SIZE; // 12 sectors of metadata
icb[56..64].copy_from_slice(&meta_len.to_le_bytes());
// Extended attribute length at offset 208
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
// Allocation descriptor at offset 216: short_ad (length + position)
let ad_len = meta_len as u32;
let ad_pos = METADATA_START - PARTITION_START; // relative to partition
icb[216..220].copy_from_slice(&ad_len.to_le_bytes());
icb[220..224].copy_from_slice(&ad_pos.to_le_bytes());
self.writer.write_all(&icb)?;
Ok(())
}
fn write_fsd(&mut self) -> io::Result<()> {
let mut fsd = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut fsd, 256, FSD_SECTOR);
// Root Directory ICB (long_ad at offset 400)
let root_lba = ROOT_ICB_SECTOR - METADATA_START; // metadata-relative
fsd[400..404].copy_from_slice(&SECTOR_SIZE.to_le_bytes()[..4]); // extent length
fsd[404..408].copy_from_slice(&root_lba.to_le_bytes());
self.writer.write_all(&fsd)?;
Ok(())
}
fn write_root_icb(&mut self) -> io::Result<()> {
let mut icb = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut icb, 266, ROOT_ICB_SECTOR);
icb[27] = 4; // file type: directory
let dir_len: u64 = SECTOR_SIZE;
icb[56..64].copy_from_slice(&dir_len.to_le_bytes());
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
let ad_pos = ROOT_DIR_SECTOR - METADATA_START;
icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
icb[220..224].copy_from_slice(&ad_pos.to_le_bytes());
self.writer.write_all(&icb)?;
Ok(())
}
fn write_root_dir(&mut self) -> io::Result<()> {
let mut dir = [0u8; SECTOR_SIZE as usize];
let mut offset = 0;
// Parent entry (.. points to self)
offset += write_fid(
&mut dir[offset..],
ROOT_ICB_SECTOR - METADATA_START,
"",
true,
);
// BDMV directory entry
offset += write_fid(
&mut dir[offset..],
BDMV_ICB_SECTOR - METADATA_START,
"BDMV",
false,
);
let _ = offset;
self.writer.write_all(&dir)?;
Ok(())
}
fn write_bdmv_icb(&mut self) -> io::Result<()> {
let mut icb = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut icb, 266, BDMV_ICB_SECTOR);
icb[27] = 4; // directory
let dir_len: u64 = SECTOR_SIZE;
icb[56..64].copy_from_slice(&dir_len.to_le_bytes());
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
let ad_pos = BDMV_DIR_SECTOR - METADATA_START;
icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
icb[220..224].copy_from_slice(&ad_pos.to_le_bytes());
self.writer.write_all(&icb)?;
Ok(())
}
fn write_bdmv_dir(&mut self) -> io::Result<()> {
let mut dir = [0u8; SECTOR_SIZE as usize];
let mut offset = 0;
offset += write_fid(
&mut dir[offset..],
ROOT_ICB_SECTOR - METADATA_START,
"",
true,
);
offset += write_fid(
&mut dir[offset..],
STREAM_ICB_SECTOR - METADATA_START,
"STREAM",
false,
);
let _ = offset;
self.writer.write_all(&dir)?;
Ok(())
}
fn write_stream_icb(&mut self) -> io::Result<()> {
let mut icb = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut icb, 266, STREAM_ICB_SECTOR);
icb[27] = 4; // directory
let dir_len: u64 = SECTOR_SIZE;
icb[56..64].copy_from_slice(&dir_len.to_le_bytes());
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
let ad_pos = STREAM_DIR_SECTOR - METADATA_START;
icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
icb[220..224].copy_from_slice(&ad_pos.to_le_bytes());
self.writer.write_all(&icb)?;
Ok(())
}
fn write_stream_dir(&mut self) -> io::Result<()> {
let mut dir = [0u8; SECTOR_SIZE as usize];
let mut offset = 0;
offset += write_fid(
&mut dir[offset..],
BDMV_ICB_SECTOR - METADATA_START,
"",
true,
);
offset += write_fid(
&mut dir[offset..],
M2TS_ICB_SECTOR - METADATA_START,
&self.m2ts_name,
false,
);
let _ = offset;
self.writer.write_all(&dir)?;
Ok(())
}
fn write_m2ts_icb(&mut self, file_size: u64) -> io::Result<()> {
let mut icb = [0u8; SECTOR_SIZE as usize];
write_descriptor_tag(&mut icb, 266, M2TS_ICB_SECTOR);
icb[27] = 5; // file type: regular file
icb[56..64].copy_from_slice(&file_size.to_le_bytes());
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
// Allocation: data starts at DATA_START in the physical partition.
// UDF short_ad is 30 bits for extent length (max 1 GB = 0x3FFFFFFF).
// For files > 1 GB, write multiple short_ad entries of 1 GB each plus remainder.
let data_offset = self.data_start_sector - PARTITION_START;
const MAX_EXTENT: u64 = 0x3FFF_FFFF; // 1 GB - 1 (30-bit max)
let mut remaining = file_size;
let mut ad_offset: usize = 216;
let mut sector_pos = data_offset;
while remaining > 0 && ad_offset + 8 <= SECTOR_SIZE as usize {
let extent_len = if remaining > MAX_EXTENT {
MAX_EXTENT
} else {
remaining
};
icb[ad_offset..ad_offset + 4].copy_from_slice(&(extent_len as u32).to_le_bytes());
icb[ad_offset + 4..ad_offset + 8].copy_from_slice(&sector_pos.to_le_bytes());
ad_offset += 8; // each short_ad is 8 bytes
let extent_sectors = ((extent_len + SECTOR_SIZE - 1) / SECTOR_SIZE) as u32;
sector_pos += extent_sectors;
remaining -= extent_len;
}
self.writer.write_all(&icb)?;
Ok(())
}
}
// ── UDF primitives ─────────────────────────────────────────────────────────
/// Write a UDF Descriptor Tag at the start of a sector.
fn write_descriptor_tag(buf: &mut [u8], tag_id: u16, sector: u32) {
buf[0..2].copy_from_slice(&tag_id.to_le_bytes());
// Descriptor version: 3 (UDF 2.50)
buf[2..4].copy_from_slice(&3u16.to_le_bytes());
// Tag location
buf[12..16].copy_from_slice(&sector.to_le_bytes());
// Compute CRC-CCITT over descriptor body (bytes 16+)
let body = &buf[16..];
let body_len = body.len();
let crc = udf_crc(body);
buf[8..10].copy_from_slice(&crc.to_le_bytes());
// Descriptor CRC length
buf[10..12].copy_from_slice(&(body_len as u16).to_le_bytes());
// Compute tag checksum: sum of bytes 0-3, 5-15 mod 256
buf[4] = 0; // clear before computing
let checksum: u8 = buf[0..4]
.iter()
.chain(buf[5..16].iter())
.fold(0u8, |acc, &b| acc.wrapping_add(b));
buf[4] = checksum;
}
/// UDF CRC-CCITT (CRC-16/ECMA-182 polynomial 0x11021).
fn udf_crc(data: &[u8]) -> u16 {
// CRC lookup table for polynomial 0x11021
static CRC_TABLE: [u16; 256] = {
let mut table = [0u16; 256];
let mut i = 0;
while i < 256 {
let mut crc = (i as u16) << 8;
let mut j = 0;
while j < 8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
j += 1;
}
table[i] = crc;
i += 1;
}
table
};
let mut crc: u16 = 0;
for &byte in data {
crc = (crc << 8) ^ CRC_TABLE[((crc >> 8) as u8 ^ byte) as usize];
}
crc
}
/// Write a UDF d-string (compressed unicode string with length prefix).
fn write_dstring(buf: &mut [u8], s: &str) {
let max = buf.len() - 1; // last byte is length
let bytes = s.as_bytes();
let len = bytes.len().min(max);
if len > 0 {
buf[0] = 8; // compression ID: 8 = Latin-1
buf[1..1 + len].copy_from_slice(&bytes[..len]);
buf[buf.len() - 1] = (len + 1) as u8; // d-string length including comp ID
}
}
/// Write a File Identifier Descriptor. Returns bytes written (4-byte aligned).
fn write_fid(buf: &mut [u8], icb_lba: u32, name: &str, is_parent: bool) -> usize {
// Tag 257 = File Identifier Descriptor
let name_bytes = name.as_bytes();
let name_len = if is_parent { 0 } else { name_bytes.len() + 1 }; // +1 for comp ID
let fid_len = 38 + name_len; // fixed header + identifier
let padded = (fid_len + 3) & !3; // 4-byte align
if padded > buf.len() {
return 0;
}
// Tag
buf[0..2].copy_from_slice(&257u16.to_le_bytes());
// File version number at offset 16
buf[16..18].copy_from_slice(&1u16.to_le_bytes());
// File characteristics at offset 18
buf[18] = if is_parent { 0x0A } else { 0x02 }; // parent | directory
if !is_parent && !name.contains('.') {
buf[18] = 0x02; // directory
} else if !is_parent {
buf[18] = 0x00; // file
}
// ICB (long_ad at offset 20): extent length + location
buf[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
buf[24..28].copy_from_slice(&icb_lba.to_le_bytes());
// Identifier length at offset 36
buf[36] = name_len as u8;
// Implementation use length at offset 37
buf[37] = 0;
// File identifier at offset 38
if !is_parent && !name_bytes.is_empty() {
buf[38] = 8; // compression ID: Latin-1
buf[39..39 + name_bytes.len()].copy_from_slice(name_bytes);
}
padded
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
/// Read a little-endian u16 from a byte slice at the given offset.
fn le_u16(data: &[u8], off: usize) -> u16 {
u16::from_le_bytes([data[off], data[off + 1]])
}
/// Read a little-endian u32 from a byte slice at the given offset.
#[allow(dead_code)]
fn le_u32(data: &[u8], off: usize) -> u32 {
u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
/// Read a little-endian u64 from a byte slice at the given offset.
fn le_u64(data: &[u8], off: usize) -> u64 {
u64::from_le_bytes([
data[off],
data[off + 1],
data[off + 2],
data[off + 3],
data[off + 4],
data[off + 5],
data[off + 6],
data[off + 7],
])
}
/// Get the sector at a given sector number from the output data.
fn sector(data: &[u8], num: u32) -> &[u8] {
let start = num as usize * SECTOR_SIZE as usize;
&data[start..start + SECTOR_SIZE as usize]
}
#[test]
fn isowriter_creates_valid_udf() {
let buf = Cursor::new(Vec::new());
let mut w = IsoWriter::new(buf, "TEST_VOL", "00001.m2ts");
w.start().unwrap();
w.write_data(&[0xAA; 4096]).unwrap();
w.finish().unwrap();
let data = w.writer.into_inner();
// AVDP at sector 256 should have tag ID = 2
let avdp = sector(&data, AVDP_SECTOR);
assert_eq!(le_u16(avdp, 0), 2, "AVDP tag ID should be 2");
// VRS at sector 16 should contain "BEA01"
let vrs = sector(&data, VRS_START);
assert_eq!(&vrs[1..6], b"BEA01", "VRS sector 16 should contain BEA01");
// FSD at metadata sector should have tag ID = 256
let fsd = sector(&data, FSD_SECTOR);
assert_eq!(le_u16(fsd, 0), 256, "FSD tag ID should be 256");
}
#[test]
fn isowriter_updates_file_size() {
let buf = Cursor::new(Vec::new());
let mut w = IsoWriter::new(buf, "SIZE_TEST", "00001.m2ts");
w.start().unwrap();
let test_data = vec![0x42u8; 8192]; // exactly 4 sectors
let written = w.write_data(&test_data).unwrap();
assert_eq!(written, 8192);
w.finish().unwrap();
let data = w.writer.into_inner();
// Read m2ts ICB at M2TS_ICB_SECTOR and check information length at offset 56
let icb = sector(&data, M2TS_ICB_SECTOR);
let file_size = le_u64(icb, 56);
assert_eq!(
file_size, 8192,
"m2ts ICB file size should match bytes written (8192), got {}",
file_size
);
}
#[test]
fn isowriter_with_names() {
let buf = Cursor::new(Vec::new());
let mut w = IsoWriter::new(buf, "MY_DISC", "00042.m2ts");
w.start().unwrap();
w.write_data(&[0x00; 2048]).unwrap();
w.finish().unwrap();
let data = w.writer.into_inner();
// Check PVD (sector 32) volume_id at offset 24 as d-string
let pvd = sector(&data, VDS_START);
// d-string: byte 0 = compression ID (8), then ASCII chars
assert_eq!(pvd[24], 8, "PVD volume_id compression ID should be 8");
assert_eq!(
&pvd[25..32],
b"MY_DISC",
"PVD should contain volume_id 'MY_DISC'"
);
// Check STREAM directory (sector 266) for m2ts filename in FID
let stream_dir = sector(&data, STREAM_DIR_SECTOR);
// The FID for the m2ts file should contain the filename after the parent entry.
// Search for "00042.m2ts" in the sector data
let name = b"00042.m2ts";
let found = stream_dir.windows(name.len()).any(|w| w == name);
assert!(
found,
"STREAM directory should contain m2ts filename '00042.m2ts'"
);
}
#[test]
fn isowriter_empty_content() {
let buf = Cursor::new(Vec::new());
let mut w = IsoWriter::new(buf, "EMPTY", "00001.m2ts");
w.start().unwrap();
// No data written
w.finish().unwrap();
let data = w.writer.into_inner();
// Should still have valid UDF structure
// AVDP at sector 256
let avdp = sector(&data, AVDP_SECTOR);
assert_eq!(
le_u16(avdp, 0),
2,
"AVDP tag should be present even with no data"
);
// VRS
let vrs = sector(&data, VRS_START);
assert_eq!(&vrs[1..6], b"BEA01");
// FSD
let fsd = sector(&data, FSD_SECTOR);
assert_eq!(le_u16(fsd, 0), 256);
// m2ts ICB should show 0 file size
let icb = sector(&data, M2TS_ICB_SECTOR);
let file_size = le_u64(icb, 56);
assert_eq!(file_size, 0, "empty content should have 0 file size");
// Output should be at least DATA_START sectors (the header structure)
assert!(
data.len() >= DATA_START as usize * SECTOR_SIZE as usize,
"output too small for valid UDF structure"
);
}
}
+196 -113
View File
@@ -1,171 +1,254 @@
//! 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.
//! Write: prepends FMKV metadata header, then muxes PES frames into BD-TS.
//! Read: extracts metadata header (or scans PMT), then demuxes BD-TS into PES frames.
use super::{meta, ts, IOStream, ReadSeek};
use super::{meta, ts};
use crate::disc::{DiscTitle, Stream as DiscStream};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::io::{self, Read, Write};
type PesSetup = (Vec<u16>, Vec<(u16, Box<dyn super::codec::CodecParser>)>, Vec<(u16, usize)>);
/// Size of initial scan buffer for PMT/stream detection.
const SCAN_SIZE: usize = 1024 * 1024;
enum Mode {
Write {
writer: Box<dyn Write>,
header_written: bool,
muxer: super::tsmux::TsMuxer<Box<dyn Write>>,
},
Read {
reader: Box<dyn ReadSeek>,
reader: Box<dyn Read>,
},
}
/// Read as many bytes as possible into buf (multiple read calls if needed).
/// Bounded by buf.len() — caller controls max bytes read.
fn read_fill(r: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
let mut total = 0;
while total < buf.len() {
match r.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => total += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(total)
}
/// BD transport stream with embedded metadata.
pub struct M2tsStream {
disc_title: DiscTitle,
mode: Mode,
finished: bool,
/// Content size in bytes (file size minus header), set for read mode.
content_size: Option<u64>,
// PES support
demuxer: Option<ts::TsDemuxer>,
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
pid_to_track: Vec<(u16, usize)>,
pes_eof: bool,
/// Codec private data per stream (from FMKV header).
stored_codec_privates: Vec<Option<Vec<u8>>>,
}
impl M2tsStream {
/// 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,
content_size: None,
/// Create for writing PES frames → BD-TS output.
/// Writes FMKV metadata header, then muxes PES frames into BD transport stream.
pub fn create(mut writer: impl Write + 'static, title: &DiscTitle) -> io::Result<Self> {
// Write FMKV metadata header
if !title.streams.is_empty() {
let m = meta::M2tsMeta::from_title(title);
meta::write_header(&mut writer, &m)?;
}
let pids: Vec<u16> = title.streams.iter().map(|s| match s {
DiscStream::Video(v) => v.pid,
DiscStream::Audio(a) => a.pid,
DiscStream::Subtitle(s) => s.pid,
}).collect();
let boxed: Box<dyn Write> = Box::new(writer);
let mut muxer = super::tsmux::TsMuxer::new(boxed, &pids);
for (i, cp) in title.codec_privates.iter().enumerate() {
if let Some(data) = cp {
muxer.set_codec_private(i, data.clone());
}
}
Ok(Self {
disc_title: title.clone(),
mode: Mode::Write { muxer },
demuxer: None,
parsers: Vec::new(),
pending_frames: std::collections::VecDeque::new(),
pid_to_track: Vec::new(),
pes_eof: false,
stored_codec_privates: Vec::new(),
})
}
/// Set stream metadata. Returns self for chaining.
pub fn meta(mut self, dt: &DiscTitle) -> Self {
self.disc_title = dt.clone();
self
fn setup_pes(streams: &[DiscStream]) -> PesSetup {
let mut pids = Vec::new();
let mut parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)> = Vec::new();
let mut pid_to_track = Vec::new();
for (i, s) in streams.iter().enumerate() {
let (pid, codec) = match s {
DiscStream::Video(v) => (v.pid, v.codec),
DiscStream::Audio(a) => (a.pid, a.codec),
DiscStream::Subtitle(s) => (s.pid, s.codec),
};
pids.push(pid);
pid_to_track.push((pid, i));
parsers.push((pid, super::codec::parser_for_codec(codec)));
}
(pids, parsers, pid_to_track)
}
/// Open an m2ts file for reading.
/// Open an M2TS stream for reading. Takes any Read source — file, pipe, socket.
///
/// Tries FMKV metadata header first. Falls back to PMT scan + PTS duration.
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
// Get total file size for progress tracking
let file_size = reader.seek(SeekFrom::End(0))?;
reader.seek(SeekFrom::Start(0))?;
/// Tries FMKV metadata header first. Falls back to PMT scan of first 1 MB.
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
// Read first chunk — enough for FMKV header or PMT scan
let mut head = vec![0u8; SCAN_SIZE];
let head_len = read_fill(&mut reader, &mut head)?;
head.truncate(head_len);
// Try FMKV metadata header
if let Ok(Some(m)) = meta::read_header(&mut reader) {
let header_end = reader.stream_position()?;
let content_size = file_size.saturating_sub(header_end);
// Try FMKV metadata header from the buffered head
let mut cursor = io::Cursor::new(&head);
if let Ok(Some(m)) = meta::read_header(&mut cursor) {
let header_end = cursor.position() as usize;
let title = m.to_title();
let (pids, parsers, pid_to_track) = Self::setup_pes(&title.streams);
// Chain: remaining head bytes + rest of reader
let remaining_head = &head[header_end..];
let chain: Box<dyn Read> = Box::new(io::Cursor::new(remaining_head.to_vec()).chain(reader));
return Ok(Self {
disc_title: m.to_title(),
mode: Mode::Read {
reader: Box::new(reader),
},
finished: false,
content_size: Some(content_size),
disc_title: title.clone(),
mode: Mode::Read { reader: chain },
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
pes_eof: false,
stored_codec_privates: title.codec_privates,
});
}
// Fallback: scan PMT for streams, PTS for duration
reader.seek(SeekFrom::Start(0))?;
let mut buf = vec![0u8; SCAN_SIZE];
let n = reader.read(&mut buf)?;
// No FMKV header — scan head for PMT
let streams = ts::scan_streams(&head)
.ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?;
let streams = ts::scan_streams(&buf[..n])
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no streams found"))?;
let (pids, parsers, pid_to_track) = Self::setup_pes(&streams);
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))?;
// Chain: full head (it's all TS data) + rest of reader
let chain: Box<dyn Read> = Box::new(io::Cursor::new(head).chain(reader));
Ok(Self {
disc_title: DiscTitle {
duration_secs: duration,
duration_secs: 0.0, // unknown without seeking
streams,
..DiscTitle::empty()
},
mode: Mode::Read {
reader: Box::new(reader),
},
finished: false,
content_size: Some(file_size),
mode: Mode::Read { reader: chain },
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
pes_eof: false,
stored_codec_privates: Vec::new(),
})
}
}
impl IOStream for M2tsStream {
fn info(&self) -> &DiscTitle {
&self.disc_title
impl crate::pes::Stream for M2tsStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
if self.pes_eof { return Ok(None); }
loop {
let reader = match &mut self.mode {
Mode::Read { reader } => reader,
_ => return Err(crate::error::Error::StreamWriteOnly.into()),
};
let mut buf = vec![0u8; 192 * 1024];
let n = reader.read(&mut buf)?;
if n == 0 {
self.pes_eof = true;
// Flush demuxer — last PES packet may still be in the assembler
if let Some(ref mut demuxer) = self.demuxer {
for pes in &demuxer.flush() {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame)
);
}
}
}
}
}
return Ok(self.pending_frames.pop_front());
}
if let Some(ref mut demuxer) = self.demuxer {
let packets = demuxer.feed(&buf[..n]);
for pes in &packets {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame)
);
}
}
}
}
}
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
}
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.mode {
Mode::Write { muxer } => muxer.write_frame(frame.track, frame.pts, &frame.data),
Mode::Read { .. } => Err(crate::error::Error::StreamReadOnly.into()),
}
}
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(())
match &mut self.mode {
Mode::Write { muxer } => muxer.finish(),
Mode::Read { .. } => Ok(()),
}
}
fn total_bytes(&self) -> Option<u64> {
self.content_size
}
}
fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title }
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;
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
// First check stored codec_privates from FMKV header
if let Some(Some(cp)) = self.stored_codec_privates.get(track) {
return Some(cp.clone());
}
// Fall back to parser-extracted codec_private
let pid = self.pid_to_track.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
fn headers_ready(&self) -> bool {
for (idx, s) in self.disc_title.streams.iter().enumerate() {
if let crate::disc::Stream::Video(v) = s {
if !v.secondary && self.codec_private(idx).is_none() {
return false;
}
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(())
}
true
}
}
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",
)),
}
}
}
+55 -117
View File
@@ -3,11 +3,9 @@
//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
//! Other tools skip the header during TS sync recovery (scan for 0x47).
use crate::disc::{
AudioStream, Codec, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream,
};
use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream};
use serde::{Deserialize, Serialize};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::io::{self, Read, Write};
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
@@ -48,6 +46,9 @@ pub enum MetaStream {
label: String,
#[serde(default)]
secondary: bool,
/// Base64-encoded codec initialization data (HEVCDecoderConfigurationRecord, etc.)
#[serde(default, skip_serializing_if = "Option::is_none")]
codec_private: Option<String>,
},
#[serde(rename = "audio")]
Audio {
@@ -76,33 +77,38 @@ pub enum MetaStream {
}
impl M2tsMeta {
/// Build metadata from a disc Title.
/// Build metadata from a DiscTitle. Codec privates come from title.codec_privates.
pub fn from_title(title: &DiscTitle) -> Self {
use base64::Engine;
let streams = title
.streams
.iter()
.map(|s| match s {
.enumerate()
.map(|(i, s)| match s {
Stream::Video(v) => MetaStream::Video {
pid: v.pid,
codec: codec_to_str(v.codec),
resolution: v.resolution.clone(),
frame_rate: v.frame_rate.clone(),
hdr: hdr_to_str(v.hdr),
codec: v.codec.id().into(),
resolution: v.resolution.to_string(),
frame_rate: v.frame_rate.to_string(),
hdr: v.hdr.id().into(),
label: v.label.clone(),
secondary: v.secondary,
codec_private: title.codec_privates.get(i)
.and_then(|cp| cp.as_ref())
.map(|cp| base64::engine::general_purpose::STANDARD.encode(cp)),
},
Stream::Audio(a) => MetaStream::Audio {
pid: a.pid,
codec: codec_to_str(a.codec),
channels: a.channels.clone(),
codec: a.codec.id().into(),
channels: a.channels.to_string(),
language: a.language.clone(),
sample_rate: a.sample_rate.clone(),
sample_rate: a.sample_rate.to_string(),
label: a.label.clone(),
secondary: a.secondary,
},
Stream::Subtitle(s) => MetaStream::Subtitle {
pid: s.pid,
codec: codec_to_str(s.codec),
codec: s.codec.id().into(),
language: s.language.clone(),
forced: s.forced,
},
@@ -131,12 +137,13 @@ impl M2tsMeta {
hdr,
label,
secondary,
codec_private: _,
} => Stream::Video(VideoStream {
pid: *pid,
codec: str_to_codec(codec),
resolution: resolution.clone(),
frame_rate: frame_rate.clone(),
hdr: str_to_hdr(hdr),
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
resolution: resolution.parse().unwrap_or(crate::disc::Resolution::Unknown),
frame_rate: frame_rate.parse().unwrap_or(crate::disc::FrameRate::Unknown),
hdr: hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr),
color_space: ColorSpace::Bt709,
secondary: *secondary,
label: label.clone(),
@@ -151,10 +158,10 @@ impl M2tsMeta {
secondary,
} => Stream::Audio(AudioStream {
pid: *pid,
codec: str_to_codec(codec),
channels: channels.clone(),
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
channels: channels.parse().unwrap_or(crate::disc::AudioChannels::Unknown),
language: language.clone(),
sample_rate: sample_rate.clone(),
sample_rate: sample_rate.parse().unwrap_or(crate::disc::SampleRate::Unknown),
secondary: *secondary,
label: label.clone(),
}),
@@ -165,7 +172,7 @@ impl M2tsMeta {
forced,
} => Stream::Subtitle(SubtitleStream {
pid: *pid,
codec: str_to_codec(codec),
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
language: language.clone(),
forced: *forced,
codec_data: None,
@@ -183,8 +190,24 @@ impl M2tsMeta {
chapters: Vec::new(),
extents: Vec::new(),
content_format: crate::disc::ContentFormat::BdTs,
codec_privates: self.codec_privates(),
}
}
/// Extract codec_private data per stream (from FMKV header).
/// Returns a Vec matching stream order — None for streams without codec_private.
pub fn codec_privates(&self) -> Vec<Option<Vec<u8>>> {
self.streams.iter().map(|s| {
if let MetaStream::Video { codec_private: Some(ref b64), .. } = s {
{
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(b64).ok()
}
} else {
None
}
}).collect()
}
}
/// Write the metadata header to a writer. Padded to 192-byte boundary.
@@ -205,28 +228,27 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
Ok(())
}
/// Try to read a metadata header from the start of an m2ts file.
/// Returns None for bare m2ts files (no header).
/// On success, leaves reader positioned at the first TS packet.
/// On failure, seeks back to the start.
pub fn read_header<R: Read + Seek>(r: &mut R) -> io::Result<Option<M2tsMeta>> {
let start = r.stream_position()?;
/// Try to read an FMKV metadata header.
/// Returns None if magic bytes don't match. Consumes header bytes on success.
/// Caller handles seek-back on failure if needed (e.g. for fallback PMT scan).
pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
const MAX_JSON_SIZE: usize = 10 * 1024 * 1024; // 10 MB
let mut magic = [0u8; 8];
if r.read_exact(&mut magic).is_err() {
r.seek(SeekFrom::Start(start))?;
return Ok(None);
}
if magic[..4] != MAGIC[..4] {
// Not a freemkv m2ts — seek back
r.seek(SeekFrom::Start(start))?;
return Ok(None);
}
let mut len_buf = [0u8; 4];
r.read_exact(&mut len_buf)?;
let json_len = u32::from_be_bytes(len_buf) as usize;
if json_len > MAX_JSON_SIZE {
return Err(crate::error::Error::NoMetadata.into());
}
let mut json_buf = vec![0u8; json_len];
r.read_exact(&mut json_buf)?;
@@ -238,37 +260,6 @@ pub fn read_header<R: Read + Seek>(r: &mut R) -> io::Result<Option<M2tsMeta>> {
let raw_len = 8 + 4 + json_len;
let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE;
let padding = padded_len - raw_len;
if padding > 0 {
r.seek(SeekFrom::Current(padding as i64))?;
}
Ok(Some(meta))
}
/// Read a metadata header from a forward-only stream (no Seek required).
/// Returns None if the magic bytes don't match. Consumes the header bytes.
pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
let mut magic = [0u8; 8];
r.read_exact(&mut magic)?;
if magic[..4] != MAGIC[..4] {
return Ok(None);
}
let mut len_buf = [0u8; 4];
r.read_exact(&mut len_buf)?;
let json_len = u32::from_be_bytes(len_buf) as usize;
let mut json_buf = vec![0u8; json_len];
r.read_exact(&mut json_buf)?;
let meta: M2tsMeta = serde_json::from_slice(&json_buf)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
// Skip padding
let raw_len = 8 + 4 + json_len;
let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE;
let padding = padded_len - raw_len;
if padding > 0 {
let mut skip = vec![0u8; padding];
r.read_exact(&mut skip)?;
@@ -277,58 +268,5 @@ pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>
Ok(Some(meta))
}
// Codec string conversion (compact, no English — just codec identifiers)
fn codec_to_str(c: Codec) -> String {
match c {
Codec::Hevc => "hevc",
Codec::H264 => "h264",
Codec::Vc1 => "vc1",
Codec::Mpeg2 => "mpeg2",
Codec::TrueHd => "truehd",
Codec::DtsHdMa => "dtshd_ma",
Codec::DtsHdHr => "dtshd_hr",
Codec::Dts => "dts",
Codec::Ac3 => "ac3",
Codec::Ac3Plus => "eac3",
Codec::Lpcm => "lpcm",
Codec::Pgs => "pgs",
Codec::DvdSub => "dvdsub",
Codec::Unknown(_) => "unknown",
}
.into()
}
fn str_to_codec(s: &str) -> Codec {
match s {
"hevc" => Codec::Hevc,
"h264" => Codec::H264,
"vc1" => Codec::Vc1,
"mpeg2" => Codec::Mpeg2,
"truehd" => Codec::TrueHd,
"dtshd_ma" => Codec::DtsHdMa,
"dtshd_hr" => Codec::DtsHdHr,
"dts" => Codec::Dts,
"ac3" => Codec::Ac3,
"eac3" => Codec::Ac3Plus,
"lpcm" => Codec::Lpcm,
"pgs" => Codec::Pgs,
_ => Codec::Unknown(0),
}
}
fn hdr_to_str(h: HdrFormat) -> String {
match h {
HdrFormat::Sdr => "sdr",
HdrFormat::Hdr10 => "hdr10",
HdrFormat::DolbyVision => "dv",
}
.into()
}
fn str_to_hdr(s: &str) -> HdrFormat {
match s {
"hdr10" => HdrFormat::Hdr10,
"dv" => HdrFormat::DolbyVision,
_ => HdrFormat::Sdr,
}
}
// Serialization uses Codec::id() / HdrFormat::id() and Display impls.
// Deserialization uses FromStr impls (.parse()) on each enum.
+6 -43
View File
@@ -35,7 +35,7 @@ impl MkvTrack {
Codec::Mpeg2 => "V_MPEG2",
_ => "V_MPEG2",
};
let (w, h) = parse_resolution(&v.resolution);
let (w, h) = v.resolution.pixels();
Self {
track_type: ebml::TRACK_TYPE_VIDEO,
codec_id,
@@ -61,8 +61,8 @@ impl MkvTrack {
Codec::Lpcm => "A_PCM/INT/BIG",
_ => "A_AC3",
};
let sr = parse_sample_rate(&a.sample_rate);
let ch = parse_channels(&a.channels);
let sr = a.sample_rate.hz();
let ch = a.channels.count();
Self {
track_type: ebml::TRACK_TYPE_AUDIO,
codec_id,
@@ -183,7 +183,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
for (i, track) in tracks.iter().enumerate() {
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x1000000)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x100_0000)?;
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
@@ -414,45 +414,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Helpers
// ============================================================
fn parse_resolution(s: &str) -> (u32, u32) {
if s.contains("2160") {
(3840, 2160)
} else if s.contains("1080") {
(1920, 1080)
} else if s.contains("720") {
(1280, 720)
} else if s.contains("576") {
(720, 576)
} else if s.contains("480") {
(720, 480)
} else {
(1920, 1080)
}
}
fn parse_sample_rate(s: &str) -> f64 {
if s.contains("192") {
192000.0
} else if s.contains("96") {
96000.0
} else {
48000.0
}
}
fn parse_channels(s: &str) -> u8 {
if s.contains("7.1") {
8
} else if s.contains("5.1") {
6
} else if s.contains("stereo") || s.contains("2.0") {
2
} else if s.contains("mono") {
1
} else {
6
}
}
// Old parse_resolution/parse_sample_rate/parse_channels removed —
// Resolution::pixels(), SampleRate::hz(), AudioChannels::count() replace them.
#[cfg(test)]
mod tests {
+185 -427
View File
@@ -1,47 +1,33 @@
//! 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.
//! Read: MKV container → demux EBML → PES frames out.
//! Write: PES frames in → MKV mux → Matroska container.
use super::codec::{self, CodecParser};
use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE};
use super::mkv::{MkvMuxer, MkvTrack};
use super::ts::TsDemuxer;
use super::{ebml, IOStream, ReadSeek, WriteSeek};
use super::{ebml, WriteSeek};
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
/// Skip `n` bytes on a forward-only reader (no Seek required).
fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> {
io::copy(&mut r.take(n), &mut io::sink())?;
Ok(())
}
use crate::disc::*;
use std::io::{self, Read, Seek, SeekFrom, Write};
/// 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,
}
use std::io::{self, Read};
struct ReadState {
reader: Box<dyn ReadSeek>,
buf: Vec<u8>,
pos: usize,
len: usize,
reader: Box<dyn Read>,
cluster_ts_ms: i64,
/// Codec private data per track (track_number, hvcC/avcC bytes).
codec_privates: Vec<(u16, Vec<u8>)>,
}
enum Mode {
Write(Box<WriteState>),
Write {
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
},
Read(ReadState),
}
@@ -49,211 +35,67 @@ enum Mode {
pub struct MkvStream {
disc_title: DiscTitle,
mode: Mode,
max_buffer: usize,
finished: bool,
/// File size in bytes, set for read mode.
file_size: Option<u64>,
}
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(Box::new(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,
file_size: None,
}
}
/// 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_with_data(s.codec, s.codec_data.clone()),
),
};
let idx = ws.tracks.len();
pids.push(pid);
ws.pid_to_track.push((pid, idx));
ws.parsers.push((pid, parser));
ws.tracks.push(track);
/// Create for writing PES frames → MKV container.
/// Codec privates come from title.codec_privates (populated by input stream).
pub fn create(
writer: Box<dyn WriteSeek>,
title: &DiscTitle,
) -> io::Result<Self> {
let mut tracks = Vec::new();
for (idx, s) in title.streams.iter().enumerate() {
let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s),
};
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
track.codec_private = Some(cp.clone());
}
ws.demuxer = TsDemuxer::new(&pids);
tracks.push(track);
}
self.disc_title = dt.clone();
self
let muxer = MkvMuxer::new_with_chapters(
writer,
&tracks,
Some(&title.playlist),
title.duration_secs,
&title.chapters,
)?;
Ok(Self {
disc_title: title.clone(),
mode: Mode::Write { muxer: Some(muxer) },
})
}
/// 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 file_size = reader.seek(SeekFrom::End(0))?;
reader.seek(SeekFrom::Start(0))?;
let disc_title = parse_mkv_header(&mut reader)?;
/// Open an MKV file for reading → PES frames.
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
let (disc_title, codec_privates) = parse_mkv_header(&mut reader)?;
Ok(Self {
disc_title,
mode: Mode::Read(ReadState {
reader: Box::new(reader),
buf: Vec::new(),
pos: 0,
len: 0,
cluster_ts_ms: 0,
codec_privates,
}),
max_buffer: 0,
finished: false,
file_size: Some(file_size),
})
}
}
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(())
}
fn total_bytes(&self) -> Option<u64> {
self.file_size
}
}
// ── 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> {
impl crate::pes::Stream for MkvStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
let rs = match self.mode {
Mode::Read(ref mut rs) => rs,
Mode::Write(_) => {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"stream opened for writing",
))
}
Mode::Write { .. } => return Err(crate::error::Error::StreamWriteOnly.into()),
};
// 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),
Err(_) => return Ok(None),
};
match id {
@@ -264,136 +106,97 @@ impl Read for MkvStream {
}
ebml::SIMPLE_BLOCK => {
let block = ebml::read_binary_val(&mut rs.reader, size as usize)?;
if block.len() < 4 {
continue;
}
if block.len() < 4 { continue; }
let (track, vl) = block_vint(&block);
if vl + 3 > block.len() {
if vl + 3 > block.len() { continue; }
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
let keyframe = block[vl + 2] & 0x80 != 0;
let data = block[vl + 3..].to_vec();
let pts_ms = rs.cluster_ts_ms + rel_ts as i64;
let track_idx = (track as usize).saturating_sub(1); // MKV tracks are 1-based
// Skip blocks for non-existent tracks
if track_idx >= self.disc_title.streams.len() {
continue;
}
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);
}
return Ok(Some(crate::pes::PesFrame {
track: track_idx,
pts: pts_ms * 1_000_000, // ms → ns
keyframe,
data,
}));
}
_ => {
if size != u64::MAX && size > 0 {
rs.reader.seek(SeekFrom::Current(size as i64))?;
}
skip_bytes(&mut rs.reader, size)?;
continue;
}
}
}
}
}
// ── 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;
}
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.mode {
Mode::Write { muxer: Some(ref mut m) } => m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data),
Mode::Write { muxer: None } => Ok(()),
Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()),
}
}
ws.video_pending == 0
}
fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> {
let writer = ws
.writer
.take()
.ok_or_else(|| io::Error::other("writer already consumed"))?;
ws.muxer = Some(MkvMuxer::new_with_chapters(
writer,
&ws.tracks,
Some(&dt.playlist),
dt.duration_secs,
&dt.chapters,
)?);
ws.phase = WritePhase::Streaming;
// Re-parse buffered data through a fresh demuxer, then reset the main
// demuxer so stale PES assembler state from scanning doesn't cause
// duplicate or incomplete packets during streaming.
let pids: Vec<u16> = ws.pid_to_track.iter().map(|(pid, _)| *pid).collect();
let buffered = ws.lookahead.drain();
if !buffered.is_empty() {
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)?;
fn finish(&mut self) -> io::Result<()> {
if let Mode::Write { ref mut muxer } = self.mode {
if let Some(m) = muxer.take() {
m.finish()?;
}
}
Ok(())
}
ws.demuxer = TsDemuxer::new(&pids);
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)?;
fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title }
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let track_num = (track + 1) as u16; // MKV tracks are 1-based
if let Mode::Read(ref rs) = self.mode {
rs.codec_privates
.iter()
.find(|(tn, _)| *tn == track_num)
.map(|(_, data)| data.clone())
} else {
None
}
}
fn headers_ready(&self) -> bool {
true // MKV has all headers upfront in the EBML header
}
Ok(())
}
// ── MKV header parsing (read side) ────────────────────────────
fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
fn parse_mkv_header(
r: &mut impl Read,
) -> MkvHeaderResult {
let mut title = String::new();
let mut duration_ms = 0.0f64;
let mut ts_scale: u64 = 1_000_000;
let mut streams: Vec<crate::disc::Stream> = Vec::new();
let mut codec_privates: Vec<(u16, Vec<u8>)> = Vec::new();
let (id, size, _) = ebml::read_element_header(r)?;
if id != ebml::EBML {
return Err(io::Error::new(io::ErrorKind::InvalidData, "not EBML"));
return Err(crate::error::Error::MkvInvalid.into());
}
if size > i64::MAX as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"EBML header too large",
));
return Err(crate::error::Error::MkvInvalid.into());
}
r.seek(SeekFrom::Current(size as i64))?;
skip_bytes(r, size)?;
let (id, _, _) = ebml::read_element_header(r)?;
if id != ebml::SEGMENT {
return Err(io::Error::new(io::ErrorKind::InvalidData, "no Segment"));
return Err(crate::error::Error::MkvInvalid.into());
}
let (mut got_info, mut got_tracks) = (false, false);
@@ -409,92 +212,102 @@ fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
match id {
ebml::INFO => {
let end = r.stream_position()? + size;
while r.stream_position()? < end {
let (cid, cs, _) = ebml::read_element_header(r)?;
let mut remaining = size;
while remaining > 0 {
let (cid, cs, hlen) = ebml::read_element_header(r)?;
remaining = remaining.saturating_sub(hlen as u64 + cs);
match cid {
ebml::TIMESTAMP_SCALE => ts_scale = ebml::read_uint_val(r, cs as usize)?,
ebml::DURATION => duration_ms = ebml::read_float_val(r, cs as usize)?,
ebml::TITLE => title = ebml::read_string_val(r, cs as usize)?,
_ => {
r.seek(SeekFrom::Current(cs as i64))?;
}
_ => { skip_bytes(r, cs)?; }
}
}
got_info = true;
}
ebml::TRACKS => {
let end = r.stream_position()? + size;
while r.stream_position()? < end {
let (cid, cs, _) = ebml::read_element_header(r)?;
let mut remaining = size;
while remaining > 0 {
let (cid, cs, hlen) = ebml::read_element_header(r)?;
remaining = remaining.saturating_sub(hlen as u64 + cs);
if cid == ebml::TRACK_ENTRY {
if let Some(s) = parse_track(r, cs)? {
let (stream, tnum, cp) = parse_track(r, cs)?;
if let Some(s) = stream {
streams.push(s);
}
if let Some(cp) = cp {
codec_privates.push((tnum, cp));
}
} else {
r.seek(SeekFrom::Current(cs as i64))?;
skip_bytes(r, cs)?;
}
}
got_tracks = true;
}
ebml::CLUSTER => break,
_ if size != u64::MAX => {
r.seek(SeekFrom::Current(size as i64))?;
skip_bytes(r, size)?;
}
_ => break,
}
}
Ok(DiscTitle {
let disc_title = DiscTitle {
playlist: title,
duration_secs: duration_ms * (ts_scale as f64) / 1_000_000_000.0,
streams,
..DiscTitle::empty()
})
};
Ok((disc_title, codec_privates))
}
fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate::disc::Stream>> {
let end = r.stream_position()? + size;
/// Returns (stream, track_number, codec_private_bytes)
fn parse_track(
r: &mut impl Read,
size: u64,
) -> io::Result<(Option<crate::disc::Stream>, u16, Option<Vec<u8>>)> {
let (mut ttype, mut tnum) = (0u64, 0u16);
let (mut codec_id, mut lang, mut name) = (String::new(), String::from("und"), String::new());
let (mut ph, mut sr, mut ch, mut forced) = (0u32, 0.0f64, 0u8, false);
let mut codec_priv: Option<Vec<u8>> = None;
while r.stream_position()? < end {
let (cid, cs, _) = ebml::read_element_header(r)?;
let mut remaining = size;
while remaining > 0 {
let (cid, cs, hlen) = ebml::read_element_header(r)?;
remaining = remaining.saturating_sub(hlen as u64 + cs);
match cid {
ebml::TRACK_NUMBER => tnum = ebml::read_uint_val(r, cs as usize)? as u16,
ebml::TRACK_TYPE => ttype = ebml::read_uint_val(r, cs as usize)?,
ebml::CODEC_ID => codec_id = ebml::read_string_val(r, cs as usize)?,
ebml::CODEC_PRIVATE => codec_priv = Some(ebml::read_binary_val(r, cs as usize)?),
ebml::LANGUAGE => lang = ebml::read_string_val(r, cs as usize)?,
ebml::TRACK_NAME => name = ebml::read_string_val(r, cs as usize)?,
ebml::FLAG_FORCED => forced = ebml::read_uint_val(r, cs as usize)? != 0,
ebml::VIDEO => {
let ve = r.stream_position()? + cs;
while r.stream_position()? < ve {
let (vid, vs, _) = ebml::read_element_header(r)?;
let mut vrem = cs;
while vrem > 0 {
let (vid, vs, vhlen) = ebml::read_element_header(r)?;
vrem = vrem.saturating_sub(vhlen as u64 + vs);
if vid == ebml::PIXEL_HEIGHT {
ph = ebml::read_uint_val(r, vs as usize)? as u32;
} else {
r.seek(SeekFrom::Current(vs as i64))?;
skip_bytes(r, vs)?;
}
}
}
ebml::AUDIO => {
let ae = r.stream_position()? + cs;
while r.stream_position()? < ae {
let (aid, as_, _) = ebml::read_element_header(r)?;
let mut arem = cs;
while arem > 0 {
let (aid, as_, ahlen) = ebml::read_element_header(r)?;
arem = arem.saturating_sub(ahlen as u64 + as_);
match aid {
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
ebml::CHANNELS => ch = ebml::read_uint_val(r, as_ as usize)? as u8,
_ => {
r.seek(SeekFrom::Current(as_ as i64))?;
}
_ => { skip_bytes(r, as_)?; }
}
}
}
_ => {
r.seek(SeekFrom::Current(cs as i64))?;
}
_ => { skip_bytes(r, cs)?; }
}
}
@@ -512,30 +325,33 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
"S_VOBSUB" => Codec::DvdSub,
_ => Codec::Unknown(0),
};
let res = format!("{}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();
let res = Resolution::from_height(ph);
let chs = AudioChannels::from_count(ch);
let srs = if sr >= 96000.0 {
SampleRate::S96
} else {
SampleRate::S48
};
Ok(match ttype {
1 => Some(crate::disc::Stream::Video(VideoStream {
pid: tnum,
codec,
resolution: res,
frame_rate: String::new(),
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: name,
})),
// Map MKV track numbers to BD-TS PIDs
let ts_pid = if tnum == 1 { 0x1011 } else { 0x1100 + (tnum - 2) };
let stream = match ttype {
1 => {
let is_secondary = name.contains("Dolby Vision EL") || name.contains("DV EL");
Some(crate::disc::Stream::Video(VideoStream {
pid: ts_pid,
codec,
resolution: res,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: is_secondary,
label: name,
}))
}
2 => Some(crate::disc::Stream::Audio(AudioStream {
pid: tnum,
pid: ts_pid,
codec,
channels: chs,
language: lang,
@@ -544,18 +360,17 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
label: name,
})),
17 => Some(crate::disc::Stream::Subtitle(SubtitleStream {
pid: tnum,
pid: ts_pid,
codec,
language: lang,
forced,
codec_data: None,
})),
_ => None,
})
};
Ok((stream, tnum, codec_priv))
}
// ── BD-TS frame wrapping (read side) ──────────────────────────
fn block_vint(d: &[u8]) -> (u64, usize) {
if d.is_empty() {
return (0, 0);
@@ -566,68 +381,11 @@ fn block_vint(d: &[u8]) -> (u64, usize) {
if d[0] & 0x40 != 0 && d.len() >= 2 {
return ((((d[0] & 0x3F) as u64) << 8) | d[1] as u64, 2);
}
(0, 1)
}
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)
};
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 byte in pkt.iter_mut().take((8 + pad).min(192)).skip(10) {
*byte = 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;
if d[0] & 0x20 != 0 && d.len() >= 3 {
return ((((d[0] & 0x1F) as u64) << 16) | ((d[1] as u64) << 8) | d[2] as u64, 3);
}
}
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,
]
if d[0] & 0x10 != 0 && d.len() >= 4 {
return ((((d[0] & 0x0F) as u64) << 24) | ((d[1] as u64) << 16) | ((d[2] as u64) << 8) | d[3] as u64, 4);
}
(0, 1) // Unsupported 5+ byte VINT — treat as track 0
}
+19 -44
View File
@@ -1,33 +1,29 @@
//! Stream-based I/O pipeline.
//!
//! All formats are streams. Two URLs, left reads, right writes:
//! All formats are PES streams. Read from a format → PES frames.
//! Write PES frames → a format.
//!
//! ```text
//! 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
//! ```
//!
//! 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)?;
//! let mut input = input("iso://Disc.iso", &opts)?;
//! let title = input.info().clone();
//! let mut output = output("mkv://Dune.mkv", &title)?;
//! while let Ok(Some(frame)) = input.read() {
//! output.write(&frame)?;
//! }
//! output.finish()?;
//! ```
//!
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
pub mod codec;
pub mod disc;
pub mod ebml;
pub mod iso;
mod isowriter;
pub mod lookahead;
pub mod mkv;
pub mod tsmux;
pub mod tsreader;
mod m2ts;
pub mod meta;
pub mod mkv;
mod mkvstream;
pub mod network;
pub mod null;
@@ -36,38 +32,17 @@ pub mod resolve;
pub mod stdio;
pub mod ts;
pub use disc::{DiscOptions, DiscStream};
pub use iso::IsoStream;
pub use disc::DiscStream;
pub use iso::IsoSectorReader;
pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream;
pub use network::NetworkStream;
pub use null::NullStream;
pub use resolve::{open_input, open_output, parse_url, InputOptions, StreamUrl};
pub use resolve::{input, output, parse_url, InputOptions, StreamUrl};
pub use stdio::StdioStream;
use crate::disc::DiscTitle;
use std::io::{self, Read, Seek, Write};
use std::io::{Seek, Write};
/// 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<()>;
/// Total content size in bytes, if known. Used for progress display.
fn total_bytes(&self) -> Option<u64> {
None
}
}
// Combined traits for internal trait objects.
pub(crate) trait ReadSeek: Read + Seek {}
impl<T: Read + Seek> ReadSeek for T {}
pub(crate) trait WriteSeek: Write + Seek {}
// WriteSeek — used internally by MKV muxer (container format requires seeking).
pub trait WriteSeek: Write + Seek {}
impl<T: Write + Seek> WriteSeek for T {}
+51 -129
View File
@@ -1,18 +1,14 @@
//! NetworkStream — BD-TS over TCP with embedded metadata.
//! NetworkStream — PES frames over TCP with embedded metadata.
//!
//! **Security:** Data is transmitted over plain TCP with no encryption.
//! Use only on trusted networks (LAN). TLS support is planned.
//! Use only on trusted networks (LAN).
//!
//! Write side (sender): connects to a listener, sends FMKV header + BD-TS data.
//! Read side (receiver): listens for a connection, reads FMKV header + BD-TS data.
//!
//! The FMKV metadata header is the same format as M2tsStream uses — so a
//! NetworkStream reader can hand off to any output stream (MKV, M2TS, etc.)
//! with full metadata (labels, languages, duration).
//! Write side (sender): connects to a listener, sends FMKV header + PES frames.
//! Read side (receiver): listens for a connection, reads FMKV header + PES frames.
use super::{meta, IOStream};
use super::meta;
use crate::disc::DiscTitle;
use std::io::{self, BufReader, BufWriter, Read, Write};
use std::io::{self, BufReader, BufWriter, Write};
use std::net::{TcpListener, TcpStream};
/// I/O buffer size for network reads/writes.
@@ -32,7 +28,6 @@ enum Mode {
pub struct NetworkStream {
disc_title: DiscTitle,
mode: Mode,
finished: bool,
}
impl NetworkStream {
@@ -46,7 +41,6 @@ impl NetworkStream {
writer: BufWriter::with_capacity(NET_BUF_SIZE, stream),
header_written: false,
},
finished: false,
})
}
@@ -64,49 +58,28 @@ impl NetworkStream {
stream.set_nodelay(true)?;
let mut reader = BufReader::with_capacity(NET_BUF_SIZE, stream);
// Read FMKV metadata header (inline, since TcpStream doesn't impl Seek)
let disc_title = meta::read_header_from_stream(&mut reader)?
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"no FMKV metadata header from sender",
)
})?
// Read FMKV metadata header
let disc_title = meta::read_header(&mut reader)?
.ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })?
.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(());
impl crate::pes::Stream for NetworkStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
match &mut self.mode {
Mode::Read { reader } => crate::pes::PesFrame::deserialize(reader),
_ => Err(crate::error::Error::StreamWriteOnly.into()),
}
self.finished = true;
if let Mode::Write { ref mut writer, .. } = self.mode {
writer.flush()?;
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
}
Ok(())
}
}
impl Write for NetworkStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.mode {
Mode::Write {
ref mut writer,
ref mut header_written,
} => {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.mode {
Mode::Write { writer, ref mut header_written, .. } => {
if !*header_written {
if !self.disc_title.streams.is_empty() {
let m = meta::M2tsMeta::from_title(&self.disc_title);
@@ -114,46 +87,32 @@ impl Write for NetworkStream {
}
*header_written = true;
}
writer.write(buf)
frame.serialize(writer)
}
Mode::Read { .. } => Err(io::Error::new(
io::ErrorKind::Unsupported,
"stream opened for reading",
)),
_ => Err(crate::error::Error::StreamReadOnly.into()),
}
}
fn flush(&mut self) -> io::Result<()> {
if let Mode::Write { ref mut writer, .. } = self.mode {
writer.flush()
} else {
Ok(())
fn finish(&mut self) -> io::Result<()> {
if let Mode::Write { writer, .. } = &mut self.mode {
writer.flush()?;
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
}
Ok(())
}
fn info(&self) -> &DiscTitle { &self.disc_title }
}
impl Read for NetworkStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self.mode {
Mode::Read { ref mut reader } => reader.read(buf),
Mode::Write { .. } => Err(io::Error::new(
io::ErrorKind::Unsupported,
"stream opened for writing",
)),
}
}
}
// NetworkStream is PES-only — no IOStream/Read/Write byte interface.
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{
AudioStream, Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream,
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat,
Resolution, SampleRate, Stream, VideoStream,
};
use std::io::{Read, Write};
use std::net::TcpListener;
/// Build a DiscTitle with streams for metadata tests.
fn sample_title() -> DiscTitle {
DiscTitle {
playlist: "NetworkTest".into(),
@@ -165,8 +124,8 @@ mod tests {
Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: "2160p".into(),
frame_rate: "23.976".into(),
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020,
secondary: false,
@@ -175,9 +134,9 @@ mod tests {
Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::TrueHd,
channels: "7.1".into(),
channels: AudioChannels::Surround71,
language: "eng".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: "English".into(),
}),
@@ -185,57 +144,15 @@ mod tests {
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
#[test]
#[ignore] // Requires TCP; may be flaky in CI environments
fn network_listen_connect_roundtrip() {
// Bind to OS-assigned port
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener); // Release so NetworkStream::listen can bind
fn network_pes_roundtrip() {
use crate::pes;
let addr = format!("127.0.0.1:{}", port);
let addr_clone = addr.clone();
// Spawn listener in a thread
let handle = std::thread::spawn(move || {
let mut ns = NetworkStream::listen(&addr_clone).unwrap();
let mut buf = vec![0u8; 4096];
let mut received = Vec::new();
loop {
match ns.read(&mut buf) {
Ok(0) => break,
Ok(n) => received.extend_from_slice(&buf[..n]),
Err(_) => break,
}
}
received
});
// Small delay to let the listener thread bind
std::thread::sleep(std::time::Duration::from_millis(50));
// Connect and write data
let dt = sample_title();
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
let payload = b"Hello from the write side of the network stream!";
writer.write_all(payload).unwrap();
writer.finish().unwrap();
let received = handle.join().unwrap();
// The received data should end with our payload (after the FMKV header)
assert!(
received.windows(payload.len()).any(|w| w == payload),
"payload not found in received data (got {} bytes)",
received.len()
);
}
#[test]
#[ignore] // Requires TCP; may be flaky in CI environments
fn network_metadata_flows() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
@@ -244,35 +161,40 @@ mod tests {
let addr_clone = addr.clone();
let handle = std::thread::spawn(move || {
let ns = NetworkStream::listen(&addr_clone).unwrap();
let info = ns.info().clone();
info
let mut ns = NetworkStream::listen(&addr_clone).unwrap();
let info = pes::Stream::info(&ns).clone();
let mut frames = Vec::new();
while let Ok(Some(f)) = pes::Stream::read(&mut ns) {
frames.push(f);
}
(info, frames)
});
std::thread::sleep(std::time::Duration::from_millis(50));
let dt = sample_title();
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
// Must write at least one byte to trigger header send
writer.write_all(&[0u8; 192]).unwrap();
writer.finish().unwrap();
let frame = pes::PesFrame { track: 0, pts: 90000, keyframe: true, data: vec![0x47; 192] };
pes::Stream::write(&mut writer, &frame).unwrap();
pes::Stream::finish(&mut writer).unwrap();
let info = handle.join().unwrap();
let (info, frames) = handle.join().unwrap();
assert_eq!(info.playlist, "NetworkTest");
assert_eq!(info.duration_secs, 3600.0);
assert_eq!(info.streams.len(), 2);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].track, 0);
assert_eq!(frames[0].pts, 90000);
}
#[test]
fn network_empty_addr_errors() {
let result = NetworkStream::connect("");
assert!(result.is_err(), "empty address should fail");
assert!(result.is_err());
}
#[test]
fn network_no_port_errors() {
// Connecting to an address without a port should fail
let result = NetworkStream::connect("127.0.0.1");
assert!(result.is_err(), "address without port should fail");
assert!(result.is_err());
}
}
+10 -100
View File
@@ -1,114 +1,24 @@
//! NullStream — discards all data. Write-only. For benchmarking.
//! NullStream — discards all data. Write-only PES sink. For benchmarking.
use super::IOStream;
use crate::disc::DiscTitle;
use std::io::{self, Read, Write};
use std::io;
/// Null stream — accepts writes, discards data. For benchmarking rip speed.
/// Null stream — accepts PES writes, discards data. For benchmarking rip speed.
pub struct NullStream {
disc_title: DiscTitle,
bytes_written: u64,
}
impl Default for NullStream {
fn default() -> Self {
Self::new()
}
}
impl NullStream {
pub fn new() -> Self {
pub fn new(title: &DiscTitle) -> Self {
Self {
disc_title: DiscTitle::empty(),
bytes_written: 0,
disc_title: title.clone(),
}
}
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();
}
#[test]
fn null_total_bytes_returns_none() {
let ns = NullStream::new();
assert_eq!(ns.total_bytes(), None);
}
impl crate::pes::Stream for NullStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { Ok(None) }
fn write(&mut self, _: &crate::pes::PesFrame) -> io::Result<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &DiscTitle { &self.disc_title }
}
+1 -1
View File
@@ -82,7 +82,6 @@ impl PsDemuxer {
let mut pos = 0;
while let Some(sc) = find_start_code(&self.buffer, pos) {
if sc + 3 >= self.buffer.len() {
// Not enough bytes to read the start code ID.
break;
@@ -164,6 +163,7 @@ impl PsDemuxer {
packets
}
}
/// Check whether a start code byte is a valid PES stream ID that carries payload.
+211 -190
View File
@@ -1,123 +1,139 @@
//! Stream URL resolver — parses URL strings into IOStream instances.
//! Stream URL resolver — parses URL strings into PES stream instances.
//!
//! Format: `scheme://path`
//!
//! | Scheme | Input | Output | Path |
//! |--------|-------|--------|------|
//! | disc:// | Yes | -- | empty (auto-detect) or /dev/sgN |
//! | m2ts:// | Yes | Yes | file path (required) |
//! | iso:// | Yes | -- | file path (required) |
//! | mkv:// | Yes | Yes | file path (required) |
//! | m2ts:// | 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.
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
use super::disc::{DiscOptions, DiscStream};
use super::iso::IsoStream;
use super::disc::DiscStream;
use super::network::NetworkStream;
use super::null::NullStream;
use super::stdio::StdioStream;
use super::{IOStream, M2tsStream, MkvStream};
use crate::disc::DiscTitle;
use std::io::{self, BufReader, BufWriter};
use std::path::Path;
use super::{M2tsStream, MkvStream};
use std::io;
use std::path::{Path, PathBuf};
/// 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,
pub enum StreamUrl {
/// Optical disc drive. Device path is optional (auto-detect if None).
Disc { device: Option<PathBuf> },
/// MPEG-2 transport stream file.
M2ts { path: PathBuf },
/// Matroska container file.
Mkv { path: PathBuf },
/// Network stream (host:port).
Network { addr: String },
/// Standard I/O (stdin/stdout).
Stdio,
/// ISO disc image file.
Iso { path: PathBuf },
/// Null sink (write-only, discards data).
Null,
/// Unrecognized URL.
Unknown { raw: 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=""
/// ```
impl StreamUrl {
/// The scheme name (e.g. "disc", "mkv", "null").
pub fn scheme(&self) -> &str {
match self {
StreamUrl::Disc { .. } => "disc",
StreamUrl::M2ts { .. } => "m2ts",
StreamUrl::Mkv { .. } => "mkv",
StreamUrl::Network { .. } => "network",
StreamUrl::Stdio => "stdio",
StreamUrl::Iso { .. } => "iso",
StreamUrl::Null => "null",
StreamUrl::Unknown { .. } => "unknown",
}
}
/// The path/address component, or empty string for scheme-only URLs.
pub fn path_str(&self) -> &str {
match self {
StreamUrl::Disc { device: Some(p) } => p.to_str().unwrap_or(""),
StreamUrl::Disc { device: None } => "",
StreamUrl::M2ts { path } | StreamUrl::Mkv { path } | StreamUrl::Iso { path } => {
path.to_str().unwrap_or("")
}
StreamUrl::Network { addr } => addr,
StreamUrl::Stdio | StreamUrl::Null => "",
StreamUrl::Unknown { raw } => raw,
}
}
/// Whether this URL represents a disc source (disc:// or iso://).
pub fn is_disc_source(&self) -> bool {
matches!(self, StreamUrl::Disc { .. } | StreamUrl::Iso { .. })
}
}
/// Parse a URL string into a typed StreamUrl.
pub fn parse_url(url: &str) -> StreamUrl {
if let Some(rest) = url.strip_prefix("disc://") {
return StreamUrl {
scheme: "disc".into(),
path: rest.to_string(),
return if rest.is_empty() {
StreamUrl::Disc { device: None }
} else {
StreamUrl::Disc {
device: Some(PathBuf::from(rest)),
}
};
}
if let Some(rest) = url.strip_prefix("m2ts://") {
return StreamUrl {
scheme: "m2ts".into(),
path: rest.to_string(),
return StreamUrl::M2ts {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("mkv://") {
return StreamUrl {
scheme: "mkv".into(),
path: rest.to_string(),
return StreamUrl::Mkv {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("network://") {
return StreamUrl {
scheme: "network".into(),
path: rest.to_string(),
return StreamUrl::Network {
addr: rest.to_string(),
};
}
if url == "null://" || url.starts_with("null://") {
return StreamUrl {
scheme: "null".into(),
path: String::new(),
};
return StreamUrl::Null;
}
if url == "stdio://" || url.starts_with("stdio://") {
return StreamUrl {
scheme: "stdio".into(),
path: String::new(),
};
return StreamUrl::Stdio;
}
if let Some(rest) = url.strip_prefix("iso://") {
return StreamUrl {
scheme: "iso".into(),
path: rest.to_string(),
return StreamUrl::Iso {
path: PathBuf::from(rest),
};
}
StreamUrl {
scheme: "unknown".into(),
path: url.to_string(),
StreamUrl::Unknown {
raw: 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
),
));
fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
if path.as_os_str().is_empty() {
return Err(crate::error::Error::StreamUrlMissingPath {
scheme: scheme.to_string(),
}.into());
}
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
),
));
if path.file_name().is_none() {
return Err(crate::error::Error::StreamUrlInvalid {
url: format!("{scheme}://{}", path.display()),
}.into());
}
Ok(())
}
@@ -125,133 +141,138 @@ fn validate_file_path(path: &str, scheme: &str) -> io::Result<()> {
/// 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)",
));
return Err(crate::error::Error::StreamUrlMissingPath {
scheme: "network".to_string(),
}.into());
}
if !addr.contains(':') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"network://{} missing port — use network://{}:PORT",
addr, addr
),
));
return Err(crate::error::Error::StreamUrlMissingPort {
addr: addr.to_string(),
}.into());
}
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::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.
#[derive(Default)]
pub struct InputOptions {
pub keydb_path: Option<String>,
pub title_index: Option<usize>,
/// Skip decryption — return raw encrypted bytes.
pub raw: bool,
}
/// Open a PES input stream (produces PES frames).
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::Disc { device } => {
// Open drive, init, scan — caller manages the drive
let mut drive = match device {
Some(ref d) => crate::drive::Drive::open(d)
.map_err(|e| -> io::Error { e.into() })?,
None => crate::drive::find_drive()
.ok_or_else(|| -> io::Error { crate::error::Error::DeviceNotFound { path: String::new() }.into() })?,
};
let _ = drive.wait_ready();
let _ = drive.init();
let _ = drive.probe_disc();
let (mut stream, _disc) = DiscStream::open_drive(
drive,
opts.keydb_path.as_deref(),
opts.title_index.unwrap_or(0),
).map_err(|e| -> io::Error { e.into() })?;
if opts.raw {
stream.set_raw();
}
Ok(Box::new(stream))
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
let scan_opts = match &opts.keydb_path {
Some(p) => crate::disc::ScanOptions::with_keydb(p),
None => crate::disc::ScanOptions::default(),
};
let mut stream = DiscStream::open_iso(&path.to_string_lossy(), opts.title_index, &scan_opts)?;
if opts.raw {
stream.set_raw();
}
Ok(Box::new(stream))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::open(path)
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e)))?;
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::open(reader)?))
}
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::open(path)
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::open(reader)?))
}
StreamUrl::Network { ref addr } => {
validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::listen(addr)?))
}
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::input()))
}
StreamUrl::Null => {
Err(crate::error::Error::StreamWriteOnly.into())
}
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid {
url: raw.clone(),
}.into())
}
}
}
/// Open a PES output stream (consumes PES frames).
pub fn output(
url: &str,
title: &crate::disc::DiscTitle,
) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
let writer: Box<dyn super::WriteSeek> =
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
Ok(Box::new(MkvStream::create(writer, title)?))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e)))?;
let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::create(writer, title)?))
}
StreamUrl::Network { ref addr } => {
validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::connect(addr)?.meta(title)))
}
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::output(title)))
}
StreamUrl::Null => {
Ok(Box::new(NullStream::new(title)))
}
StreamUrl::Disc { .. } => {
Err(crate::error::Error::StreamReadOnly.into())
}
StreamUrl::Iso { .. } => {
Err(crate::error::Error::StreamReadOnly.into())
}
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid {
url: raw.clone(),
}.into())
}
}
}
+61 -75
View File
@@ -1,17 +1,21 @@
//! StdioStream — raw byte pipe via stdin/stdout. Format-agnostic.
//! StdioStream — PES frames via stdin/stdout with FMKV metadata header.
//!
//! The FMKV header carries stream metadata (PIDs, codecs, languages, codec_privates)
//! so the receiving end can set up muxing without scanning the content.
use super::IOStream;
use super::meta;
use crate::disc::DiscTitle;
use std::io::{self, Read, Write};
use std::io::{self, Write};
/// 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.
/// Stdio stream — reads PES from stdin, writes PES to stdout.
/// FMKV metadata header is written/read automatically.
pub struct StdioStream {
disc_title: DiscTitle,
reader: Option<io::Stdin>,
writer: Option<io::Stdout>,
writer: Option<io::BufWriter<io::Stdout>>,
header_written: bool,
header_read: bool,
stored_codec_privates: Vec<Option<Vec<u8>>>,
}
impl StdioStream {
@@ -21,94 +25,76 @@ impl StdioStream {
disc_title: DiscTitle::empty(),
reader: Some(io::stdin()),
writer: None,
header_written: false,
header_read: false,
stored_codec_privates: Vec::new(),
}
}
/// Create a stdio stream for writing (stdout).
pub fn output() -> Self {
pub fn output(title: &DiscTitle) -> Self {
Self {
disc_title: DiscTitle::empty(),
disc_title: title.clone(),
reader: None,
writer: Some(io::stdout()),
writer: Some(io::BufWriter::new(io::stdout())),
header_written: false,
header_read: false,
stored_codec_privates: Vec::new(),
}
}
/// 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()?;
/// Read the FMKV metadata header from stdin on first read.
fn ensure_header_read(&mut self) -> io::Result<()> {
if self.header_read {
return Ok(());
}
self.header_read = true;
if let Some(ref mut r) = self.reader {
if let Ok(Some(m)) = meta::read_header(r) {
let title = m.to_title();
self.stored_codec_privates = title.codec_privates.clone();
self.disc_title = title;
}
}
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 crate::pes::Stream for StdioStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
self.ensure_header_read()?;
match &mut self.reader {
Some(r) => crate::pes::PesFrame::deserialize(r),
None => Err(crate::error::Error::StreamWriteOnly.into()),
}
}
}
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 write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.writer {
Some(ref mut w) => {
if !self.header_written {
if !self.disc_title.streams.is_empty() {
let m = meta::M2tsMeta::from_title(&self.disc_title);
meta::write_header(w, &m)?;
}
self.header_written = true;
}
frame.serialize(w)
}
None => Err(crate::error::Error::StreamReadOnly.into()),
}
}
fn flush(&mut self) -> io::Result<()> {
match self.writer {
Some(ref mut w) => w.flush(),
None => Ok(()),
}
fn finish(&mut self) -> io::Result<()> {
if let Some(w) = &mut self.writer { w.flush()?; }
Ok(())
}
}
fn info(&self) -> &DiscTitle { &self.disc_title }
#[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);
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
self.stored_codec_privates.get(track).and_then(|c| c.clone())
}
#[test]
fn stdio_total_bytes_returns_none() {
let input = StdioStream::input();
assert_eq!(input.total_bytes(), None);
let output = StdioStream::output();
assert_eq!(output.total_bytes(), None);
fn headers_ready(&self) -> bool {
// After first read(), header is parsed and codec_privates populated
self.header_read || self.writer.is_some()
}
}
+65 -31
View File
@@ -104,6 +104,18 @@ pub struct TsDemuxer {
}
impl TsDemuxer {
/// Take the remainder bytes (leftover from last feed() that didn't
/// align to a 192-byte packet boundary).
pub fn take_remainder(&mut self) -> Vec<u8> {
std::mem::take(&mut self.remainder)
}
/// Set the remainder bytes. Used to transfer alignment state from
/// one demuxer to another without losing sync.
pub fn set_remainder(&mut self, data: Vec<u8>) {
self.remainder = data;
}
/// Create a new demuxer tracking the given PIDs.
pub fn new(pids: &[u16]) -> Self {
let mut pid_index = [-1i16; 8192];
@@ -165,6 +177,9 @@ impl TsDemuxer {
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
// Adaptation field present
let af_len = ts[4] as usize;
if af_len > 183 {
continue; // Malformed: AF length exceeds TS payload
}
5 + af_len
} else {
4
@@ -196,9 +211,15 @@ impl TsDemuxer {
}
}
// Save leftover bytes for next call
// Save leftover bytes for next call (cap at one packet to prevent unbounded growth)
if offset < work.len() {
self.remainder.extend_from_slice(&work[offset..]);
let leftover = &work[offset..];
if leftover.len() < BD_TS_PACKET_SIZE {
self.remainder.extend_from_slice(leftover);
} else {
// More than one full packet leftover — something is wrong, discard
self.remainder.clear();
}
}
completed
@@ -245,30 +266,38 @@ fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
let pts_dts_flags = (data[7] >> 6) & 0x03;
let header_data_len = data[8] as usize;
let data_start = 9 + header_data_len;
let data_start = (9 + header_data_len).min(data.len());
let mut pts = None;
let mut dts = None;
if pts_dts_flags >= 2 && data.len() >= 14 {
pts = Some(parse_timestamp(&data[9..14]));
if pts_dts_flags >= 2 && header_data_len >= 5 && data.len() >= 14 {
pts = parse_timestamp(&data[9..14]);
}
if pts_dts_flags == 3 && data.len() >= 19 {
dts = Some(parse_timestamp(&data[14..19]));
if pts_dts_flags == 3 && header_data_len >= 10 && data.len() >= 19 {
dts = parse_timestamp(&data[14..19]);
}
(pts, dts, data_start)
}
/// Parse a 5-byte PTS/DTS timestamp (33 bits in 90kHz).
fn parse_timestamp(data: &[u8]) -> i64 {
/// Validates marker bits per MPEG-2 spec. Returns None on invalid encoding.
fn parse_timestamp(data: &[u8]) -> Option<i64> {
if data.len() < 5 {
return None;
}
// Validate marker bits: byte 2 bit 0 and byte 4 bit 0 must be 1
if (data[2] & 0x01) == 0 || (data[4] & 0x01) == 0 {
return None;
}
let b0 = data[0] as i64;
let b1 = data[1] as i64;
let b2 = data[2] as i64;
let b3 = data[3] as i64;
let b4 = data[4] as i64;
((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1
Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1)
}
// ============================================================
@@ -300,6 +329,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
let section_len = (((data[pat_start + 1] & 0x0F) as usize) << 8)
| data[pat_start + 2] as usize;
let entries_start = pat_start + 8;
if section_len < 4 { offset += BD_TS_PACKET_SIZE; continue; }
let entries_end = pat_start + 3 + section_len - 4;
let mut e = entries_start;
while e + 4 <= data.len() && e < entries_end {
@@ -363,8 +393,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
0x1B => Some(Stream::Video(VideoStream {
pid: es_pid,
codec: Codec::H264,
resolution: "1080p".into(),
frame_rate: String::new(),
resolution: Resolution::R1080p,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -373,8 +403,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
0x24 => Some(Stream::Video(VideoStream {
pid: es_pid,
codec: Codec::Hevc,
resolution: "2160p".into(),
frame_rate: String::new(),
resolution: Resolution::R2160p,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -383,8 +413,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
0xEA => Some(Stream::Video(VideoStream {
pid: es_pid,
codec: Codec::Vc1,
resolution: "1080p".into(),
frame_rate: String::new(),
resolution: Resolution::R1080p,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -393,8 +423,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
0x02 => Some(Stream::Video(VideoStream {
pid: es_pid,
codec: Codec::Mpeg2,
resolution: "1080i".into(),
frame_rate: String::new(),
resolution: Resolution::R1080i,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -403,45 +433,45 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
0x81 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::Ac3,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: String::new(),
})),
0x83 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::TrueHd,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: String::new(),
})),
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::Ac3Plus,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: String::new(),
})),
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::DtsHdMa,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: String::new(),
})),
0x82 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::Dts,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: String::new(),
})),
@@ -495,7 +525,7 @@ pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option<i64> {
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
let pts_dts_flags = (payload[7] >> 6) & 0x03;
if pts_dts_flags >= 2 {
return Some(parse_timestamp(&payload[9..14]));
return parse_timestamp(&payload[9..14]);
}
}
}
@@ -525,7 +555,7 @@ pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option<i64> {
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
let pts_dts_flags = (payload[7] >> 6) & 0x03;
if pts_dts_flags >= 2 {
last_pts = Some(parse_timestamp(&payload[9..14]));
last_pts = parse_timestamp(&payload[9..14]);
}
}
}
@@ -577,14 +607,18 @@ mod tests {
fn test_parse_timestamp() {
// Example: PTS = 0 → encoded as 21 00 01 00 01
let data = [0x21, 0x00, 0x01, 0x00, 0x01];
assert_eq!(parse_timestamp(&data), 0);
assert_eq!(parse_timestamp(&data), Some(0));
// Example: PTS = 90000 (1 second at 90kHz)
// Manual encoding: 33 bits = 0x00015F90
// This is just a sanity check that the parser doesn't crash
let data2 = [0x21, 0x00, 0x07, 0xE9, 0x01]; // approximate
let pts = parse_timestamp(&data2);
assert!(pts >= 0);
assert!(pts.is_some() && pts.unwrap() >= 0);
// Invalid marker bits → returns None
let bad = [0x00, 0x00, 0x00, 0x00, 0x00]; // marker bits wrong
assert_eq!(parse_timestamp(&bad), None);
}
#[test]
+251
View File
@@ -0,0 +1,251 @@
//! BD Transport Stream muxer — PES frames → 192-byte BD-TS packets.
//!
//! Takes PES frames and writes them as BD-TS (Blu-ray transport stream)
//! packets. Each frame is wrapped in a PES header, split into TS packets,
//! and prepended with the 4-byte TP_extra_header.
use std::io::{self, Write};
const SYNC_BYTE: u8 = 0x47;
const TS_PAYLOAD: usize = 184;
pub struct TsMuxer<W: Write> {
writer: W,
pids: Vec<u16>,
continuity: Vec<u8>, // per-PID continuity counter (0-15)
codec_privates: Vec<Option<Vec<u8>>>, // per-track codec_private (for video parameter sets)
params_written: Vec<bool>, // per-track: have we written parameter sets?
}
impl<W: Write> TsMuxer<W> {
pub fn new(writer: W, pids: &[u16]) -> Self {
let n = pids.len();
Self {
writer,
pids: pids.to_vec(),
continuity: vec![0u8; n],
codec_privates: vec![None; n],
params_written: vec![false; n],
}
}
/// Set codec_private data for a track. Used to prepend VPS/SPS/PPS
/// as Annex B NALs before the first keyframe in the transport stream.
pub fn set_codec_private(&mut self, track: usize, data: Vec<u8>) {
if track < self.codec_privates.len() {
self.codec_privates[track] = Some(data);
}
}
/// Write a PES frame as BD-TS packets.
/// Video frame data is expected as length-prefixed NALUs (MKV/PES format)
/// and is converted to Annex B for transport stream.
pub fn write_frame(
&mut self,
track: usize,
pts_ns: i64,
data: &[u8],
) -> io::Result<()> {
if track >= self.pids.len() {
return Ok(()); // unknown track, skip
}
let pid = self.pids[track];
let is_video = (0x1011..=0x101F).contains(&pid);
// For video: convert length-prefixed NALUs to Annex B (start codes)
// On first keyframe, prepend parameter sets from codec_private
let es_data = if is_video && !data.is_empty() {
let mut annex_b = Vec::new();
// Prepend codec_private parameter sets on first keyframe
if !self.params_written[track] {
if let Some(ref cp) = self.codec_privates[track] {
if let Some(params) = hvcc_to_annex_b(cp) {
annex_b.extend_from_slice(&params);
self.params_written[track] = true;
}
}
}
annex_b.extend_from_slice(&length_prefixed_to_annex_b(data));
annex_b
} else {
data.to_vec()
};
// Build PES packet: header + data
let pts_90k = if pts_ns >= 0 {
(pts_ns as u64).saturating_mul(9) / 100_000
} else {
0
};
let pes_header = build_pes_header(pid, pts_90k, es_data.len());
let pes_packet = [&pes_header[..], &es_data[..]].concat();
// Split into TS packets
let mut offset = 0;
let mut first = true;
while offset < pes_packet.len() {
let remaining = pes_packet.len() - offset;
let payload_len = remaining.min(TS_PAYLOAD);
let need_stuffing = payload_len < TS_PAYLOAD;
// TP_extra_header (4 bytes — arrival time, set to 0)
let tp_extra = [0u8; 4];
// TS header (4 bytes)
let cc = self.continuity[track];
self.continuity[track] = (cc + 1) & 0x0F;
let mut ts_header = [0u8; 4];
ts_header[0] = SYNC_BYTE;
ts_header[1] = ((pid >> 8) as u8) & 0x1F;
if first {
ts_header[1] |= 0x40; // PUSI
}
ts_header[2] = pid as u8;
ts_header[3] = 0x10 | cc; // no adaptation, has payload
if need_stuffing {
// Adaptation field for stuffing
let stuff_len = TS_PAYLOAD - payload_len;
ts_header[3] = 0x30 | cc; // adaptation + payload
self.writer.write_all(&tp_extra)?;
self.writer.write_all(&ts_header)?;
// Write adaptation field: length byte + flags byte + 0xFF padding
// stuff_len == 1: AF length = 0 (just the length byte, no flags)
// stuff_len >= 2: AF length = stuff_len-1, flags = 0, rest 0xFF
static STUFF_FF: [u8; 184] = [0xFF; 184];
if stuff_len == 1 {
self.writer.write_all(&[0u8])?; // adaptation_field_length = 0
} else {
self.writer.write_all(&[(stuff_len - 1) as u8])?; // AF length
self.writer.write_all(&[0u8])?; // flags
if stuff_len > 2 {
self.writer.write_all(&STUFF_FF[..stuff_len - 2])?;
}
}
self.writer.write_all(&pes_packet[offset..offset + payload_len])?;
} else {
self.writer.write_all(&tp_extra)?;
self.writer.write_all(&ts_header)?;
self.writer.write_all(&pes_packet[offset..offset + payload_len])?;
}
offset += payload_len;
first = false;
}
Ok(())
}
pub fn finish(&mut self) -> io::Result<()> {
self.writer.flush()
}
}
/// Build a PES packet header for a BD stream.
fn build_pes_header(pid: u16, pts_90k: u64, data_len: usize) -> Vec<u8> {
// Determine stream_id from PID range
let stream_id: u8 = if (0x1011..=0x101F).contains(&pid) {
0xE0 // video
} else {
0xBD // audio, PGS subtitle, or default (private stream 1)
};
let pes_data_len = data_len + 8; // 3 header bytes + 5 PTS bytes + data
let mut header = Vec::with_capacity(14);
// Start code: 00 00 01 stream_id
header.push(0x00);
header.push(0x00);
header.push(0x01);
header.push(stream_id);
// PES packet length (0 = unbounded for video or if too large for u16)
if stream_id == 0xE0 || pes_data_len > 65535 {
header.push(0x00);
header.push(0x00);
} else {
let len = pes_data_len as u16;
header.push((len >> 8) as u8);
header.push(len as u8);
}
// Flags: 10xx xxxx — MPEG-2, PTS present
header.push(0x80); // marker bits
header.push(0x80); // PTS present
// PES header data length
header.push(5); // 5 bytes of PTS
// PTS (5 bytes, 33-bit timestamp with markers)
let pts = pts_90k & 0x1_FFFF_FFFF;
header.push(0x21 | (((pts >> 29) & 0x0E) as u8));
header.push(((pts >> 22) & 0xFF) as u8);
header.push(0x01 | (((pts >> 14) & 0xFE) as u8));
header.push(((pts >> 7) & 0xFF) as u8);
header.push(0x01 | (((pts << 1) & 0xFE) as u8));
header
}
/// Extract NAL arrays from HEVCDecoderConfigurationRecord and convert to Annex B.
/// Returns VPS + SPS + PPS as Annex B NAL units (00 00 00 01 + NAL).
fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
// HEVCDecoderConfigurationRecord: 22 bytes header, then NAL arrays
if hvcc.len() < 23 {
return None;
}
let num_arrays = hvcc[22] as usize;
let mut out = Vec::new();
let mut offset = 23;
for _ in 0..num_arrays {
if offset + 3 > hvcc.len() { break; }
// array: 1 byte (completeness + NAL type), 2 bytes (numNalus)
let _nal_type = hvcc[offset] & 0x3F;
let num_nalus = u16::from_be_bytes([hvcc[offset + 1], hvcc[offset + 2]]) as usize;
offset += 3;
for _ in 0..num_nalus {
if offset + 2 > hvcc.len() { break; }
let nal_len = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
offset += 2;
if offset + nal_len > hvcc.len() { break; }
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
out.extend_from_slice(&hvcc[offset..offset + nal_len]);
offset += nal_len;
}
}
if out.is_empty() { None } else { Some(out) }
}
/// Convert length-prefixed NALUs (4-byte BE length + NAL) to Annex B
/// (00 00 00 01 + NAL). Used for video elementary streams in TS.
fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
let mut offset = 0;
while offset + 4 <= data.len() {
let len = u32::from_be_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]) as usize;
offset += 4;
if offset + len > data.len() {
break;
}
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
out.extend_from_slice(&data[offset..offset + len]);
offset += len;
}
// If data doesn't look like length-prefixed NALs (no valid parse),
// return original data unchanged — it may already be Annex B.
if out.is_empty() && !data.is_empty() {
return data.to_vec();
}
out
}
+108
View File
@@ -0,0 +1,108 @@
//! TsDemuxReader — reads from any source, demuxes BD-TS, produces PES frames.
//!
//! Wraps any Read source with a TsDemuxer + CodecParsers.
//! One implementation used by M2TS, Network, Stdio, and any other BD-TS input.
use super::codec::{self, CodecParser};
use super::ts::TsDemuxer;
use crate::disc::Stream as DiscStream;
use crate::pes::PesFrame;
use std::collections::VecDeque;
use std::io::{self, Read};
const READ_BUF_SIZE: usize = 192 * 1024; // 1024 BD-TS packets
/// Generic BD-TS → PES frame reader.
pub struct TsDemuxReader<R: Read> {
reader: R,
demuxer: TsDemuxer,
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
pending: VecDeque<PesFrame>,
buf: Vec<u8>,
eof: bool,
}
impl<R: Read> TsDemuxReader<R> {
/// Create from a reader and stream metadata.
pub fn new(reader: R, streams: &[DiscStream]) -> Self {
let mut pids = Vec::new();
let mut parsers: Vec<(u16, Box<dyn CodecParser>)> = Vec::new();
let mut pid_to_track = Vec::new();
for (i, s) in streams.iter().enumerate() {
let (pid, c) = match s {
DiscStream::Video(v) => (v.pid, v.codec),
DiscStream::Audio(a) => (a.pid, a.codec),
DiscStream::Subtitle(s) => (s.pid, s.codec),
};
pids.push(pid);
pid_to_track.push((pid, i));
parsers.push((pid, codec::parser_for_codec(c)));
}
Self {
reader,
demuxer: TsDemuxer::new(&pids),
parsers,
pid_to_track,
pending: VecDeque::new(),
buf: vec![0u8; READ_BUF_SIZE],
eof: false,
}
}
/// Get the next PES frame. Returns None at EOF.
pub fn next_frame(&mut self) -> io::Result<Option<PesFrame>> {
if let Some(frame) = self.pending.pop_front() {
return Ok(Some(frame));
}
if self.eof {
return Ok(None);
}
loop {
let n = self.reader.read(&mut self.buf)?;
if n == 0 {
self.eof = true;
return Ok(None);
}
let packets = self.demuxer.feed(&self.buf[..n]);
for pes in &packets {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
for frame in parser.parse(pes) {
self.pending.push_back(PesFrame::from_codec_frame(*track, frame));
}
}
}
}
if let Some(frame) = self.pending.pop_front() {
return Ok(Some(frame));
}
}
}
/// Codec private data for a track.
pub fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self.pid_to_track.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
/// True when all primary video tracks have codec_private.
pub fn headers_ready(&self, streams: &[DiscStream]) -> bool {
for (idx, s) in streams.iter().enumerate() {
if let DiscStream::Video(v) = s {
if !v.secondary && self.codec_private(idx).is_none() {
return false;
}
}
}
true
}
}
+150
View File
@@ -0,0 +1,150 @@
//! Stream — read PES frames in, write PES frames out.
//!
//! A stream is a stream. You read() from it or write() to it.
//! The stream handles its own format internally.
//!
//! disc.read() → PES frame (sectors → decrypt → demux internally)
//! mkv.write(frame) → MKV file (mux internally)
/// One frame of elementary stream data.
#[derive(Debug, Clone)]
pub struct PesFrame {
/// Track index (0-based, matches stream info track order).
pub track: usize,
/// Presentation timestamp in nanoseconds.
pub pts: i64,
/// True if this is a keyframe (IDR for video).
pub keyframe: bool,
/// Raw elementary stream data (NAL units, audio samples, etc).
pub data: Vec<u8>,
}
impl PesFrame {
/// Serialize to bytes: track(1) | pts(8) | keyframe(1) | len(4) | data
pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
if self.track > 255 {
return Err(crate::error::Error::PesInvalidMagic.into());
}
if self.data.len() > u32::MAX as usize {
return Err(crate::error::Error::PesFrameTooLarge {
size: self.data.len(),
}.into());
}
w.write_all(&[self.track as u8])?;
w.write_all(&self.pts.to_le_bytes())?;
w.write_all(&[if self.keyframe { 1 } else { 0 }])?;
w.write_all(&(self.data.len() as u32).to_le_bytes())?;
w.write_all(&self.data)
}
/// Deserialize from bytes. Returns None at EOF.
pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result<Option<Self>> {
const MAX_FRAME_SIZE: usize = 256 * 1024 * 1024; // 256 MB
let mut header = [0u8; 14]; // 1 + 8 + 1 + 4
match r.read_exact(&mut header) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let track = header[0] as usize;
let pts = i64::from_le_bytes([
header[1], header[2], header[3], header[4],
header[5], header[6], header[7], header[8],
]);
let keyframe = header[9] != 0;
let len = u32::from_le_bytes([header[10], header[11], header[12], header[13]]) as usize;
if len > MAX_FRAME_SIZE {
return Err(crate::error::Error::PesFrameTooLarge { size: len }.into());
}
let mut data = vec![0u8; len];
r.read_exact(&mut data)?;
Ok(Some(Self { track, pts, keyframe, data }))
}
/// Create from a codec::Frame with a track index.
pub fn from_codec_frame(track: usize, frame: crate::mux::codec::Frame) -> Self {
Self {
track,
pts: frame.pts_ns,
keyframe: frame.keyframe,
data: frame.data,
}
}
}
/// A stream. Read from it or write to it. Not both.
pub trait Stream {
/// Read the next frame. Returns None at end of stream.
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
/// Write a frame.
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
/// Finalize (flush, write index, close).
fn finish(&mut self) -> std::io::Result<()>;
/// Stream metadata.
fn info(&self) -> &crate::disc::DiscTitle;
/// Codec initialization data for a track (SPS/PPS, etc).
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> { None }
/// True when codec_private is available for all video tracks.
fn headers_ready(&self) -> bool { true }
}
/// Wraps any output stream and counts bytes written.
///
/// Progress tracking is a CLI concern — streams don't know their size.
/// Wrap the output with CountingStream, then query bytes_written().
///
/// ```text
/// let mut output = CountingStream::new(libfreemkv::output(dest, &title)?);
/// while let Ok(Some(frame)) = input.read() {
/// output.write(&frame)?;
/// let pct = output.bytes_written() as f64 / total as f64;
/// }
/// ```
pub struct CountingStream {
inner: Box<dyn Stream>,
written: u64,
}
impl CountingStream {
pub fn new(inner: Box<dyn Stream>) -> Self {
Self { inner, written: 0 }
}
/// Total bytes of PES frame data written through this stream.
pub fn bytes_written(&self) -> u64 {
self.written
}
}
impl Stream for CountingStream {
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
self.inner.read()
}
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()> {
self.written += frame.data.len() as u64;
self.inner.write(frame)
}
fn finish(&mut self) -> std::io::Result<()> {
self.inner.finish()
}
fn info(&self) -> &crate::disc::DiscTitle {
self.inner.info()
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
self.inner.codec_private(track)
}
fn headers_ready(&self) -> bool {
self.inner.headers_ready()
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod mt1959;
use crate::error::Result;
use crate::scsi::ScsiTransport;
pub(crate) trait PlatformDriver {
pub(crate) trait PlatformDriver: Send {
/// Unlock drive + upload firmware if needed.
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
+10 -6
View File
@@ -178,7 +178,7 @@ impl Mt1959 {
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let mut unlocked = false;
for _attempt in 0..6 {
for _attempt in 0..3 {
match self.do_unlock(scsi) {
Ok(_) => {
unlocked = true;
@@ -188,15 +188,17 @@ impl Mt1959 {
return Err(Error::UnlockFailed);
}
Err(_) => {
let ok = if self.mode == MODE_A {
let loaded = if self.mode == MODE_A {
variant_a::load_firmware(self, scsi).is_ok()
} else {
variant_b::load_firmware(self, scsi).is_ok()
};
if ok {
unlocked = true;
break;
if !loaded {
continue;
}
// Firmware upload resets the drive. Give it time to
// fully recover before retrying unlock.
std::thread::sleep(std::time::Duration::from_secs(10));
}
}
}
@@ -319,7 +321,9 @@ impl PlatformDriver for Mt1959 {
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if !self.unlocked {
self.run_init(scsi)?;
// Don't retry init here — if init() failed, probing can't work either.
// Retrying causes repeated USB bus resets on BU40N.
return Ok(());
}
if self.probed {
return Ok(());
+194 -22
View File
@@ -5,9 +5,13 @@ use crate::error::{Error, Result};
use std::path::Path;
const SG_IO: u32 = 0x2285;
const SG_SCSI_RESET: u32 = 0x2284;
const SG_SCSI_RESET_DEVICE: i32 = 1;
const SG_DXFER_NONE: i32 = -1;
const SG_DXFER_TO_DEV: i32 = -2;
const SG_DXFER_FROM_DEV: i32 = -3;
const SG_FLAG_DIRECT_IO: u32 = 1;
const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
#[repr(C)]
#[allow(non_camel_case_types)]
@@ -36,18 +40,24 @@ struct sg_io_hdr {
info: u32,
}
// Compile-time validation: sg_io_hdr must match the kernel's layout.
// 64 bytes on 64-bit, 44 bytes on 32-bit (pointer-size dependent).
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 88);
#[cfg(target_pointer_width = "32")]
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
pub struct SgIoTransport {
fd: i32,
}
impl SgIoTransport {
/// Open a SCSI device for use. Resets the drive first to ensure
/// a known good state, then opens a fresh fd for commands.
pub fn open(device: &Path) -> Result<Self> {
use std::os::unix::ffi::OsStrExt;
let path_bytes = device.as_os_str().as_bytes();
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
c_path.extend_from_slice(path_bytes);
c_path.push(0);
let device = Self::resolve_to_sg(device);
Self::reset(&device)?;
let c_path = Self::to_c_path(&device);
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
@@ -55,29 +65,183 @@ impl SgIoTransport {
)
};
if fd < 0 {
let err = std::io::Error::last_os_error();
return Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
Error::DevicePermission {
path: format!(
"{}: permission denied (try running as root)",
device.display()
),
}
} else {
Error::DeviceNotFound {
path: device.display().to_string(),
}
});
return Self::open_error(&device);
}
Ok(SgIoTransport { fd })
}
/// Reset the drive to a known good state — equivalent to unplug/replug.
/// After reset, the drive is clean and no fd is held open.
///
/// ## Why each step exists
///
/// When a process is killed (SIGKILL/kill -9) mid-SG_IO ioctl, two things
/// go wrong: (1) the kernel's SG driver may have stale pending commands
/// queued for the dead process's fd, and (2) the drive firmware may still
/// be mid-operation (seeking, reading, processing a vendor command).
///
/// A new process opening the same /dev/sg* device gets a fresh fd, but the
/// kernel doesn't automatically abort the dead process's commands — the
/// drive can appear hung on the first SCSI command.
///
/// Additionally, killed processes skip Drop, so the tray may be locked
/// via PREVENT MEDIUM REMOVAL with no process alive to unlock it.
///
/// ## Sequence
///
/// 1. **open** — allocates kernel SG state for this fd
/// 2. **close** — triggers kernel cleanup: aborts any pending SG_IO
/// commands associated with this fd. The key operation —
/// the kernel's sg_release() cancels queued commands.
/// 3. **sleep 2s** — the drive firmware needs time to finish/abort whatever
/// it was doing when the previous process died. Without
/// this, the next command may block on drive-internal state.
/// 4. **open** — fresh fd with no stale commands in the kernel queue
/// 5. **unlock** — ALLOW MEDIUM REMOVAL (CDB 0x1E, prevent=0). Clears
/// any tray lock left by a killed process that never
/// ran its Drop/cleanup.
/// 6. **TUR** — TEST UNIT READY (CDB 0x00) with 3s timeout. If the
/// drive responds, it's in a good state.
/// 7. **escalate** — if TUR fails:
/// - SG_SCSI_RESET (device level) — kernel sends a SCSI
/// bus reset to the device, clearing all firmware state.
/// - STOP + START UNIT (CDB 0x1B) — power-cycles the
/// drive's logical unit, like pressing the eject button
/// and reinserting.
/// 8. **close** — release the fd. Drive is clean, nobody holds it.
pub fn reset(device: &Path) -> Result<()> {
let c_path = Self::to_c_path(device);
// Step 1-2: open + close — flush stale kernel SG_IO state
let probe_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK,
)
};
if probe_fd >= 0 {
unsafe { libc::close(probe_fd) };
}
// Step 3: let drive settle
std::thread::sleep(std::time::Duration::from_secs(2));
// Step 4: open clean fd
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK,
)
};
if fd < 0 {
return Self::open_error(device);
}
// Step 5: unlock tray
let _ = Self::raw_command(fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
// Step 6: TUR — if drive responds, we're done
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
// Step 7: escalate — SG_SCSI_RESET
let mut reset_type: i32 = SG_SCSI_RESET_DEVICE;
unsafe { libc::ioctl(fd, SG_SCSI_RESET as _, &mut reset_type) };
std::thread::sleep(std::time::Duration::from_secs(3));
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
// STOP + START
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x00, 0], 3_000);
std::thread::sleep(std::time::Duration::from_secs(1));
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x01, 0], 3_000);
std::thread::sleep(std::time::Duration::from_secs(3));
let _ = Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000);
}
}
// Step 8: close — drive is clean
unsafe { libc::close(fd) };
Ok(())
}
fn open_error<T>(device: &Path) -> Result<T> {
let err = std::io::Error::last_os_error();
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
Error::DevicePermission {
path: format!("{}: permission denied (try running as root)", device.display()),
}
} else {
Error::DeviceNotFound {
path: device.display().to_string(),
}
})
}
/// Send a raw SCSI command on an fd. Used by reset() before the
/// transport is constructed.
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> std::result::Result<(), ()> {
let mut sense = [0u8; 32];
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
hdr.interface_id = b'S' as i32;
hdr.dxfer_direction = SG_DXFER_NONE;
hdr.cmd_len = cdb.len().min(16) as u8;
hdr.mx_sb_len = sense.len() as u8;
hdr.dxfer_len = 0;
hdr.dxferp = std::ptr::null_mut();
hdr.cmdp = cdb.as_ptr();
hdr.sbp = sense.as_mut_ptr();
hdr.timeout = timeout_ms;
hdr.flags = SG_FLAG_Q_AT_HEAD;
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
if ret < 0 || hdr.status != 0 {
Err(())
} else {
Ok(())
}
}
fn to_c_path(device: &Path) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
let path_bytes = device.as_os_str().as_bytes();
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
c_path.extend_from_slice(path_bytes);
c_path.push(0);
c_path
}
/// Resolve /dev/sr* -> /dev/sg* via sysfs. If already sg, returns as-is.
/// Falls back to the original path if resolution fails.
fn resolve_to_sg(device: &Path) -> std::path::PathBuf {
let dev_name = match device.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return device.to_path_buf(),
};
if dev_name.starts_with("sg") {
return device.to_path_buf();
}
if dev_name.starts_with("sr") {
let sg_dir = format!("/sys/class/block/{}/device/scsi_generic", dev_name);
if let Ok(mut entries) = std::fs::read_dir(&sg_dir) {
if let Some(Ok(entry)) = entries.next() {
let sg_name = entry.file_name();
return std::path::PathBuf::from(format!(
"/dev/{}",
sg_name.to_string_lossy()
));
}
}
}
device.to_path_buf()
}
}
impl Drop for SgIoTransport {
fn drop(&mut self) {
unsafe {
libc::close(self.fd);
}
// Unlock tray before closing — don't leave it locked
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
unsafe { libc::close(self.fd) };
}
}
@@ -117,6 +281,14 @@ impl ScsiTransport for SgIoTransport {
hdr.cmdp = cdb.as_ptr();
hdr.sbp = sense.as_mut_ptr();
hdr.timeout = timeout_ms;
if dxfer_direction == SG_DXFER_FROM_DEV
&& data.len() >= 4096
&& (data.as_ptr() as usize) % 4096 == 0
{
hdr.flags = SG_FLAG_DIRECT_IO | SG_FLAG_Q_AT_HEAD;
} else {
hdr.flags = SG_FLAG_Q_AT_HEAD;
}
let ret = unsafe { libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
+21
View File
@@ -168,6 +168,9 @@ pub struct MacScsiTransport {
exclusive: bool,
}
// IOKit COM interface pointers are Mach port references — safe to send between threads.
unsafe impl Send for MacScsiTransport {}
impl MacScsiTransport {
pub fn open(device: &Path) -> Result<Self> {
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
@@ -245,6 +248,24 @@ impl MacScsiTransport {
exclusive: true,
})
}
/// Reset the drive to a known good state.
/// On macOS, we open the device, release exclusive access, wait for
/// the system to reclaim it, then the next open() re-acquires.
/// IOKit's USB layer handles device-level resets internally when the
/// exclusive access is released and re-acquired.
///
/// NOTE: untested — macOS reset may need IOUSBDeviceInterface::ResetDevice()
/// for USB drives. This is a best-effort implementation.
pub fn reset(device: &Path) -> Result<()> {
// Opening and immediately dropping triggers release of exclusive access
// which forces IOKit to reset the device state.
if let Ok(transport) = Self::open(device) {
drop(transport); // Drop releases exclusive access + closes plugin
}
std::thread::sleep(std::time::Duration::from_secs(2));
Ok(())
}
}
impl Drop for MacScsiTransport {
+27 -2
View File
@@ -50,7 +50,7 @@ pub struct ScsiResult {
}
/// Low-level SCSI transport — one implementation per platform.
pub trait ScsiTransport {
pub trait ScsiTransport: Send {
fn execute(
&mut self,
cdb: &[u8],
@@ -60,7 +60,7 @@ pub trait ScsiTransport {
) -> Result<ScsiResult>;
}
// ── Platform-agnostic open ──────────────────────────────────────────────────
// ── Platform-agnostic open / reset ──────────────────────────────────────────
/// Open a SCSI transport for the given device path.
/// Selects the right backend for the current platform.
@@ -88,6 +88,31 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
}
}
/// Reset a SCSI device to a known good state. Platform-specific.
/// On Linux: open/close fd cycle + TUR + SG_SCSI_RESET escalation.
pub fn reset(device: &Path) -> Result<()> {
#[cfg(target_os = "linux")]
{
linux::SgIoTransport::reset(device)
}
#[cfg(target_os = "macos")]
{
macos::MacScsiTransport::reset(device)
}
#[cfg(target_os = "windows")]
{
windows::SptiTransport::reset(device)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = device;
Ok(())
}
}
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
/// SCSI INQUIRY response.
+49
View File
@@ -136,6 +136,55 @@ impl SptiTransport {
Ok(SptiTransport { handle })
}
/// Reset the drive to a known good state.
/// Opens the device, sends IOCTL_STORAGE_RESET_DEVICE to reset
/// the USB/SCSI bus, then closes. Same concept as SG_SCSI_RESET on Linux.
pub fn reset(device: &Path) -> Result<()> {
const IOCTL_STORAGE_RESET_DEVICE: u32 = 0x002D1004;
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
let win_path = normalize_device_path(dev_str);
let wide: Vec<u16> = win_path.encode_utf16().chain(std::iter::once(0)).collect();
// Open
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null(),
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
std::ptr::null(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Ok(()); // can't open — skip reset, not fatal
}
// Send device reset
let mut returned: u32 = 0;
unsafe {
DeviceIoControl(
handle,
IOCTL_STORAGE_RESET_DEVICE,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
0,
&mut returned,
std::ptr::null_mut(),
);
}
// Close and wait for drive to settle
unsafe { CloseHandle(handle) };
std::thread::sleep(std::time::Duration::from_secs(2));
Ok(())
}
}
impl Drop for SptiTransport {
+48 -2
View File
@@ -1,14 +1,60 @@
//! SectorReader — trait for reading 2048-byte disc sectors.
//!
//! Implemented by DriveSession (SCSI) and IsoFile (file-backed).
//! Implemented by Drive (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 {
pub trait SectorReader: Send {
/// Read `count` sectors starting at `lba` into `buf`.
/// `buf` must be at least `count * 2048` bytes.
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
/// Total capacity in sectors, if known.
fn capacity(&self) -> u32 { 0 }
}
/// SectorReader backed by a file (ISO image).
/// Seeks to lba * 2048, reads count * 2048 bytes.
pub struct FileSectorReader {
file: std::io::BufReader<std::fs::File>,
capacity: u32,
}
impl FileSectorReader {
pub fn open(path: &str) -> std::io::Result<Self> {
let file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
let sectors = len / 2048;
if sectors > u32::MAX as u64 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{path}: image too large, max ~8 TB"),
));
}
let capacity = sectors as u32;
Ok(Self {
file: std::io::BufReader::with_capacity(4 * 1024 * 1024, file),
capacity,
})
}
}
impl SectorReader for FileSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
use std::io::{Read, Seek, SeekFrom};
let offset = lba as u64 * 2048;
let bytes = count as usize * 2048;
self.file.seek(SeekFrom::Start(offset))
.map_err(|e| crate::error::Error::IoError { source: e })?;
self.file.read_exact(&mut buf[..bytes])
.map_err(|e| crate::error::Error::IoError { source: e })?;
Ok(bytes)
}
fn capacity(&self) -> u32 {
self.capacity
}
}
+48 -4
View File
@@ -187,6 +187,50 @@ impl UdfFs {
Ok(merged)
}
/// All sector ranges that contain data (metadata + all files including STREAM).
/// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps.
pub fn all_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure sectors
let meta_end = self.metadata_start + self.metadata_sectors;
ranges.push((0, meta_end));
// Walk entire tree including STREAM directories
self.collect_all_file_ranges(reader, &self.root, &mut ranges)?;
// Merge overlapping/adjacent ranges and sort
ranges.sort_by_key(|r| r.0);
let merged = merge_ranges(&ranges);
Ok(merged)
}
fn collect_all_file_ranges(
&self,
reader: &mut dyn SectorReader,
entry: &DirEntry,
ranges: &mut Vec<(u32, u32)>,
) -> Result<()> {
for child in &entry.entries {
if child.is_dir {
self.collect_all_file_ranges(reader, child, ranges)?;
} else {
// Include the ICB sector
ranges.push((self.meta_to_abs(child.meta_lba), 1));
// Include ALL file data extents (large m2ts files have many)
if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
for (data_lba, data_len) in extents {
let abs_start = self.partition_start + data_lba;
let sector_count = (data_len as u64).div_ceil(2048) as u32;
ranges.push((abs_start, sector_count));
}
}
}
}
Ok(())
}
fn collect_file_ranges(
&self,
reader: &mut dyn SectorReader,
@@ -288,7 +332,7 @@ impl UdfFs {
let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]);
let extent_type = raw_len >> 30;
let data_len = raw_len & 0x3FFFFFFF;
let data_len = raw_len & 0x3FFF_FFFF;
let data_lba =
u32::from_le_bytes([icb[off + 4], icb[off + 5], icb[off + 6], icb[off + 7]]);
@@ -455,7 +499,7 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
meta_icb[ad_off + 1],
meta_icb[ad_off + 2],
meta_icb[ad_off + 3],
]) & 0x3FFFFFFF;
]) & 0x3FFF_FFFF;
metadata_size_bytes = ad_len;
let ad_pos = u32::from_le_bytes([
meta_icb[ad_off + 4],
@@ -543,7 +587,7 @@ fn read_directory(
icb[ad_off + 1],
icb[ad_off + 2],
icb[ad_off + 3],
]) & 0x3FFFFFFF;
]) & 0x3FFF_FFFF;
let pos = u32::from_le_bytes([
icb[ad_off + 4],
icb[ad_off + 5],
@@ -565,7 +609,7 @@ fn read_directory(
icb[ad_off + 1],
icb[ad_off + 2],
icb[ad_off + 3],
]) & 0x3FFFFFFF;
]) & 0x3FFF_FFFF;
let pos = u32::from_le_bytes([
icb[ad_off + 4],
icb[ad_off + 5],
+57 -20
View File
@@ -363,8 +363,7 @@ fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
/// The standard AACS IV, copied here independently so we are NOT importing
/// the library's constant — this IS the cross-validation reference value.
const CROSS_AACS_IV: [u8; 16] = [
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3,
0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
];
/// Build a plaintext aligned unit with TS sync markers and recognisable
@@ -373,8 +372,8 @@ const CROSS_AACS_IV: [u8; 16] = [
#[test]
fn aacs_cross_validation_encrypt_then_decrypt() {
let unit_key: [u8; 16] = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32,
0x10,
];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
@@ -403,7 +402,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
for i in 0..16 {
dk[i] = derived[i] ^ header[i];
}
ref_aes_cbc_encrypt(&dk, &CROSS_AACS_IV, &mut plaintext[16..aacs::ALIGNED_UNIT_LEN]);
ref_aes_cbc_encrypt(
&dk,
&CROSS_AACS_IV,
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
);
// Sanity: ciphertext should differ
assert_ne!(
@@ -414,7 +417,10 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
// -- Decrypt with the library --
let ok = aacs::decrypt_unit(&mut plaintext, &unit_key);
assert!(ok, "decrypt_unit returned false (TS sync verification failed)");
assert!(
ok,
"decrypt_unit returned false (TS sync verification failed)"
);
assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared");
// Compare (byte 0 flag was cleared)
@@ -432,8 +438,8 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
#[test]
fn aacs_cross_validation_alternate_key() {
let unit_key: [u8; 16] = [
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08,
];
let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
@@ -452,7 +458,11 @@ fn aacs_cross_validation_alternate_key() {
for i in 0..16 {
dk[i] = derived[i] ^ header[i];
}
ref_aes_cbc_encrypt(&dk, &CROSS_AACS_IV, &mut plaintext[16..aacs::ALIGNED_UNIT_LEN]);
ref_aes_cbc_encrypt(
&dk,
&CROSS_AACS_IV,
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
);
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
@@ -469,8 +479,8 @@ fn aacs_cross_validation_alternate_key() {
#[test]
fn aacs_bus_decrypt_cross_validation() {
let read_data_key: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00,
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
0x00,
];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
@@ -552,10 +562,22 @@ fn css_roundtrip_with_snapshot() {
#[test]
fn css_roundtrip_multiple_keys() {
let cases: &[([u8; 5], [u8; 5])] = &[
([0x00, 0x00, 0x00, 0x00, 0x00], [0x00, 0x00, 0x00, 0x00, 0x00]),
([0xFF, 0xFF, 0xFF, 0xFF, 0xFF], [0xFF, 0xFF, 0xFF, 0xFF, 0xFF]),
([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]),
([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]),
(
[0x00, 0x00, 0x00, 0x00, 0x00],
[0x00, 0x00, 0x00, 0x00, 0x00],
),
(
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
),
(
[0x01, 0x02, 0x03, 0x04, 0x05],
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
),
(
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
[0x12, 0x34, 0x56, 0x78, 0x9A],
),
];
for (idx, (key, seed)) in cases.iter().enumerate() {
@@ -594,11 +616,26 @@ fn css_roundtrip_multiple_keys() {
#[test]
fn css_stevenson_attack_validates_cracked_key() {
let candidates: &[([u8; 5], [u8; 5])] = &[
([0x42, 0x13, 0x37, 0xBE, 0xEF], [0x11, 0x22, 0x33, 0x44, 0x55]),
([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]),
([0x10, 0x20, 0x30, 0x40, 0x50], [0x05, 0x06, 0x07, 0x08, 0x09]),
([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]),
([0x55, 0xAA, 0x55, 0xAA, 0x55], [0x00, 0x00, 0x00, 0x00, 0x00]),
(
[0x42, 0x13, 0x37, 0xBE, 0xEF],
[0x11, 0x22, 0x33, 0x44, 0x55],
),
(
[0x01, 0x02, 0x03, 0x04, 0x05],
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
),
(
[0x10, 0x20, 0x30, 0x40, 0x50],
[0x05, 0x06, 0x07, 0x08, 0x09],
),
(
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
[0x12, 0x34, 0x56, 0x78, 0x9A],
),
(
[0x55, 0xAA, 0x55, 0xAA, 0x55],
[0x00, 0x00, 0x00, 0x00, 0x00],
),
];
let mut any_cracked = false;
+132 -6
View File
@@ -143,9 +143,15 @@ fn scan_options_with_keydb_pathbuf() {
// ── detect_format integration tests ───────────────────────────────────────
use libfreemkv::{Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream};
use libfreemkv::{
Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
};
fn title_with_video(codec: Codec, resolution: &str, content_format: ContentFormat) -> DiscTitle {
fn title_with_video(
codec: Codec,
resolution: Resolution,
content_format: ContentFormat,
) -> DiscTitle {
DiscTitle {
playlist: "00800.mpls".into(),
playlist_id: 800,
@@ -155,8 +161,8 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma
streams: vec![Stream::Video(VideoStream {
pid: 0x1011,
codec,
resolution: resolution.into(),
frame_rate: "23.976".into(),
resolution,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -165,6 +171,7 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma
chapters: Vec::new(),
extents: Vec::new(),
content_format,
codec_privates: Vec::new(),
}
}
@@ -191,13 +198,13 @@ fn disc_title_duration_display_edge_cases() {
#[test]
fn content_format_default_bdts() {
let t = title_with_video(Codec::H264, "1080p", ContentFormat::BdTs);
let t = title_with_video(Codec::H264, Resolution::R1080p, ContentFormat::BdTs);
assert_eq!(t.content_format, ContentFormat::BdTs);
}
#[test]
fn content_format_dvd_mpegps() {
let t = title_with_video(Codec::Mpeg2, "480i", ContentFormat::MpegPs);
let t = title_with_video(Codec::Mpeg2, Resolution::R480i, ContentFormat::MpegPs);
assert_eq!(t.content_format, ContentFormat::MpegPs);
}
@@ -368,6 +375,125 @@ fn resolve_encryption_no_aacs_dir() {
assert!(disc.aacs.is_none(), "aacs should be None without /AACS dir");
}
// ── Batch count arithmetic tests ──────────────────────────────────────────
// Regression tests for the u16 truncation bug: when (remaining as u16) was
// used instead of remaining.min(batch as u32) as u16, any remaining count
// that was a multiple of 65536 would truncate to 0, causing an infinite loop.
/// Simulates the fixed batch count calculation from pipe.rs / drive.rs
fn safe_batch_count(remaining: u32, batch_sectors: u16) -> u16 {
remaining.min(batch_sectors as u32) as u16
}
/// Simulates the BUGGY calculation that caused the infinite loop
fn buggy_batch_count(remaining: u32, batch_sectors: u16) -> u16 {
(remaining as u16).min(batch_sectors)
}
#[test]
fn batch_count_normal() {
// Normal case: remaining > batch_sectors
assert_eq!(safe_batch_count(1000, 60), 60);
assert_eq!(safe_batch_count(47533152, 60), 60);
}
#[test]
fn batch_count_last_batch() {
// Last batch: remaining < batch_sectors
assert_eq!(safe_batch_count(30, 60), 30);
assert_eq!(safe_batch_count(1, 60), 1);
}
#[test]
fn batch_count_exact_boundary() {
// Exact boundary: remaining == batch_sectors
assert_eq!(safe_batch_count(60, 60), 60);
}
#[test]
fn batch_count_u16_overflow_regression() {
// THE BUG: remaining is a multiple of 65536 → truncates to 0
// 47513600 = 725 * 65536, lower 16 bits = 0
let remaining: u32 = 47533152 - 19552; // = 47513600
assert_eq!(remaining, 47513600);
assert_eq!(remaining % 65536, 0, "remaining should be multiple of 65536");
// Buggy version produces 0 → infinite loop
assert_eq!(buggy_batch_count(remaining, 60), 0);
// Fixed version produces 60
assert_eq!(safe_batch_count(remaining, 60), 60);
}
#[test]
fn batch_count_other_u16_overflow_values() {
// Other multiples of 65536
assert_eq!(safe_batch_count(65536, 60), 60);
assert_eq!(safe_batch_count(131072, 60), 60);
assert_eq!(safe_batch_count(65536 * 100, 60), 60);
// Verify buggy version fails on all of these
assert_eq!(buggy_batch_count(65536, 60), 0);
assert_eq!(buggy_batch_count(131072, 60), 0);
assert_eq!(buggy_batch_count(65536 * 100, 60), 0);
}
#[test]
fn batch_count_near_u16_boundary() {
// Values just below and above 65536
assert_eq!(safe_batch_count(65535, 60), 60);
assert_eq!(safe_batch_count(65536, 60), 60);
assert_eq!(safe_batch_count(65537, 60), 60);
// Buggy: 65535 as u16 = 65535, min(60) = 60 (OK by accident)
assert_eq!(buggy_batch_count(65535, 60), 60);
// Buggy: 65536 as u16 = 0, min(60) = 0 (BUG)
assert_eq!(buggy_batch_count(65536, 60), 0);
// Buggy: 65537 as u16 = 1, min(60) = 1 (wrong but doesn't loop)
assert_eq!(buggy_batch_count(65537, 60), 1);
}
#[test]
fn batch_count_real_disc_sizes() {
let batch: u16 = 60;
// DVD-5: ~2,295,104 sectors
assert_eq!(safe_batch_count(2295104, batch), 60);
// BD-25: ~12,219,392 sectors
assert_eq!(safe_batch_count(12219392, batch), 60);
// BD-50: ~24,438,784 sectors
assert_eq!(safe_batch_count(24438784, batch), 60);
// UHD BD-66: ~33,554,432 sectors
assert_eq!(safe_batch_count(33554432, batch), 60);
// UHD BD-100: ~47,533,152 sectors
assert_eq!(safe_batch_count(47533152, batch), 60);
// Last few sectors of each
assert_eq!(safe_batch_count(52, batch), 52);
assert_eq!(safe_batch_count(3, batch), 3);
}
#[test]
fn batch_count_zero_remaining() {
// Zero remaining should produce 0 (loop exits before this)
assert_eq!(safe_batch_count(0, 60), 0);
}
#[test]
fn batch_count_max_batch_sizes() {
// Test with different batch sizes used by detect_max_batch_sectors
for &batch in &[3u16, 6, 9, 30, 60, 120, 240, 510] {
// Large remaining should always return batch
assert_eq!(safe_batch_count(47533152, batch), batch);
// Small remaining should return remaining
assert_eq!(safe_batch_count(1, batch), 1);
}
}
#[test]
fn resolve_encryption_no_keydb() {
// A UDF image with /AACS directory but no keydb path -> aacs is None
+162 -341
View File
@@ -1,8 +1,9 @@
//! Integration tests for the IOStream pipeline.
//! Integration tests for the PES stream pipeline.
use libfreemkv::mux::meta::M2tsMeta;
use libfreemkv::pes::Stream as PesStream;
use libfreemkv::*;
use std::io::{Cursor, Read, Write};
use std::io::{Cursor, Write};
fn sample_disc_title() -> DiscTitle {
DiscTitle {
@@ -15,8 +16,8 @@ fn sample_disc_title() -> DiscTitle {
Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: "2160p".into(),
frame_rate: "23.976".into(),
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -25,18 +26,18 @@ fn sample_disc_title() -> DiscTitle {
Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::TrueHd,
channels: "7.1".into(),
channels: AudioChannels::Surround71,
language: "eng".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: "English Atmos".into(),
}),
Stream::Audio(AudioStream {
pid: 0x1101,
codec: Codec::Ac3,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "fra".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: "French".into(),
}),
@@ -51,6 +52,7 @@ fn sample_disc_title() -> DiscTitle {
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
@@ -59,131 +61,131 @@ fn sample_disc_title() -> DiscTitle {
#[test]
fn parse_url_disc() {
let u = parse_url("disc://");
assert_eq!(u.scheme, "disc");
assert_eq!(u.path, "");
assert_eq!(u.scheme(), "disc");
assert_eq!(u.path_str(), "");
}
#[test]
fn parse_url_disc_device() {
let u = parse_url("disc:///dev/sg4");
assert_eq!(u.scheme, "disc");
assert_eq!(u.path, "/dev/sg4");
assert_eq!(u.scheme(), "disc");
assert_eq!(u.path_str(), "/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");
assert_eq!(u.scheme(), "mkv");
assert_eq!(u.path_str(), "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");
assert_eq!(u.scheme(), "network");
assert_eq!(u.path_str(), "10.0.0.1:9000");
}
#[test]
fn parse_url_bare_path_rejected() {
let u = parse_url("Dune.mkv");
assert_eq!(u.scheme, "unknown");
assert_eq!(u.scheme(), "unknown");
}
#[test]
fn parse_url_null() {
let u = parse_url("null://");
assert_eq!(u.scheme, "null");
assert_eq!(u.path, "");
assert_eq!(u.scheme(), "null");
assert_eq!(u.path_str(), "");
}
#[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");
assert_eq!(u.scheme(), "m2ts");
assert_eq!(u.path_str(), "/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");
assert_eq!(u.scheme(), "m2ts");
assert_eq!(u.path_str(), "Dune.m2ts");
}
#[test]
fn open_input_bare_path_errors() {
let result = libfreemkv::open_input("Dune.mkv", &libfreemkv::InputOptions::default());
let result = libfreemkv::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);
assert!(msg.contains("not a valid stream URL") || msg.contains("E9002"), "got: {}", msg);
}
#[test]
fn open_output_bare_path_errors() {
let dt = sample_disc_title();
let result = libfreemkv::open_output("Dune.mkv", &dt);
let result = libfreemkv::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);
assert!(msg.contains("not a valid stream URL") || msg.contains("E9002"), "got: {}", msg);
}
#[test]
fn open_input_m2ts_empty_path_errors() {
let result = libfreemkv::open_input("m2ts://", &libfreemkv::InputOptions::default());
let result = libfreemkv::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);
assert!(msg.contains("requires a file path") || msg.contains("E9003"), "got: {}", msg);
}
#[test]
fn open_output_null_input_errors() {
let result = libfreemkv::open_input("null://", &libfreemkv::InputOptions::default());
let result = libfreemkv::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);
assert!(msg.contains("write-only") || msg.contains("E9001"), "got: {}", msg);
}
#[test]
fn open_output_disc_errors() {
let dt = sample_disc_title();
let result = libfreemkv::open_output("disc://", &dt);
let result = libfreemkv::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);
assert!(msg.contains("read-only") || msg.contains("E9000"), "got: {}", msg);
}
#[test]
fn open_input_network_no_port_errors() {
let result = libfreemkv::open_input("network://10.0.0.1", &libfreemkv::InputOptions::default());
let result = libfreemkv::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);
assert!(msg.contains("PES pipeline") || msg.contains("missing port") || msg.contains("E9004"), "got: {}", msg);
}
#[test]
fn parse_url_stdio() {
let u = parse_url("stdio://");
assert_eq!(u.scheme, "stdio");
assert_eq!(u.path, "");
assert_eq!(u.scheme(), "stdio");
assert_eq!(u.path_str(), "");
}
// ── M2TS metadata roundtrip ───────────────────────────────────
@@ -201,7 +203,7 @@ fn m2ts_meta_roundtrip() {
// Check video
if let Stream::Video(v) = &restored.streams[0] {
assert_eq!(v.codec, Codec::Hevc);
assert_eq!(v.resolution, "2160p");
assert_eq!(v.resolution, Resolution::R2160p);
assert_eq!(v.label, "Main");
} else {
panic!("expected video");
@@ -261,124 +263,73 @@ fn m2ts_header_write_read() {
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
// Write PES frames 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();
let mut stream = M2tsStream::create(output, &dt).unwrap();
// Write some PES frames
for i in 0..5u8 {
let frame = libfreemkv::pes::PesFrame {
track: 0,
pts: i as i64 * 1_000_000,
keyframe: i == 0,
data: vec![i; 100],
};
stream.write(&frame).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();
// Verify the info is correct
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 ───────────────────────────
// ── M2tsStream PES frame roundtrip ───────────────────────────
#[test]
fn m2ts_passthrough_preserves_data() {
let dt = sample_disc_title();
fn m2ts_pes_frame_roundtrip() {
// PesFrame serialize/deserialize roundtrip
let frame = libfreemkv::pes::PesFrame {
track: 2,
pts: 1_234_567_890,
keyframe: true,
data: vec![0xDE; 200],
};
// 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, byte) in pkt.iter_mut().enumerate().skip(8) {
*byte = i.wrapping_add(j as u8);
}
original.extend_from_slice(&pkt);
}
let mut buf = Vec::new();
frame.serialize(&mut buf).unwrap();
// 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);
let mut cursor = Cursor::new(&buf);
let restored = libfreemkv::pes::PesFrame::deserialize(&mut cursor)
.unwrap()
.unwrap();
// 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);
assert_eq!(restored.track, frame.track);
assert_eq!(restored.pts, frame.pts);
assert_eq!(restored.keyframe, frame.keyframe);
assert_eq!(restored.data, frame.data);
}
// ── IOStream trait ────────────────────────────────────────────
// ── PES Stream trait ─────────────────────────────────────────
#[test]
fn m2ts_implements_iostream() {
fn m2ts_implements_pes_stream() {
let dt = sample_disc_title();
let output = Cursor::new(Vec::new());
let stream = M2tsStream::new(output).meta(&dt);
let stream = M2tsStream::create(output, &dt).unwrap();
let mut boxed: Box<dyn IOStream> = Box::new(stream);
let boxed: Box<dyn PesStream> = 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 dt = sample_disc_title();
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());
let mut stream = M2tsStream::create(output, &dt).unwrap();
assert!(stream.read().is_err());
}
// ── DiscTitle::empty ──────────────────────────────────────────
@@ -413,8 +364,8 @@ fn meta_codec_roundtrip() {
streams.push(Stream::Video(VideoStream {
pid: (0x1011 + i) as u16,
codec,
resolution: "1080p".into(),
frame_rate: "23.976".into(),
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -425,9 +376,9 @@ fn meta_codec_roundtrip() {
streams.push(Stream::Audio(AudioStream {
pid: (0x1100 + i) as u16,
codec,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "eng".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: String::new(),
}));
@@ -452,6 +403,7 @@ fn meta_codec_roundtrip() {
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
};
let meta = M2tsMeta::from_title(&dt);
@@ -486,6 +438,7 @@ fn meta_empty_streams() {
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
};
let meta = M2tsMeta::from_title(&dt);
@@ -505,12 +458,13 @@ fn meta_all_stream_types() {
clips: Vec::new(),
chapters: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
streams: vec![
Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: "2160p".into(),
frame_rate: "23.976".into(),
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -519,9 +473,9 @@ fn meta_all_stream_types() {
Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::TrueHd,
channels: "7.1".into(),
channels: AudioChannels::Surround71,
language: "eng".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: "Primary Audio".into(),
}),
@@ -535,9 +489,9 @@ fn meta_all_stream_types() {
Stream::Audio(AudioStream {
pid: 0x1110,
codec: Codec::Ac3,
channels: "stereo".into(),
channels: AudioChannels::Stereo,
language: "eng".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: true,
label: "Commentary".into(),
}),
@@ -553,7 +507,7 @@ fn meta_all_stream_types() {
// Video preserved
if let Stream::Video(v) = &restored.streams[0] {
assert_eq!(v.codec, Codec::Hevc);
assert_eq!(v.resolution, "2160p");
assert_eq!(v.resolution, Resolution::R2160p);
assert_eq!(v.label, "Primary");
assert!(!v.secondary);
} else {
@@ -563,7 +517,7 @@ fn meta_all_stream_types() {
// Primary audio preserved
if let Stream::Audio(a) = &restored.streams[1] {
assert_eq!(a.codec, Codec::TrueHd);
assert_eq!(a.channels, "7.1");
assert_eq!(a.channels, AudioChannels::Surround71);
assert!(!a.secondary);
} else {
panic!("expected audio");
@@ -591,21 +545,20 @@ fn meta_all_stream_types() {
#[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);
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(Cursor::new(Vec::new()));
let mut stream = MkvStream::create(writer, &dt).unwrap();
// Write some fake BD-TS packets (they won't produce valid MKV frames
// Write some fake PES frames (they won't produce valid MKV content
// 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();
let frame = libfreemkv::pes::PesFrame {
track: 0,
pts: i as i64 * 1_000_000,
keyframe: i == 0,
data: vec![i; 100],
};
stream.write(&frame).unwrap();
}
// finish should not panic even without valid codec data
@@ -614,9 +567,9 @@ fn mkvstream_write_finish() {
#[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 writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(Cursor::new(Vec::new()));
let stream = MkvStream::create(writer, &dt).unwrap();
let info = stream.info();
assert_eq!(info.playlist, "Test Movie");
@@ -642,30 +595,30 @@ fn mkvstream_roundtrip_bdts() {
streams: vec![Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::Ac3,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "eng".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: "English".into(),
})],
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
};
let output = Cursor::new(Vec::new());
let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024);
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(Cursor::new(Vec::new()));
let mut stream = MkvStream::create(writer, &dt).unwrap();
// Write BD-TS packets targeting the audio PID 0x1100
// Write PES frames targeting the audio track
for i in 0..10u8 {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
// PID 0x1100
pkt[5] = 0x11;
pkt[6] = 0x00;
pkt[7] = 0x10;
pkt[8] = i;
stream.write_all(&pkt).unwrap();
let frame = libfreemkv::pes::PesFrame {
track: 0,
pts: i as i64 * 1_000_000,
keyframe: true,
data: vec![i; 100],
};
stream.write(&frame).unwrap();
}
stream.finish().unwrap();
@@ -688,8 +641,8 @@ fn mkvstream_meta_preserves_all_streams() {
Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::H264,
resolution: "1080p".into(),
frame_rate: "23.976".into(),
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -698,18 +651,18 @@ fn mkvstream_meta_preserves_all_streams() {
Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::Ac3,
channels: "5.1".into(),
channels: AudioChannels::Surround51,
language: "eng".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: "English".into(),
}),
Stream::Audio(AudioStream {
pid: 0x1101,
codec: Codec::DtsHdMa,
channels: "7.1".into(),
channels: AudioChannels::Surround71,
language: "fra".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: "French".into(),
}),
@@ -731,10 +684,11 @@ fn mkvstream_meta_preserves_all_streams() {
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
};
let output = Cursor::new(Vec::new());
let stream = MkvStream::new(output).meta(&dt);
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(Cursor::new(Vec::new()));
let stream = MkvStream::create(writer, &dt).unwrap();
let info = stream.info();
assert_eq!(info.streams.len(), 5, "all 5 streams should be preserved");
@@ -781,8 +735,8 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
streams: vec![Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::H264,
resolution: "1080p".into(),
frame_rate: "23.976".into(),
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
@@ -791,19 +745,15 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
};
// Build synthetic BD-TS packets containing valid H.264 NALs
// We need PES headers wrapping: SPS (NAL type 7), PPS (NAL type 8), IDR (NAL type 5)
let mut ts_data = Vec::new();
// Build elementary stream data: start codes + NALs
let mut es_data = Vec::new();
// SPS NAL (type 7): minimal valid SPS
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
es_data.push(0x67); // NAL type 7 (SPS), nal_ref_idc=3
// Minimal SPS payload: profile_idc=66 (Baseline), constraint flags, level_idc=30
es_data.extend_from_slice(&[
0x42, 0xC0, 0x1E, // profile=66, constraint_set0=1, level=30
0xD9, 0x00, 0xA0, 0x47, 0xFE, 0x88, // minimal SPS rbsp
@@ -817,124 +767,16 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
// IDR NAL (type 5): keyframe
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
es_data.push(0x65); // NAL type 5 (IDR), nal_ref_idc=3
// Some IDR slice data
es_data.extend_from_slice(&[0x88, 0x84, 0x00, 0x21, 0xFF, 0xFE, 0xF6, 0xE2]);
// Pad to reasonable size
es_data.extend_from_slice(&[0x00; 64]);
// Wrap in PES header with PTS
let pts: i64 = 90000; // 1 second in 90kHz ticks
let pts_bytes = encode_pts_test(pts);
let pes_header_len = 9 + 5; // basic PES header (9) + PTS (5)
let pes_length = (3 + 5 + es_data.len()) as u16; // flags(3) + PTS(5) + ES data
let mut pes = Vec::new();
pes.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]); // PES start code + video stream_id
pes.extend_from_slice(&pes_length.to_be_bytes()); // PES packet length
pes.extend_from_slice(&[0x80, 0x80, 0x05]); // flags: PTS present, header_data_len=5
pes.extend_from_slice(&pts_bytes);
pes.extend_from_slice(&es_data);
// Wrap PES in 192-byte BD-TS packets
let pid: u16 = 0x1011;
let mut pes_offset = 0;
let mut pusi = true;
let mut cc: u8 = 0;
while pes_offset < pes.len() {
let mut pkt = [0u8; 192];
// 4-byte TP_extra_header (zeros)
pkt[4] = 0x47; // sync byte
pkt[5] = (pid >> 8) as u8 & 0x1F;
if pusi {
pkt[5] |= 0x40; // PUSI
pusi = false;
}
pkt[6] = pid as u8;
pkt[7] = 0x10 | (cc & 0x0F); // payload only + continuity counter
cc = cc.wrapping_add(1);
let space = 184;
let rem = pes.len() - pes_offset;
let n = rem.min(space);
if n < space {
// Need adaptation field for padding
let pad = space - n;
pkt[7] = 0x30 | (cc.wrapping_sub(1) & 0x0F); // AF + payload
pkt[8] = (pad - 1) as u8; // adaptation_field_length
if pad > 1 {
pkt[9] = 0x00; // flags
}
for byte in pkt.iter_mut().take(8 + pad).skip(10) {
*byte = 0xFF;
}
pkt[8 + pad..8 + pad + n].copy_from_slice(&pes[pes_offset..pes_offset + n]);
} else {
pkt[8..8 + n].copy_from_slice(&pes[pes_offset..pes_offset + n]);
}
ts_data.extend_from_slice(&pkt);
pes_offset += n;
}
// Build a second PES (access unit) to trigger the first PES to be output
// by the TS demuxer (it needs a new PUSI to emit the previous PES).
let pts2: i64 = 90000 + 3753; // ~1 frame later
let pts2_bytes = encode_pts_test(pts2);
// Non-IDR slice data (NAL type 1)
let mut es_data2 = Vec::new();
// Just a non-IDR slice (NAL type 1)
es_data2.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
es_data2.push(0x41); // NAL type 1 (non-IDR)
es_data2.extend_from_slice(&[0x9A, 0x00, 0x10, 0x20]);
es_data2.extend_from_slice(&[0x00; 32]);
let pes2_length = (3 + 5 + es_data2.len()) as u16;
let mut pes2 = Vec::new();
pes2.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]);
pes2.extend_from_slice(&pes2_length.to_be_bytes());
pes2.extend_from_slice(&[0x80, 0x80, 0x05]);
pes2.extend_from_slice(&pts2_bytes);
pes2.extend_from_slice(&es_data2);
// Wrap second PES in BD-TS packets
let mut pes2_offset = 0;
let mut pusi2 = true;
while pes2_offset < pes2.len() {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
pkt[5] = (pid >> 8) as u8 & 0x1F;
if pusi2 {
pkt[5] |= 0x40;
pusi2 = false;
}
pkt[6] = pid as u8;
pkt[7] = 0x10 | (cc & 0x0F);
cc = cc.wrapping_add(1);
let space = 184;
let rem = pes2.len() - pes2_offset;
let n = rem.min(space);
if n < space {
let pad = space - n;
pkt[7] = 0x30 | (cc.wrapping_sub(1) & 0x0F);
pkt[8] = (pad - 1) as u8;
if pad > 1 {
pkt[9] = 0x00;
}
for byte in pkt.iter_mut().take(8 + pad).skip(10) {
*byte = 0xFF;
}
pkt[8 + pad..8 + pad + n].copy_from_slice(&pes2[pes2_offset..pes2_offset + n]);
} else {
pkt[8..8 + n].copy_from_slice(&pes2[pes2_offset..pes2_offset + n]);
}
ts_data.extend_from_slice(&pkt);
pes2_offset += n;
}
// Feed through MkvStream using a shared writer to inspect the output bytes.
let output2 = std::sync::Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
@@ -953,15 +795,36 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
}
}
let writer = SharedWriter(output2.clone());
let mut stream2 = MkvStream::new(writer).meta(&dt).max_buffer(1024 * 1024);
stream2.write_all(&ts_data).unwrap();
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(SharedWriter(output2.clone()));
let mut stream2 = MkvStream::create(writer, &dt).unwrap();
// Write the ES data (SPS+PPS+IDR) as a keyframe PES frame.
let frame1 = libfreemkv::pes::PesFrame {
track: 0,
pts: 1_000_000_000, // 1 second in ns
keyframe: true,
data: es_data,
};
stream2.write(&frame1).unwrap();
// Write a second non-IDR frame
let frame2 = libfreemkv::pes::PesFrame {
track: 0,
pts: 1_041_700_000, // ~1 frame later in ns
keyframe: false,
data: es_data2,
};
stream2.write(&frame2).unwrap();
stream2.finish().unwrap();
let data = output2.lock().unwrap().clone().into_inner();
// Verify output starts with EBML magic (0x1A45DFA3)
assert!(data.len() >= 4, "MKV output too small: {} bytes", data.len());
assert!(
data.len() >= 4,
"MKV output too small: {} bytes",
data.len()
);
assert_eq!(
&data[0..4],
&[0x1A, 0x45, 0xDF, 0xA3],
@@ -970,52 +833,10 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
// Verify output contains a Tracks element (0x1654AE6B)
let tracks_needle = [0x16, 0x54, 0xAE, 0x6B];
let has_tracks = data
.windows(4)
.any(|w| w == tracks_needle);
let has_tracks = data.windows(4).any(|w| w == tracks_needle);
assert!(has_tracks, "output should contain Tracks element");
// Verify codecPrivate is non-empty (not all zeros)
// CodecPrivate element ID is 0x63A2
let cp_needle = [0x63, 0xA2];
let cp_pos = data
.windows(2)
.position(|w| w == cp_needle);
if let Some(pos) = cp_pos {
// After the ID, there's a size VINT, then the data
let after_id = pos + 2;
if after_id < data.len() {
// Read VINT size
let size_byte = data[after_id];
let (cp_size, cp_data_start) = if size_byte & 0x80 != 0 {
((size_byte & 0x7F) as usize, after_id + 1)
} else if size_byte & 0x40 != 0 && after_id + 1 < data.len() {
(
(((size_byte & 0x3F) as usize) << 8) | data[after_id + 1] as usize,
after_id + 2,
)
} else {
(0, after_id + 1)
};
if cp_size > 0 && cp_data_start + cp_size <= data.len() {
let cp_data = &data[cp_data_start..cp_data_start + cp_size];
let all_zeros = cp_data.iter().all(|&b| b == 0);
assert!(
!all_zeros,
"codecPrivate should not be all zeros (SPS/PPS should be filled)"
);
}
}
}
}
fn encode_pts_test(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,
]
// Note: With the PES stream API, codec_privates are populated from the
// DiscTitle at creation time, not extracted from ES data during write.
// This test verifies the muxer produces valid EBML structure.
}