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.
This commit is contained in:
MattJackson
2026-04-15 19:46:01 +00:00
parent bd7220c62b
commit 0f18906ede
30 changed files with 979 additions and 3689 deletions
+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
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.10.0"
version = "0.10.1"
edition = "2021"
rust-version = "1.86"
license = "AGPL-3.0-only"
+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.
+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)
+1 -7
View File
@@ -54,13 +54,7 @@ pub fn decrypt_sectors(
let uk = match unit_keys.get(unit_key_idx) {
Some((_, k)) => *k,
None => {
return Err(crate::error::Error::DecryptFailed {
reason: format!(
"unit_key_idx {} out of range (have {} keys)",
unit_key_idx,
unit_keys.len()
),
});
return Err(crate::error::Error::DecryptFailed);
}
};
let rdk = read_data_key.as_ref();
+83 -329
View File
@@ -15,7 +15,6 @@ mod encrypt;
use crate::drive::Drive;
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::speed::DriveSpeed;
use crate::udf;
use encrypt::HandshakeResult;
@@ -918,73 +917,6 @@ impl ScanOptions {
}
}
/// A disc with an active drive session -- the main API.
///
/// Owns both the disc metadata and the drive connection.
/// Created by `Disc::open()`. Provides `rip()` to read title data.
pub struct OpenDisc {
pub disc: Disc,
pub session: Drive,
}
impl OpenDisc {
/// Open a drive, wait for disc, initialize, probe, and scan.
/// This is the single entry point -- one call does everything.
///
pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> {
use std::path::Path;
let mut session = Drive::open(Path::new(device))?;
session.wait_ready()?;
// Init (unlock + firmware) -- non-fatal if fails
let _ = session.init();
let _ = session.probe_disc();
let opts = if let Some(kp) = keydb_path {
ScanOptions::with_keydb(kp)
} else {
ScanOptions::default()
};
let disc = Disc::scan(&mut session, &opts)?;
Ok(Self { disc, session })
}
/// Rip a title to any output stream.
///
/// Reads sectors from disc, decrypts AACS, handles errors/retries,
/// and writes decrypted BD-TS bytes to the output.
/// Knows nothing about the output format -- just calls `write_all()`.
///
pub fn rip(&mut self, title_idx: usize, mut output: impl std::io::Write) -> Result<()> {
let mut reader = self.disc.open_title(&mut self.session, title_idx)?;
loop {
match reader.read_batch() {
Ok(Some(batch)) => {
output.write_all(batch).map_err(|_| Error::WriteError)?;
}
Ok(None) => break,
Err(_) => {
// ContentReader handles retries internally
}
}
}
Ok(())
}
/// Total bytes for a title (for progress tracking).
pub fn title_size(&self, title_idx: usize) -> u64 {
self.disc
.titles
.get(title_idx)
.map(|t| t.size_bytes)
.unwrap_or(0)
}
}
impl Disc {
/// Disc capacity in GB
pub fn capacity_gb(&self) -> f64 {
@@ -1148,40 +1080,6 @@ impl Disc {
}
}
// ─── Decrypted reader ──────────────────────────────────────────────────────
/// A reader that reads m2ts content, decrypting transparently if needed.
///
/// Adaptive read strategy:
/// - Starts at max batch size (510 sectors ≈ 1MB) and full disc speed
/// - On read error: halves batch size, brief pause for drive recovery
/// - On repeated errors: reduces disc spin speed (scratched region)
/// - On success streak: ramps batch back up, then restores disc speed
/// - At minimum batch + still failing: retries once, then skips + zero-fills
pub struct ContentReader<'a> {
session: &'a mut Drive,
decrypt_keys: crate::decrypt::DecryptKeys,
extents: Vec<Extent>,
current_extent: usize,
current_offset: u32,
unit_key_idx: usize,
read_buf: Vec<u8>,
buf_pos: usize,
buf_len: usize,
/// Current batch size in sectors (adapts on errors)
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,
/// Current speed tier index (0 = max, higher = slower)
/// Last time maintain_speed was called
/// Total read errors encountered
pub errors: u32,
}
impl Disc {
/// Get the resolved decryption keys for this disc.
/// Used by disc-to-ISO and other full-disc operations.
@@ -1200,59 +1098,99 @@ impl Disc {
}
}
/// Open a title for reading. Decryption is automatic -- if the disc
/// is encrypted and keys were found during scan(), content is decrypted
/// on the fly. Unencrypted discs pass through unchanged.
/// Raw sector copy — write the entire disc image to a file.
///
pub fn open_title<'a>(
&'a self,
session: &'a mut Drive,
title_idx: usize,
) -> Result<ContentReader<'a>> {
let title = self.titles.get(title_idx).ok_or(Error::DiscTitleRange {
index: title_idx,
count: self.titles.len(),
})?;
/// This is NOT a stream operation. It copies sectors 0→capacity byte-for-byte,
/// producing a valid ISO/UDF image. The disc's filesystem structure is preserved.
///
/// If `decrypt` is true and keys are available, sectors are decrypted on the fly.
/// If `resume` is true and the file already exists, resumes from the last safe position.
///
/// `on_progress` is called periodically with (bytes_done, total_bytes).
pub fn copy(
&self,
reader: &mut dyn SectorReader,
path: &std::path::Path,
decrypt: bool,
resume: bool,
on_progress: Option<&dyn Fn(u64, u64)>,
) -> Result<()> {
use std::io::{Seek, SeekFrom, Write};
// Let the drive manage its own read speed after init.
// SET_CD_SPEED is only used reactively by the error handler to slow
// down on read errors, then let the drive recover.
let total_bytes = self.capacity_sectors as u64 * 2048;
let keys = if decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None };
// Detect kernel max transfer size for this device
let max_batch = detect_max_batch_sectors(session.device_path());
let decrypt_keys = if let Some(ref aacs) = self.aacs {
crate::decrypt::DecryptKeys::Aacs {
unit_keys: aacs.unit_keys.clone(),
read_data_key: aacs.read_data_key,
}
} else if let Some(ref css) = self.css {
crate::decrypt::DecryptKeys::Css {
title_key: css.title_key,
// Resume: check existing file
let (start_lba, file) = if resume {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > 0 => {
let safe_sectors = (meta.len() / 2048).saturating_sub(5) as u32;
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(|e| Error::IoError { source: e })?;
let resume_pos = safe_sectors as u64 * 2048;
f.set_len(resume_pos)
.map_err(|e| Error::IoError { source: e })?;
f.seek(SeekFrom::End(0))
.map_err(|e| Error::IoError { source: e })?;
(safe_sectors, f)
}
_ => {
let f = std::fs::File::create(path)
.map_err(|e| Error::IoError { source: e })?;
(0u32, f)
}
}
} else {
crate::decrypt::DecryptKeys::None
let f = std::fs::File::create(path)
.map_err(|e| Error::IoError { source: e })?;
(0u32, f)
};
Ok(ContentReader {
session,
decrypt_keys,
extents: title.extents.clone(),
current_extent: 0,
current_offset: 0,
unit_key_idx: 0,
read_buf: Vec::with_capacity(max_batch as usize * 2048),
buf_pos: 0,
buf_len: 0,
batch_sectors: max_batch,
max_batch_sectors: max_batch,
ok_streak: 0,
error_streak: 0,
errors: 0,
})
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
let batch: u16 = 64; // 128 KB per read
let mut lba = start_lba;
let mut bytes_done = start_lba as u64 * 2048;
let mut buf = vec![0u8; batch as usize * 2048];
while lba < self.capacity_sectors {
let remaining = self.capacity_sectors - lba;
let count = remaining.min(batch as u32) as u16;
let bytes = count as usize * 2048;
reader
.read_sectors(lba, count, &mut buf[..bytes])
.map_err(|e| Error::IoError {
source: std::io::Error::other(e.to_string()),
})?;
// Decrypt if requested
if decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
}
writer
.write_all(&buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
lba += count as u32;
bytes_done += bytes as u64;
if let Some(ref cb) = on_progress {
cb(bytes_done, total_bytes);
}
}
writer.flush().map_err(|e| Error::IoError { source: e })?;
Ok(())
}
}
const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS: u16 = 60;
const MIN_BATCH_SECTORS: u16 = 3;
/// Detect the maximum transfer size in sectors for a device.
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
/// For sg devices, resolves the corresponding block device via sysfs.
@@ -1293,190 +1231,6 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
DEFAULT_BATCH_SECTORS
}
/// Read strategy constants
pub(crate) const MAX_BATCH_SECTORS: u16 = 510; // absolute max (170 aligned units ≈ 1MB)
pub(crate) const DEFAULT_BATCH_SECTORS: u16 = 60; // fallback: typical kernel limit (120KB = 60 sectors)
pub(crate) const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (error recovery)
pub(crate) const RAMP_BATCH_AFTER: u32 = 5; // successes before doubling batch size
pub(crate) const RAMP_SPEED_AFTER: u32 = 50; // successes at max batch before restoring speed
pub(crate) const SLOW_SPEED_AFTER: u32 = 3; // consecutive errors before reducing disc speed
impl<'a> ContentReader<'a> {
/// Total bytes across all extents (for progress display).
pub fn total_bytes(&self) -> u64 {
self.extents
.iter()
.map(|e| e.sector_count as u64 * 2048)
.sum()
}
/// Read the next aligned unit (6144 bytes).
/// Automatically decrypted if AACS keys are available.
/// Returns None when all extents are exhausted.
pub fn read_unit(&mut self) -> Result<Option<Vec<u8>>> {
// Refill buffer if empty
if self.buf_pos >= self.buf_len && !self.fill_buffer()? {
return Ok(None);
}
// Extract one aligned unit from buffer
let start = self.buf_pos * crate::aacs::ALIGNED_UNIT_LEN;
let end = start + crate::aacs::ALIGNED_UNIT_LEN;
let mut unit = self.read_buf[start..end].to_vec();
// Decrypt if needed
self.decrypt_unit(&mut unit)?;
self.buf_pos += 1;
Ok(Some(unit))
}
/// Read the next batch of aligned units, decrypted in-place.
/// Returns the decrypted data as a single contiguous slice.
/// More efficient than read_unit() -- one write_all() per batch instead of per unit.
/// Returns None when all extents are exhausted.
pub fn read_batch(&mut self) -> Result<Option<&[u8]>> {
if !self.fill_buffer()? {
return Ok(None);
}
// Decrypt all units in the buffer in-place
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
let total_bytes = self.buf_len * unit_len;
crate::decrypt::decrypt_sectors(
&mut self.read_buf[..total_bytes],
&self.decrypt_keys,
self.unit_key_idx,
)?;
self.buf_pos = self.buf_len;
Ok(Some(&self.read_buf[..total_bytes]))
}
/// Decrypt a single aligned unit in-place if needed.
fn decrypt_unit(&self, unit: &mut [u8]) -> Result<()> {
crate::decrypt::decrypt_sectors(unit, &self.decrypt_keys, self.unit_key_idx)
}
/// Read sectors via standard READ(10) 0x00.
/// calibration primers. Standard reads are faster on most drives.
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<()> {
self.session.read(lba, count, &mut self.read_buf)?;
Ok(())
}
/// Read a batch of sectors into the internal buffer.
///
/// Error handling:
/// - First error: re-init drive (may have re-locked), halve batch
/// - Repeated errors: reduce speed, keep halving batch
/// - At minimum batch: retry once, then skip + zero-fill
/// - After sustained success: ramp batch back up, restore max speed
fn fill_buffer(&mut self) -> Result<bool> {
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.buf_len = sectors_to_read as usize / 3;
self.buf_pos = 0;
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.buf_len = 1;
self.buf_pos = 0;
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);
self.buf_len = 1;
self.buf_pos = 0;
return Ok(true);
}
}
}
}
}
}
// ─── Format helpers ────────────────────────────────────────────────────────
// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums
+4 -4
View File
@@ -141,8 +141,8 @@ impl Drive {
}
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(),
})
}
@@ -264,8 +264,8 @@ impl Drive {
}
}
Err(Error::DeviceNotFound {
path: format!("{}: drive reset failed", self.device_path),
Err(Error::DeviceResetFailed {
path: self.device_path.clone(),
})
}
+103 -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,8 +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;
@@ -63,121 +71,94 @@ 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 not found at the given path.
// Device (1xxx)
DeviceNotFound { path: String },
/// Insufficient permissions to open the device.
DevicePermission { path: String },
DeviceNotReady { path: String },
DeviceResetFailed { path: String },
/// Drive model is not in the profile database.
// Profile (2xxx)
UnsupportedDrive {
vendor_id: String,
product_id: String,
product_revision: String,
},
/// No matching firmware profile found for this drive revision.
ProfileNotFound {
vendor_id: String,
product_revision: String,
vendor_specific: String,
},
/// Failed to parse the bundled profile database.
ProfileParse,
/// Drive unlock (firmware upload) failed.
// Unlock (3xxx)
UnlockFailed,
/// Firmware signature verification failed.
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
/// Operation requires an unlocked drive.
NotUnlocked,
/// Operation requires a calibrated drive.
NotCalibrated,
/// SCSI command returned an error status.
// SCSI (4xxx)
ScsiError {
opcode: u8,
status: u8,
sense_key: u8,
},
/// SCSI command timed out.
ScsiTimeout { opcode: u8 },
/// Underlying I/O error.
// I/O (5xxx)
IoError { source: std::io::Error },
/// Write operation failed.
WriteError,
/// Failed to read disc sector.
// Disc format (6xxx)
DiscRead { sector: u64 },
/// MPLS playlist parsing failed.
MplsParse,
/// CLPI clip info parsing failed.
ClpiParse,
/// File not found on the UDF filesystem.
UdfNotFound { path: String },
/// Disc contains no playable titles.
DiscNoTitles,
/// Title index out of range.
DiscTitleRange { index: usize, count: usize },
/// Title has no sector extents to read.
DiscNoExtents,
/// DVD IFO file parsing failed.
IfoParse,
MkvInvalid,
NoStreams,
/// No AACS decryption keys available for this disc.
// AACS (7xxx)
AacsNoKeys,
/// Host certificate too short.
AacsCertShort,
/// Failed to allocate AGID for AACS handshake.
AacsAgidAlloc,
/// Drive rejected the host certificate.
AacsCertRejected,
/// Failed to read drive certificate.
AacsCertRead,
/// Drive certificate verification failed.
AacsCertVerify,
/// Failed to read host key from drive.
AacsKeyRead,
/// Drive rejected the host key.
AacsKeyRejected,
/// Host key verification failed.
AacsKeyVerify,
/// Failed to read Volume ID.
AacsVidRead,
/// Volume ID MAC verification failed.
AacsVidMac,
/// Failed to derive the data key.
AacsDataKey,
/// Failed to derive the Volume Unique Key.
AacsVukDerive,
/// Decryption failed — missing or invalid keys.
DecryptFailed { reason: String },
DecryptFailed,
/// Failed to connect to the KEYDB server.
// Keydb (8xxx)
KeydbConnect { host: String },
/// KEYDB server returned an HTTP error.
KeydbHttp { status: u16 },
/// Downloaded KEYDB file is invalid (no entries found).
KeydbInvalid,
/// Failed to write KEYDB to disk.
KeydbWrite { path: String },
/// Failed to parse KEYDB file.
KeydbParse,
/// Failed to load KEYDB from disk.
KeydbLoad { path: String },
/// Lookahead buffer exhausted before codec headers found.
MuxLookahead,
/// Muxer write failed.
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 {
@@ -185,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,
@@ -216,16 +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::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,
}
}
}
@@ -236,68 +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),
Error::DecryptFailed { reason } => write!(f, "E{}: {}", self.code(), reason),
// 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()),
}
}
@@ -318,5 +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>;
+11 -7
View File
@@ -9,17 +9,23 @@
//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
//!
//! 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 drive, 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
@@ -103,14 +109,12 @@ pub use disc::{
ScanOptions, Stream, SubtitleStream, VideoStream,
};
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::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl};
pub use mux::{input, output, parse_url, InputOptions, StreamUrl};
pub use scsi::ScsiTransport;
pub use sector::SectorReader;
pub use speed::DriveSpeed;
+92 -289
View File
@@ -1,42 +1,36 @@
//! DiscStream — read sectors from an optical disc drive.
//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
//!
//! `DiscStream::open()` does the full init sequence:
//! drive open → wait_ready → init → probe_disc → scan
//! One stream type for all disc sources. The source is a SectorReader —
//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care.
//!
//! Then reads title extents or full-disc sequentially.
//! No decryption — that's a caller concern.
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
use super::IOStream;
use crate::disc::{
detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions,
};
use crate::drive::Drive;
use crate::error::{Error, Result};
use crate::event::{Event, EventKind};
use std::io::{self, Read, Write};
use std::path::Path;
use crate::sector::SectorReader;
use std::io;
/// Optical disc stream. Read-only — yields raw sector bytes.
/// Disc stream. Reads sectors from any source → PES frames.
///
/// Created from an initialized Drive + title extents or full-disc mode.
/// Error recovery (batch reduction, retry, zero-fill) is handled internally.
/// Sources: physical drive, ISO file, or any SectorReader.
/// Decrypt, demux, and codec parsing happen internally.
pub struct DiscStream {
drive: Drive,
reader: Box<dyn SectorReader>,
title: DiscTitle,
disc: Option<Disc>,
decrypt_keys: crate::decrypt::DecryptKeys,
// What to read
mode: ReadMode,
// Extents to read
extents: Vec<Extent>,
// Position
current_lba: u32,
current_extent: usize,
current_offset: u32,
// Buffer
read_buf: Vec<u8>,
buf_valid: usize,
buf_cursor: usize,
// Batch size for reads
batch_sectors: u16,
@@ -51,77 +45,24 @@ pub struct DiscStream {
pid_to_track: Vec<(u16, usize)>,
}
enum ReadMode {
/// Read title extents (for MKV, M2TS, etc.)
Extents(Vec<Extent>),
/// Read LBA 0 to capacity (for ISO)
Sequential { capacity: u32 },
}
/// Result of opening a DiscStream.
pub struct DiscOpenResult {
pub stream: DiscStream,
pub disc: Disc,
}
impl DiscStream {
/// Open a disc drive, init, scan, and prepare to read a title.
///
/// Steps (each does one thing):
/// 1. Drive::open (or find_drive)
/// 2. wait_ready
/// 3. init (non-fatal)
/// 4. probe_disc (non-fatal)
/// 5. Disc::scan
///
/// Pass an event callback for status reporting, or None.
pub fn open(
device: Option<&Path>,
/// 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,
on_event: Option<&dyn Fn(Event)>,
) -> Result<DiscOpenResult> {
let emit = |kind: EventKind| {
if let Some(cb) = &on_event {
cb(Event { kind });
}
};
// 1. Open
let mut drive = match device {
Some(d) => Drive::open(d)?,
None => crate::drive::find_drive().ok_or_else(|| Error::DeviceNotFound {
path: String::new(),
})?,
};
emit(EventKind::DriveOpened {
device: drive.device_path().to_string(),
});
// 2. Wait
let _ = drive.wait_ready();
emit(EventKind::DriveReady);
// 3. Init
let init_ok = drive.init().is_ok();
emit(EventKind::InitComplete { success: init_ok });
// 4. Probe
let probe_ok = drive.probe_disc().is_ok();
emit(EventKind::ProbeComplete { success: probe_ok });
// 5. Scan
) -> 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)?;
emit(EventKind::ScanComplete {
titles: disc.titles.len(),
});
if title_index >= disc.titles.len() {
return Err(Error::DiscTitleRange {
return Err(crate::error::Error::DiscTitleRange {
index: title_index,
count: disc.titles.len(),
});
@@ -129,45 +70,59 @@ impl DiscStream {
let title = disc.titles[title_index].clone();
let keys = disc.decrypt_keys();
let mut stream = Self::title(drive, title);
stream.decrypt_keys = keys;
let max_batch = detect_max_batch_sectors(drive.device_path());
let content_format = disc.content_format;
// DVD: use program stream demuxer instead of transport stream
if disc.content_format == crate::disc::ContentFormat::MpegPs {
let mut stream = Self::from_reader(Box::new(drive), title, keys, max_batch);
if content_format == crate::disc::ContentFormat::MpegPs {
stream.ts_demuxer = None;
stream.ps_demuxer = Some(super::ps::PsDemuxer::new());
}
Ok(DiscOpenResult { stream, disc })
Ok((stream, disc))
}
/// Create a stream that reads a title's extents.
/// Use this when you already have an initialized Drive.
pub fn title(drive: Drive, title: DiscTitle) -> Self {
let max_batch = detect_max_batch_sectors(drive.device_path());
/// 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);
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();
Self::new(drive, title, ReadMode::Extents(extents), max_batch)
}
/// Create a stream that reads the full disc sequentially (for ISO).
pub fn full_disc(drive: Drive, title: DiscTitle, capacity: u32) -> Self {
let max_batch = detect_max_batch_sectors(drive.device_path());
Self::new(drive, title, ReadMode::Sequential { capacity }, max_batch)
}
/// Resume a full disc read from a given LBA (for ISO resume).
/// Use after checking an existing partial file:
/// start_lba = (file_size / 2048) - safety_margin
pub fn full_disc_resume(drive: Drive, title: DiscTitle, capacity: u32, start_lba: u32) -> Self {
let max_batch = detect_max_batch_sectors(drive.device_path());
let mut stream = Self::new(drive, title, ReadMode::Sequential { capacity }, max_batch);
stream.current_lba = start_lba;
stream
}
/// Set SCSI read timeout (default 30s).
fn new(drive: Drive, title: DiscTitle, mode: ReadMode, max_batch: u16) -> Self {
// Set up PES demux from title stream PIDs
let mut pids = Vec::new();
let mut parsers = Vec::new();
let mut pid_to_track = Vec::new();
@@ -183,21 +138,20 @@ impl DiscStream {
}
Self {
drive,
reader,
title,
decrypt_keys: crate::decrypt::DecryptKeys::None,
mode,
current_lba: 0,
disc: None,
decrypt_keys,
extents,
current_extent: 0,
current_offset: 0,
read_buf: Vec::with_capacity(max_batch as usize * 2048),
read_buf: Vec::with_capacity(batch_sectors as usize * 2048),
buf_valid: 0,
buf_cursor: 0,
batch_sectors: max_batch,
batch_sectors,
errors: 0,
eof: false,
ts_demuxer: if pids.is_empty() { None } else { Some(super::ts::TsDemuxer::new(&pids)) },
ps_demuxer: None, // set by caller for DVD content
ps_demuxer: None,
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
@@ -209,43 +163,17 @@ impl DiscStream {
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
}
/// Lock the tray.
pub fn lock_tray(&mut self) {
self.drive.lock_tray();
}
/// Unlock the tray.
pub fn unlock_tray(&mut self) {
self.drive.unlock_tray();
}
/// Recover the drive (for batch: switch to another title).
pub fn into_drive(self) -> Drive {
self.drive
}
// ── Fill ─────────────────────────────────────────────────────────────
fn fill(&mut self) -> bool {
match &self.mode {
ReadMode::Extents(_) => self.fill_extents(),
ReadMode::Sequential { .. } => self.fill_sequential(),
}
/// Get the scanned Disc (for listing all titles).
pub fn disc(&self) -> Option<&Disc> {
self.disc.as_ref()
}
fn fill_extents(&mut self) -> bool {
let (ext_start, ext_sectors) = match &self.mode {
ReadMode::Extents(exts) => {
if self.current_extent >= exts.len() {
return false;
}
(
exts[self.current_extent].start_lba,
exts[self.current_extent].sector_count,
)
}
_ => unreachable!(),
};
if self.current_extent >= self.extents.len() {
return 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);
let sectors = remaining.min(self.batch_sectors as u32) as u16;
@@ -253,22 +181,16 @@ impl DiscStream {
if sectors == 0 {
self.current_extent += 1;
self.current_offset = 0;
return self.fill_extents(); // next extent
return self.fill_extents();
}
let lba = ext_start + self.current_offset;
let bytes = sectors as usize * 2048;
self.read_buf.resize(bytes, 0);
// Drive handles all error recovery internally.
match self.drive.read(
lba,
sectors,
&mut self.read_buf[..bytes],
) {
match self.reader.read_sectors(lba, sectors, &mut self.read_buf[..bytes]) {
Ok(_) => {
self.buf_valid = bytes;
self.buf_cursor = 0;
self.current_offset += sectors as u32;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
@@ -276,73 +198,13 @@ impl DiscStream {
}
true
}
Err(_) => false, // drive gone — EOF
Err(_) => false,
}
}
fn fill_sequential(&mut self) -> bool {
let capacity = match &self.mode {
ReadMode::Sequential { capacity } => *capacity,
_ => unreachable!(),
};
if self.current_lba >= capacity {
return false;
}
let remaining = capacity - self.current_lba;
let count = remaining.min(self.batch_sectors as u32) as u16;
let bytes = count as usize * 2048;
self.read_buf.resize(bytes, 0);
// Drive handles all error recovery internally —
// retries, speed changes, zero-fill on unreadable sectors.
match self.drive.read(
self.current_lba,
count,
&mut self.read_buf[..bytes],
) {
Ok(_) => {
self.buf_valid = bytes;
self.buf_cursor = 0;
self.current_lba += count as u32;
true
}
Err(_) => false, // drive gone — EOF
}
}
}
// ── IOStream ─────────────────────────────────────────────────────────────────
impl IOStream for DiscStream {
fn info(&self) -> &DiscTitle {
&self.title
}
fn finish(&mut self) -> io::Result<()> {
self.drive.unlock_tray();
Ok(())
}
fn total_bytes(&self) -> Option<u64> {
match &self.mode {
ReadMode::Extents(extents) => {
Some(extents.iter().map(|e| e.sector_count as u64 * 2048).sum())
}
ReadMode::Sequential { capacity } => Some(*capacity as u64 * 2048),
}
}
fn keys(&self) -> crate::decrypt::DecryptKeys {
self.decrypt_keys.clone()
}
}
impl crate::pes::Stream for DiscStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
// Return buffered frame if available
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
@@ -351,32 +213,22 @@ impl crate::pes::Stream for DiscStream {
return Ok(None);
}
// Read sectors until we produce at least one frame
loop {
// Fill the read buffer with next batch of sectors
let got_data = match &self.mode {
ReadMode::Extents(_) => self.fill_extents(),
ReadMode::Sequential { .. } => self.fill_sequential(),
};
if !got_data {
if !self.fill_extents() {
self.eof = true;
return Ok(None);
}
// Decrypt
let bytes = self.buf_valid;
if let Err(e) = crate::decrypt::decrypt_sectors(
&mut self.read_buf[..bytes],
&self.decrypt_keys,
0,
) {
return Err(io::Error::other(e.to_string()));
return Err(e.into());
}
// Demux into packets, parse into frames
if let Some(ref mut demuxer) = self.ts_demuxer {
// BD: transport stream demux
let packets = demuxer.feed(&self.read_buf[..bytes]);
for pes in &packets {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
@@ -390,13 +242,11 @@ impl crate::pes::Stream for DiscStream {
}
}
} else if let Some(ref mut demuxer) = self.ps_demuxer {
// DVD: program stream demux
let packets = demuxer.feed(&self.read_buf[..bytes]);
for ps in &packets {
// Map PS stream_id to track index
let track = match ps.stream_id {
0xE0..=0xEF => 0, // video
0xC0..=0xDF => 1, // audio
0xE0..=0xEF => 0,
0xC0..=0xDF => 1,
0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1),
_ => continue,
};
@@ -405,36 +255,28 @@ impl crate::pes::Stream for DiscStream {
self.pending_frames.push_back(crate::pes::PesFrame {
track,
pts: pts_ns,
keyframe: true, // PS doesn't have keyframe flag easily
keyframe: true,
data: ps.data.clone(),
});
}
}
}
// Reset buffer for next read
self.buf_valid = 0;
self.buf_cursor = 0;
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
// No frames produced — read more data
}
}
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "disc is read-only"))
Err(crate::error::Error::StreamReadOnly.into())
}
fn finish(&mut self) -> io::Result<()> {
self.drive.unlock_tray();
Ok(())
}
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &DiscTitle {
&self.title
}
fn info(&self) -> &DiscTitle { &self.title }
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self.pid_to_track.iter()
@@ -456,42 +298,3 @@ impl crate::pes::Stream for DiscStream {
true
}
}
impl Read for DiscStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
// Drain current buffer
if self.buf_cursor < self.buf_valid {
let n = (self.buf_valid - self.buf_cursor).min(buf.len());
buf[..n].copy_from_slice(&self.read_buf[self.buf_cursor..self.buf_cursor + n]);
self.buf_cursor += n;
return Ok(n);
}
if self.eof {
return Ok(0);
}
// Fill next batch
if self.fill() {
let n = self.buf_valid.min(buf.len());
buf[..n].copy_from_slice(&self.read_buf[..n]);
self.buf_cursor = n;
Ok(n)
} else {
self.eof = true;
Ok(0)
}
}
}
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(())
}
}
+2 -8
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())
}
}
@@ -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())
}
// ============================================================
+9 -407
View File
@@ -1,27 +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::decrypt::{decrypt_sectors, DecryptKeys};
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,
@@ -29,16 +18,15 @@ 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 sectors = size / SECTOR_SIZE;
if sectors > u32::MAX as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("iso://{path}: image too large ({} TB, max ~8 TB)", size / (1024 * 1024 * 1024 * 1024)),
));
return Err(crate::error::Error::IsoTooLarge {
path: path.to_string(),
}.into());
}
let capacity = sectors as u32;
Ok(Self { file, capacity })
@@ -62,355 +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,
/// Decrypt on read — auto-detected from disc scan.
decrypt_keys: DecryptKeys,
// Write side
iso_writer: Option<IsoWriter<io::BufWriter<File>>>,
write_started: bool,
// PES output (for InputStream impl)
demuxer: Option<super::ts::TsDemuxer>,
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
pid_to_track: Vec<(u16, usize)>,
}
impl IsoStream {
/// 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()))?;
if disc.titles.is_empty() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no titles found in ISO image",
));
}
let idx = title_index.unwrap_or(0);
if idx >= disc.titles.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"title {} out of range (disc has {})",
idx + 1,
disc.titles.len()
),
));
}
let disc_title = disc.titles[idx].clone();
let decrypt_keys = disc.decrypt_keys();
let extents: Vec<(u32, u32)> = disc_title
.extents
.iter()
.map(|e| (e.start_lba, e.sector_count))
.collect();
let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0);
// Set up PES demux from title stream PIDs
let mut pids = Vec::new();
let mut parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)> = Vec::new();
let mut pid_to_track = Vec::new();
for (i, s) in disc_title.streams.iter().enumerate() {
let (pid, codec) = match s {
crate::disc::Stream::Video(v) => (v.pid, v.codec),
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
};
pids.push(pid);
pid_to_track.push((pid, i));
parsers.push((pid, super::codec::parser_for_codec(codec)));
}
Ok(IsoStream {
disc_title,
disc: Some(disc),
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,
decrypt_keys,
iso_writer: None,
write_started: false,
demuxer: if pids.is_empty() { None } else { Some(super::ts::TsDemuxer::new(&pids)) },
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
})
}
/// 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,
decrypt_keys: DecryptKeys::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,
demuxer: None,
parsers: Vec::new(),
pending_frames: std::collections::VecDeque::new(),
pid_to_track: Vec::new(),
})
}
/// 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()))?;
// Decrypt after read — stream handles its own decryption
let bytes = count as usize * SECTOR_SIZE as usize;
decrypt_sectors(&mut self.batch_buf[..bytes], &self.decrypt_keys, 0)
.map_err(|e| io::Error::other(e.to_string()))?;
self.buf_pos = 0;
self.buf_len = bytes;
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)
}
/// Skip decryption — return raw encrypted bytes.
pub fn set_raw(&mut self) {
self.decrypt_keys = DecryptKeys::None;
}
}
impl crate::pes::Stream for IsoStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
// Return buffered frame
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
if self.eof {
return Ok(None);
}
// Read until we produce at least one frame
loop {
if !self.read_next_batch()? {
self.eof = true;
return Ok(None);
}
// Data in batch_buf is already decrypted by fill_next_batch
if let Some(ref mut demuxer) = self.demuxer {
let packets = demuxer.feed(&self.batch_buf[..self.buf_len]);
for pes in &packets {
if let Some((pid_idx, _)) = self.pid_to_track.iter().enumerate()
.find(|(_, (pid, _))| *pid == pes.pid)
{
let track_idx = self.pid_to_track[pid_idx].1;
if let Some((_, parser)) = self.parsers.iter_mut()
.find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(track_idx, frame)
);
}
}
}
}
}
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
}
}
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "ISO is read-only for PES"))
}
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &crate::disc::DiscTitle {
&self.disc_title
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self.pid_to_track.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
fn headers_ready(&self) -> bool {
for (idx, s) in self.disc_title.streams.iter().enumerate() {
if let crate::disc::Stream::Video(v) = s {
if !v.secondary && self.codec_private(idx).is_none() {
return false;
}
}
}
true
}
}
impl IOStream for IsoStream {
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> {
if self.reader.is_some() {
Some(self.disc_title.size_bytes)
} else {
None
}
}
fn keys(&self) -> DecryptKeys {
self.decrypt_keys.clone()
}
}
impl Read for IsoStream {
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::*;
@@ -453,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 = 0xFFFF_FFFF;
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.div_ceil(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"
);
}
}
+41 -99
View File
@@ -1,9 +1,9 @@
//! 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};
use super::{meta, ts};
use crate::disc::{DiscTitle, Stream as DiscStream};
use std::io::{self, Read, Write};
@@ -14,8 +14,7 @@ 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 Read>,
@@ -41,9 +40,6 @@ fn read_fill(r: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
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>)>,
@@ -55,23 +51,36 @@ pub struct M2tsStream {
}
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(),
}
})
}
fn setup_pes(streams: &[DiscStream]) -> PesSetup {
@@ -91,12 +100,6 @@ impl M2tsStream {
(pids, parsers, pid_to_track)
}
/// Set stream metadata. Returns self for chaining.
pub fn meta(mut self, dt: &DiscTitle) -> Self {
self.disc_title = dt.clone();
self
}
/// Open an M2TS stream for reading. Takes any Read source — file, pipe, socket.
///
/// Tries FMKV metadata header first. Falls back to PMT scan of first 1 MB.
@@ -118,8 +121,6 @@ impl M2tsStream {
return Ok(Self {
disc_title: title.clone(),
mode: Mode::Read { reader: chain },
finished: false,
content_size: None,
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
parsers,
pending_frames: std::collections::VecDeque::new(),
@@ -131,7 +132,7 @@ impl M2tsStream {
// No FMKV header — scan head for PMT
let streams = ts::scan_streams(&head)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no streams found"))?;
.ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?;
let (pids, parsers, pid_to_track) = Self::setup_pes(&streams);
@@ -145,8 +146,6 @@ impl M2tsStream {
..DiscTitle::empty()
},
mode: Mode::Read { reader: chain },
finished: false,
content_size: None,
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
parsers,
pending_frames: std::collections::VecDeque::new(),
@@ -167,7 +166,7 @@ impl crate::pes::Stream for M2tsStream {
loop {
let reader = match &mut self.mode {
Mode::Read { reader } => reader,
_ => return Err(io::Error::new(io::ErrorKind::Unsupported, "not in read mode")),
_ => return Err(crate::error::Error::StreamWriteOnly.into()),
};
let mut buf = vec![0u8; 192 * 1024];
let n = reader.read(&mut buf)?;
@@ -197,11 +196,19 @@ impl crate::pes::Stream for M2tsStream {
}
}
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "use M2tsOutputStream for writing"))
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<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> {
match &mut self.mode {
Mode::Write { muxer } => muxer.finish(),
Mode::Read { .. } => Ok(()),
}
}
fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title }
@@ -231,68 +238,3 @@ impl crate::pes::Stream for M2tsStream {
}
}
impl IOStream for M2tsStream {
fn info(&self) -> &DiscTitle {
&self.disc_title
}
fn finish(&mut self) -> io::Result<()> {
if self.finished {
return Ok(());
}
self.finished = true;
if let Mode::Write { ref mut writer, .. } = self.mode {
writer.flush()
} else {
Ok(())
}
}
fn total_bytes(&self) -> Option<u64> {
self.content_size
}
}
impl Write for M2tsStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.mode {
Mode::Write {
ref mut writer,
ref mut header_written,
} => {
if !*header_written {
if !self.disc_title.streams.is_empty() {
let m = meta::M2tsMeta::from_title(&self.disc_title);
meta::write_header(&mut *writer, &m)?;
}
*header_written = true;
}
writer.write(buf)
}
Mode::Read { .. } => Err(io::Error::new(
io::ErrorKind::Unsupported,
"stream opened for reading",
)),
}
}
fn flush(&mut self) -> io::Result<()> {
if let Mode::Write { ref mut writer, .. } = self.mode {
writer.flush()
} else {
Ok(())
}
}
}
impl Read for M2tsStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self.mode {
Mode::Read { ref mut reader } => reader.read(buf),
Mode::Write { .. } => Err(io::Error::new(
io::ErrorKind::Unsupported,
"stream opened for writing",
)),
}
}
}
+1 -1
View File
@@ -247,7 +247,7 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
r.read_exact(&mut len_buf)?;
let json_len = u32::from_be_bytes(len_buf) as usize;
if json_len > MAX_JSON_SIZE {
return Err(io::Error::new(io::ErrorKind::InvalidData, "FMKV JSON too large"));
return Err(crate::error::Error::NoMetadata.into());
}
let mut json_buf = vec![0u8; json_len];
-70
View File
@@ -1,70 +0,0 @@
//! MKV output stream — accepts PES frames, writes Matroska container.
//!
//! Implements OutputStream. Takes PES frames directly — no TS demuxing needed.
//! Creates the MKV muxer once codec_private is provided for all tracks.
use super::mkv::{MkvMuxer, MkvTrack};
use super::WriteSeek;
use crate::disc::DiscTitle;
use crate::pes::PesFrame;
use std::io;
pub struct MkvOutputStream {
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
title: DiscTitle,
}
impl MkvOutputStream {
/// Create an MKV output stream. Codec privates come from title.codec_privates.
pub fn create(
writer: Box<dyn WriteSeek>,
title: &DiscTitle,
) -> io::Result<Self> {
let mut tracks = Vec::new();
for (idx, s) in title.streams.iter().enumerate() {
let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s),
};
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
track.codec_private = Some(cp.clone());
}
tracks.push(track);
}
let muxer = MkvMuxer::new_with_chapters(
writer,
&tracks,
Some(&title.playlist),
title.duration_secs,
&title.chapters,
)?;
Ok(Self { muxer: Some(muxer), title: title.clone() })
}
}
impl crate::pes::Stream for MkvOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "MKV output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
if let Some(ref mut muxer) = self.muxer {
muxer.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
} else {
Ok(())
}
}
fn finish(&mut self) -> io::Result<()> {
if let Some(muxer) = self.muxer.take() {
muxer.finish()
} else {
Ok(())
}
}
fn info(&self) -> &DiscTitle { &self.title }
}
+56 -496
View File
@@ -1,14 +1,10 @@
//! 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, WriteSeek};
use std::io::Seek;
use super::{ebml, WriteSeek};
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
@@ -19,44 +15,19 @@ fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> {
}
use crate::disc::*;
use std::io::{self, Read, 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 Read>,
buf: Vec<u8>,
pos: usize,
len: usize,
cluster_ts_ms: i64,
/// Codec private data per track (track_number, hvcC/avcC bytes).
/// Emitted as Annex B NALs before first frame of each video track.
codec_privates: Vec<(u16, Vec<u8>)>,
/// Tracks that have already had their codec_private emitted.
initialized_tracks: Vec<u16>,
}
enum Mode {
Write(Box<WriteState>),
Write {
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
},
Read(ReadState),
}
@@ -64,95 +35,52 @@ 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) => {
// Only count primary video as pending — secondary streams
// (Dolby Vision EL, PiP) may never produce codec headers
if !v.secondary {
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.
/// 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,
initialized_tracks: Vec::new(),
}),
max_buffer: 0,
finished: false,
file_size: None,
})
}
}
@@ -161,7 +89,7 @@ impl crate::pes::Stream for MkvStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
let rs = match self.mode {
Mode::Read(ref mut rs) => rs,
Mode::Write(_) => return Err(io::Error::new(io::ErrorKind::Unsupported, "write-only")),
Mode::Write { .. } => return Err(crate::error::Error::StreamWriteOnly.into()),
};
loop {
@@ -209,11 +137,22 @@ impl crate::pes::Stream for MkvStream {
}
}
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "use MkvOutputStream for writing"))
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()),
}
}
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> {
if let Mode::Write { ref mut muxer } = self.mode {
if let Some(m) = muxer.take() {
m.finish()?;
}
}
Ok(())
}
fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title }
@@ -234,256 +173,6 @@ impl crate::pes::Stream for MkvStream {
}
}
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> {
let rs = match self.mode {
Mode::Read(ref mut rs) => rs,
Mode::Write(_) => {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"stream opened for writing",
))
}
};
// Drain internal buffer first
if rs.pos < rs.len {
let n = (rs.len - rs.pos).min(buf.len());
buf[..n].copy_from_slice(&rs.buf[rs.pos..rs.pos + n]);
rs.pos += n;
return Ok(n);
}
// Read next element from MKV
loop {
let (id, size, _) = match ebml::read_element_header(&mut rs.reader) {
Ok(h) => h,
Err(_) => return Ok(0),
};
match id {
ebml::CLUSTER => continue,
ebml::CLUSTER_TIMESTAMP => {
rs.cluster_ts_ms = ebml::read_uint_val(&mut rs.reader, size as usize)? as i64;
continue;
}
ebml::SIMPLE_BLOCK => {
let block = ebml::read_binary_val(&mut rs.reader, size as usize)?;
if block.len() < 4 {
continue;
}
let (track, vl) = block_vint(&block);
if vl + 3 > block.len() {
continue;
}
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
let frame = &block[vl + 3..];
let pts_ms = rs.cluster_ts_ms + rel_ts as i64;
let tnum = track as u16;
rs.buf.clear();
// First frame of a track: emit codec_private as Annex B NALs
if !rs.initialized_tracks.contains(&tnum) {
rs.initialized_tracks.push(tnum);
if let Some((_, cp)) = rs.codec_privates.iter().find(|(t, _)| *t == tnum) {
let annex_b = hvcc_to_annex_b(cp);
if !annex_b.is_empty() {
frame_to_ts(&mut rs.buf, tnum, pts_ms, &annex_b);
}
}
}
frame_to_ts(&mut rs.buf, tnum, pts_ms, frame);
rs.pos = 0;
rs.len = rs.buf.len();
if rs.len > 0 {
let n = rs.len.min(buf.len());
buf[..n].copy_from_slice(&rs.buf[..n]);
rs.pos = n;
return Ok(n);
}
}
_ => {
if size != u64::MAX && size > 0 {
skip_bytes(&mut rs.reader, size)?;
}
}
}
}
}
}
// ── Write internals ────────────────────────────────────────────
fn check_codec_private(ws: &mut WriteState) -> bool {
if ws.video_pending == 0 {
return true;
}
for (pid, parser) in &ws.parsers {
if let Some(cp) = parser.codec_private() {
if let Some((_, idx)) = ws.pid_to_track.iter().find(|(p, _)| p == pid) {
if ws.tracks[*idx].codec_private.is_none() {
ws.tracks[*idx].codec_private = Some(cp);
ws.video_pending -= 1;
}
}
}
}
ws.video_pending == 0
}
fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> {
let writer = ws
.writer
.take()
.ok_or_else(|| io::Error::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)?;
}
}
}
// Transfer remainder bytes from old demuxer to new one.
// Preserves 192-byte packet alignment across the reset.
let remainder = ws.demuxer.take_remainder();
ws.demuxer = TsDemuxer::new(&pids);
ws.demuxer.set_remainder(remainder);
Ok(())
}
fn write_pes(
pid_to_track: &[(u16, usize)],
parsers: &mut [(u16, Box<dyn CodecParser>)],
muxer: &mut MkvMuxer<Box<dyn WriteSeek>>,
pes: &super::ts::PesPacket,
) -> io::Result<()> {
let idx = match pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
Some((_, idx)) => *idx,
None => return Ok(()),
};
let parser = match parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
Some((_, p)) => p,
None => return Ok(()),
};
for frame in parser.parse(pes) {
muxer.write_frame(idx, frame.pts_ns, frame.keyframe, &frame.data)?;
}
Ok(())
}
// ── MKV header parsing (read side) ────────────────────────────
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
@@ -498,19 +187,16 @@ fn parse_mkv_header(
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());
}
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);
@@ -647,7 +333,7 @@ fn parse_track(
SampleRate::S48
};
// Map MKV track numbers to BD-TS PIDs (same mapping as frame_to_ts)
// Map MKV track numbers to BD-TS PIDs
let ts_pid = if tnum == 1 { 0x1011 } else { 0x1100 + (tnum - 2) };
let stream = match ttype {
@@ -685,8 +371,6 @@ fn parse_track(
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);
@@ -705,127 +389,3 @@ fn block_vint(d: &[u8]) -> (u64, usize) {
}
(0, 1) // Unsupported 5+ byte VINT — treat as track 0
}
/// Convert HEVCDecoderConfigurationRecord (hvcC) to Annex B NAL units.
/// Extracts VPS, SPS, PPS arrays and prefixes each with 0x00000001.
fn hvcc_to_annex_b(hvcc: &[u8]) -> Vec<u8> {
// hvcC format (ISO 14496-15):
// byte 0: configurationVersion (1)
// bytes 1-21: profile/level info
// byte 22: numOfArrays
// For each array:
// byte 0: array_completeness(1) + reserved(1) + NAL_unit_type(6)
// bytes 1-2: numNalus (big-endian u16)
// For each NAL:
// bytes 0-1: nalUnitLength (big-endian u16)
// bytes 2..: NAL data
if hvcc.len() < 23 {
return Vec::new();
}
let num_arrays = hvcc[22] as usize;
let mut pos = 23;
let mut out = Vec::new();
for _ in 0..num_arrays {
if pos + 3 > hvcc.len() {
break;
}
pos += 1; // skip array_completeness + NAL type byte
let num_nalus = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
pos += 2;
for _ in 0..num_nalus {
if pos + 2 > hvcc.len() {
break;
}
let nal_len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
pos += 2;
if pos + nal_len > hvcc.len() {
break;
}
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
out.extend_from_slice(&hvcc[pos..pos + nal_len]);
pos += nal_len;
}
}
out
}
fn frame_to_ts(out: &mut Vec<u8>, track: u16, pts_ms: i64, data: &[u8]) {
let pid = if track == 1 {
0x1011
} else {
0x1100 + (track - 2)
};
let is_video = track <= 1 || pid == 0x1011;
let stream_id: u8 = if is_video { 0xE0 } else { 0xBD };
let pts = encode_pts(pts_ms * 90);
let hdr = [0x00, 0x00, 0x01, stream_id, 0x00, 0x00, 0x80, 0x80, 0x05];
let mut pes = Vec::with_capacity(hdr.len() + pts.len() + data.len());
pes.extend_from_slice(&hdr);
pes.extend_from_slice(&pts);
// Video: convert MKV length-prefixed NALs to Annex B start codes
if is_video && data.len() > 4 {
let mut pos = 0;
while pos + 4 <= data.len() {
let nal_len = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
if nal_len == 0 || pos + nal_len > data.len() {
break;
}
pes.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
pes.extend_from_slice(&data[pos..pos + nal_len]);
pos += nal_len;
}
} else {
pes.extend_from_slice(data);
}
let mut off = 0;
let mut pusi = true;
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;
}
}
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,
]
}
+16 -48
View File
@@ -1,37 +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;
mod m2ts;
pub mod meta;
pub mod mkv;
pub mod mkvout;
pub mod pesout;
pub mod tsmux;
pub mod tsreader;
mod m2ts;
pub mod meta;
mod mkvstream;
pub mod network;
pub mod null;
@@ -40,40 +32,16 @@ pub mod resolve;
pub mod stdio;
pub mod ts;
pub use disc::{DiscOpenResult, DiscStream};
pub use iso::{IsoSectorReader, 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::{input, output, 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};
/// 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
}
/// Decryption keys for this stream. Default: no encryption.
/// Overridden by DiscStream and IsoStream for AACS/CSS.
fn keys(&self) -> crate::decrypt::DecryptKeys {
crate::decrypt::DecryptKeys::None
}
}
use std::io::{Seek, Write};
// WriteSeek — used internally by MKV muxer (container format requires seeking).
pub trait WriteSeek: Write + Seek {}
+3 -8
View File
@@ -60,12 +60,7 @@ impl NetworkStream {
// Read FMKV metadata header
let disc_title = meta::read_header(&mut reader)?
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"no FMKV metadata header from sender",
)
})?
.ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })?
.to_title();
Ok(Self {
@@ -79,7 +74,7 @@ impl crate::pes::Stream for NetworkStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
match &mut self.mode {
Mode::Read { reader } => crate::pes::PesFrame::deserialize(reader),
_ => Err(io::Error::new(io::ErrorKind::Unsupported, "network opened for writing")),
_ => Err(crate::error::Error::StreamWriteOnly.into()),
}
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
@@ -94,7 +89,7 @@ impl crate::pes::Stream for NetworkStream {
}
frame.serialize(writer)
}
_ => Err(io::Error::new(io::ErrorKind::Unsupported, "network opened for reading")),
_ => Err(crate::error::Error::StreamReadOnly.into()),
}
}
fn finish(&mut self) -> io::Result<()> {
+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 }
}
-124
View File
@@ -1,124 +0,0 @@
//! PES output streams — each writes its own format from PES frames.
use super::tsmux::TsMuxer;
use crate::disc::DiscTitle;
use crate::pes::PesFrame;
use std::io::{self, Write};
// ── M2TS ────────────────────────────────────────────────────────────────────
pub struct M2tsOutputStream {
muxer: TsMuxer<io::BufWriter<std::fs::File>>,
title: DiscTitle,
}
impl M2tsOutputStream {
pub fn create(path: &str, title: &DiscTitle) -> io::Result<Self> {
let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path, e)))?;
let mut writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
if !title.streams.is_empty() {
let m = super::meta::M2tsMeta::from_title(title);
super::meta::write_header(&mut writer, &m)?;
}
let pids = extract_pids(title);
let mut muxer = TsMuxer::new(writer, &pids);
for (i, cp) in title.codec_privates.iter().enumerate() {
if let Some(data) = cp {
muxer.set_codec_private(i, data.clone());
}
}
Ok(Self { muxer, title: title.clone() })
}
}
impl crate::pes::Stream for M2tsOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "M2TS output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
self.muxer.write_frame(frame.track, frame.pts, &frame.data)
}
fn finish(&mut self) -> io::Result<()> { self.muxer.finish() }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Null ────────────────────────────────────────────────────────────────────
pub struct NullOutputStream { title: DiscTitle }
impl NullOutputStream {
pub fn new(title: &DiscTitle) -> Self { Self { title: title.clone() } }
}
impl crate::pes::Stream for NullOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> { Ok(None) }
fn write(&mut self, _: &PesFrame) -> io::Result<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Stdio — serializes PES frames directly ──────────────────────────────────
pub struct StdioOutputStream {
writer: io::BufWriter<io::Stdout>,
title: DiscTitle,
}
impl StdioOutputStream {
pub fn new(title: &DiscTitle) -> Self {
Self { writer: io::BufWriter::new(io::stdout()), title: title.clone() }
}
}
impl crate::pes::Stream for StdioOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "stdio output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
frame.serialize(&mut self.writer)
}
fn finish(&mut self) -> io::Result<()> { self.writer.flush() }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Network — serializes PES frames over TCP ────────────────────────────────
pub struct NetworkOutputStream {
writer: io::BufWriter<std::net::TcpStream>,
title: DiscTitle,
}
impl NetworkOutputStream {
pub fn connect(addr: &str, title: &DiscTitle) -> io::Result<Self> {
let stream = std::net::TcpStream::connect(addr)?;
let mut writer = io::BufWriter::with_capacity(256 * 1024, stream);
if !title.streams.is_empty() {
let m = super::meta::M2tsMeta::from_title(title);
super::meta::write_header(&mut writer, &m)?;
writer.flush()?;
}
Ok(Self { writer, title: title.clone() })
}
}
impl crate::pes::Stream for NetworkOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "network output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
frame.serialize(&mut self.writer)
}
fn finish(&mut self) -> io::Result<()> { self.writer.flush() }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn extract_pids(title: &DiscTitle) -> Vec<u16> {
title.streams.iter().map(|s| match s {
crate::disc::Stream::Video(v) => v.pid,
crate::disc::Stream::Audio(a) => a.pid,
crate::disc::Stream::Subtitle(s) => s.pid,
}).collect()
}
+59 -191
View File
@@ -1,37 +1,31 @@
//! 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::DiscStream;
use super::iso::IsoStream;
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 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;
/// Default MKV lookahead buffer size.
/// Dynamically increased for UHD content (many streams delay video codec headers).
const MKV_LOOKAHEAD_DEFAULT: usize = 10 * 1024 * 1024;
const MKV_LOOKAHEAD_UHD: usize = 100 * 1024 * 1024;
/// Parsed stream URL.
pub enum StreamUrl {
/// Optical disc drive. Device path is optional (auto-detect if None).
@@ -88,17 +82,6 @@ impl StreamUrl {
}
/// Parse a URL string into a typed StreamUrl.
///
/// All URLs must use the `scheme://path` format. Bare paths are not supported.
///
/// ```text
/// disc:// → Disc { device: None }
/// disc:///dev/sg4 → Disc { device: Some("/dev/sg4") }
/// m2ts:///tmp/Dune.m2ts → M2ts { path: "/tmp/Dune.m2ts" }
/// mkv://Dune.mkv → Mkv { path: "Dune.mkv" }
/// network://10.0.0.1:9000 → Network { addr: "10.0.0.1:9000" }
/// null:// → Null
/// ```
pub fn parse_url(url: &str) -> StreamUrl {
if let Some(rest) = url.strip_prefix("disc://") {
return if rest.is_empty() {
@@ -143,19 +126,14 @@ pub fn parse_url(url: &str) -> StreamUrl {
/// Validate that a file path is non-empty and has a filename component.
fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
if path.as_os_str().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"),
));
return Err(crate::error::Error::StreamUrlMissingPath {
scheme: scheme.to_string(),
}.into());
}
if path.file_name().is_none() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{scheme}://{} is not a valid file path — must include a filename",
path.display()
),
));
return Err(crate::error::Error::StreamUrlInvalid {
url: format!("{scheme}://{}", path.display()),
}.into());
}
Ok(())
}
@@ -163,136 +141,18 @@ fn validate_file_path(path: &Path, 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://{addr} missing port — use network://{addr}:PORT"),
));
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 {
StreamUrl::Disc { device } => {
let result = DiscStream::open(
device.as_deref(),
opts.keydb_path.as_deref(),
opts.title_index.unwrap_or(0),
None,
)
.map_err(|e| io::Error::other(e.to_string()))?;
let mut stream = result.stream;
if opts.raw {
stream.set_raw();
}
Ok(Box::new(stream))
}
StreamUrl::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 = 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 = BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::open(reader)?))
}
StreamUrl::Network { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported,
"network:// requires PES pipeline — use input() instead of open_input()"))
}
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::input()))
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
let scan_opts = match &opts.keydb_path {
Some(p) => crate::disc::ScanOptions::with_keydb(p),
None => crate::disc::ScanOptions::default(),
};
let mut stream = IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?;
if opts.raw {
stream.set_raw();
}
Ok(Box::new(stream))
}
StreamUrl::Null => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
"null:// is write-only — cannot use as input"))
}
StreamUrl::Unknown { ref raw } => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw)))
}
}
}
/// Open a 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 {
StreamUrl::Disc { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported,
"disc:// is read-only — cannot use as output"))
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
Ok(Box::new(IsoStream::create(&path.to_string_lossy())?.meta(meta)))
}
StreamUrl::Null => {
Ok(Box::new(NullStream::new().meta(meta)))
}
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::output().meta(meta)))
}
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 = BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::new(writer).meta(meta)))
}
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 = BufWriter::with_capacity(IO_BUF_SIZE, file);
let lookahead = if meta.streams.len() > 15 {
MKV_LOOKAHEAD_UHD
} else {
MKV_LOOKAHEAD_DEFAULT
};
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead)))
}
StreamUrl::Network { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported,
"network:// output requires PES pipeline — use pipe() instead of open_output()"))
}
StreamUrl::Unknown { ref raw } => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", raw)))
}
}
}
/// Options for opening an input stream.
#[derive(Default)]
pub struct InputOptions {
@@ -302,42 +162,43 @@ pub struct InputOptions {
pub raw: bool,
}
// ── PES-based open ──────────────────────────────────────────────────────────
/// Open a PES input stream (produces PES frames).
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::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 = IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?;
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::Disc { device } => {
let result = DiscStream::open(
device.as_deref(),
opts.keydb_path.as_deref(),
opts.title_index.unwrap_or(0),
None,
)
.map_err(|e| io::Error::other(e.to_string()))?;
let mut stream = result.stream;
if opts.raw {
stream.set_raw();
}
Ok(Box::new(stream))
}
StreamUrl::Null => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
"null:// is write-only — cannot use as input"))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::open(path)
@@ -359,9 +220,13 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::input()))
}
StreamUrl::Null => {
Err(crate::error::Error::StreamWriteOnly.into())
}
StreamUrl::Unknown { ref raw } => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw)))
Err(crate::error::Error::StreamUrlInvalid {
url: raw.clone(),
}.into())
}
}
}
@@ -379,32 +244,35 @@ pub fn output(
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
let writer: Box<dyn super::WriteSeek> =
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
Ok(Box::new(super::mkvout::MkvOutputStream::create(writer, title)?))
Ok(Box::new(MkvStream::create(writer, title)?))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
Ok(Box::new(super::pesout::M2tsOutputStream::create(&path.to_string_lossy(), title)?))
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(super::pesout::NetworkOutputStream::connect(addr, title)?))
Ok(Box::new(NetworkStream::connect(addr)?.meta(title)))
}
StreamUrl::Stdio => {
Ok(Box::new(super::pesout::StdioOutputStream::new(title)))
Ok(Box::new(StdioStream::output(title)))
}
StreamUrl::Null => {
Ok(Box::new(super::pesout::NullOutputStream::new(title)))
Ok(Box::new(NullStream::new(title)))
}
StreamUrl::Disc { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only"))
Err(crate::error::Error::StreamReadOnly.into())
}
StreamUrl::Iso { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported,
"ISO output from PES not supported — use disc.copy() for raw ISO"))
Err(crate::error::Error::StreamReadOnly.into())
}
StreamUrl::Unknown { ref raw } => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("'{}' is not a valid stream URL", raw)))
Err(crate::error::Error::StreamUrlInvalid {
url: raw.clone(),
}.into())
}
}
}
+9 -92
View File
@@ -1,17 +1,13 @@
//! StdioStream — raw byte pipe via stdin/stdout. Format-agnostic.
//! StdioStream — PES frames via stdin/stdout.
use super::IOStream;
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.
pub struct StdioStream {
disc_title: DiscTitle,
reader: Option<io::Stdin>,
writer: Option<io::Stdout>,
writer: Option<io::BufWriter<io::Stdout>>,
}
impl StdioStream {
@@ -25,32 +21,26 @@ impl StdioStream {
}
/// 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())),
}
}
/// Set metadata (for output — passed through from input side).
pub fn meta(mut self, dt: &DiscTitle) -> Self {
self.disc_title = dt.clone();
self
}
}
impl crate::pes::Stream for StdioStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
match &mut self.reader {
Some(r) => crate::pes::PesFrame::deserialize(r),
None => Err(io::Error::new(io::ErrorKind::Unsupported, "stdio opened for writing")),
None => Err(crate::error::Error::StreamWriteOnly.into()),
}
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.writer {
Some(w) => frame.serialize(w),
None => Err(io::Error::new(io::ErrorKind::Unsupported, "stdio opened for reading")),
None => Err(crate::error::Error::StreamReadOnly.into()),
}
}
fn finish(&mut self) -> io::Result<()> {
@@ -59,76 +49,3 @@ impl crate::pes::Stream for StdioStream {
}
fn info(&self) -> &DiscTitle { &self.disc_title }
}
impl IOStream for StdioStream {
fn info(&self) -> &DiscTitle {
&self.disc_title
}
fn finish(&mut self) -> io::Result<()> {
if let Some(ref mut w) = self.writer {
w.flush()?;
}
Ok(())
}
}
impl Read for StdioStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self.reader {
Some(ref mut r) => r.read(buf),
None => Err(io::Error::new(
io::ErrorKind::Unsupported,
"stdio:// opened for output — cannot read",
)),
}
}
}
impl Write for StdioStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.writer {
Some(ref mut w) => w.write(buf),
None => Err(io::Error::new(
io::ErrorKind::Unsupported,
"stdio:// opened for input — cannot write",
)),
}
}
fn flush(&mut self) -> io::Result<()> {
match self.writer {
Some(ref mut w) => w.flush(),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn stdio_output_write_errors_on_read() {
let mut stream = StdioStream::output();
let mut buf = [0u8; 10];
let err = stream.read(&mut buf).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
assert!(err.to_string().contains("cannot read"), "got: {}", err);
}
#[test]
fn stdio_input_read_errors_on_write() {
let mut stream = StdioStream::input();
let err = stream.write(&[0u8; 10]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
assert!(err.to_string().contains("cannot write"), "got: {}", err);
}
#[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);
}
}
+60 -12
View File
@@ -23,16 +23,12 @@ impl PesFrame {
/// Serialize to bytes: track(1) | pts(8) | keyframe(1) | len(4) | data
pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
if self.track > 255 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"track index exceeds 255",
));
return Err(crate::error::Error::PesInvalidMagic.into());
}
if self.data.len() > u32::MAX as usize {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"frame data exceeds 4 GB",
));
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())?;
@@ -59,10 +55,7 @@ impl PesFrame {
let keyframe = header[9] != 0;
let len = u32::from_le_bytes([header[10], header[11], header[12], header[13]]) as usize;
if len > MAX_FRAME_SIZE {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("frame size {} exceeds maximum {}", len, MAX_FRAME_SIZE),
));
return Err(crate::error::Error::PesFrameTooLarge { size: len }.into());
}
let mut data = vec![0u8; len];
r.read_exact(&mut data)?;
@@ -100,3 +93,58 @@ pub trait Stream {
/// 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()
}
}