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:
@@ -1,5 +1,84 @@
|
|||||||
# Changelog
|
# 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)
|
## 0.8.0 (2026-04-11)
|
||||||
|
|
||||||
### DVD support
|
### DVD support
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# libfreemkv — Rules
|
||||||
|
|
||||||
|
## No English in library code
|
||||||
|
|
||||||
|
The library contains ZERO user-facing English text. All errors use numeric codes from `error.rs`. Applications (CLI, GUI, server) handle i18n.
|
||||||
|
|
||||||
|
- `io::Error::new(kind, "english string")` — NEVER. Use `Error::VariantName.into()`.
|
||||||
|
- If you need a new error, add a variant to `error.rs` with a code, not a string.
|
||||||
|
- Acceptable strings: debug/trace logging, test assertions, comments, data format strings (paths, codec IDs).
|
||||||
|
- `Error` implements `From<Error> for io::Error` — use `?` or `.into()` anywhere an `io::Error` is expected.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Streams are PES.** Every stream reads its format → PES frames out, or PES frames in → writes its format. One type per format.
|
||||||
|
- **Disc::copy() for sector dumps.** disc→ISO is NOT a stream. It's `Disc::copy()`.
|
||||||
|
- **DiscStream = any disc.** Physical drive or ISO file. Same type, different SectorReader.
|
||||||
|
- **No IOStream.** Deleted. No byte-level Read/Write on streams.
|
||||||
|
- **Streams don't know their size.** Progress/file_size is a CLI concern.
|
||||||
|
- **One method per action.** No `foo_with_X` variants. Use `Option<T>` params.
|
||||||
|
- **Streams impl Read only (conceptually).** No Seek, no File backing.
|
||||||
|
- **Functions return errors, only main() exits.** No `process::exit` in library code.
|
||||||
|
|
||||||
|
## Device rules
|
||||||
|
|
||||||
|
- Always use `/dev/sg*` not `/dev/sr*` for SCSI.
|
||||||
|
- `--raw` only skips decryption. Init/probe/speed still run.
|
||||||
|
- Each function does one thing. One runner orchestrates the sequence.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|||||||
@@ -18,33 +18,37 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libfreemkv = "0.6"
|
libfreemkv = "0.10"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use libfreemkv::{DriveSession, Disc, ScanOptions};
|
use libfreemkv::{Drive, Disc, ScanOptions};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
// Open drive — profiles are bundled, auto-identified
|
// Open drive — profiles are bundled, auto-identified
|
||||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
session.wait_ready()?; // wait for disc
|
drive.wait_ready()?; // wait for disc
|
||||||
session.init()?; // unlock + firmware upload
|
drive.init()?; // unlock + firmware upload
|
||||||
session.probe_disc()?; // probe disc surface for optimal speeds
|
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||||
|
|
||||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
// 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 {
|
for title in &disc.titles {
|
||||||
println!("{} — {} streams", title.duration_display(), title.streams.len());
|
println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read content (decrypted transparently if AACS keys available)
|
// Stream pipeline — read PES frames from any source, write to any output
|
||||||
let mut reader = disc.open_title(&mut session, 0)?;
|
let opts = libfreemkv::InputOptions::default();
|
||||||
while let Some(unit) = reader.read_unit()? {
|
let mut input = libfreemkv::input("iso://Disc.iso", &opts)?;
|
||||||
// 6144 bytes of content per aligned unit
|
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
|
## What It Does
|
||||||
@@ -63,32 +67,44 @@ while let Some(unit) = reader.read_unit()? {
|
|||||||
| Stream | Input | Output | Transport |
|
| Stream | Input | Output | Transport |
|
||||||
|--------|-------|--------|-----------|
|
|--------|-------|--------|-----------|
|
||||||
| DiscStream | Yes | -- | Optical drive via SCSI |
|
| 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 |
|
| MkvStream | Yes | Yes | Matroska container |
|
||||||
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
||||||
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
||||||
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
|
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
|
||||||
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
|
| 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.
|
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
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DriveSession — open any drive, identify, init (optional), read sectors
|
Drive — open, identify, init, unlock, read (with recovery)
|
||||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||||
└── PlatformDriver — MediaTek (supported), Renesas (planned)
|
└── 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
|
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||||
├── MPLS parser — playlists → titles + clips + streams
|
├── MPLS parser — playlists → titles + clips + streams
|
||||||
├── CLPI parser — clip info → EP map → sector extents
|
├── 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)
|
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||||
├── AACS — key resolution + content decryption
|
├── AACS — key resolution + content decryption
|
||||||
|
├── CSS — DVD CSS cipher (table-driven, no keys needed)
|
||||||
└── KEYDB — download + verify + save
|
└── 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.
|
See [docs/](docs/) for detailed technical documentation on each module.
|
||||||
|
|||||||
+2
-1
@@ -11,11 +11,12 @@ Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv),
|
|||||||
| Document | What it covers |
|
| Document | What it covers |
|
||||||
|----------|---------------|
|
|----------|---------------|
|
||||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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
|
## Reading Order
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -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:
|
AACS decryption is transparent to the application. The `Disc::scan()` method handles everything automatically:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use libfreemkv::{DriveSession, Disc};
|
use libfreemkv::{Drive, Disc};
|
||||||
use libfreemkv::disc::ScanOptions;
|
use libfreemkv::disc::ScanOptions;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
let mut drive = Drive::open(Path::new("/dev/sg4")).unwrap();
|
||||||
let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
drive.wait_ready().unwrap();
|
||||||
|
drive.init().unwrap();
|
||||||
|
let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
|
||||||
|
|
||||||
// Check encryption state
|
// Check encryption state
|
||||||
if disc.encrypted {
|
if disc.encrypted {
|
||||||
|
|||||||
+119
-169
@@ -12,81 +12,109 @@
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Open drive — explicit steps, app prints between them
|
// Open drive — explicit steps, app prints between them
|
||||||
let mut session = DriveSession::open(path)?;
|
let mut drive = Drive::open(path)?;
|
||||||
session.wait_ready()?;
|
drive.wait_ready()?;
|
||||||
session.init()?;
|
drive.init()?;
|
||||||
session.probe_disc()?;
|
drive.probe_disc()?;
|
||||||
|
|
||||||
// Scan disc
|
// Scan disc
|
||||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||||
|
|
||||||
// Browse
|
// Browse
|
||||||
disc.titles // Vec<Title>
|
disc.titles // Vec<DiscTitle>
|
||||||
disc.format // BD / UHD / DVD
|
disc.format // BD / UHD / DVD
|
||||||
disc.capacity_gb()
|
disc.capacity_gb()
|
||||||
|
```
|
||||||
|
|
||||||
// Rip with events
|
## PES Pipeline (primary API)
|
||||||
disc.rip(&mut session, 0, output, |event| {
|
|
||||||
match event.kind {
|
The PES pipeline is the main way to move content. All streams produce/consume
|
||||||
EventKind::BytesRead { bytes, total } => ...,
|
PES frames. The pipeline just reads frames and writes frames.
|
||||||
EventKind::ReadError { sector, error } => ...,
|
|
||||||
EventKind::Retry { attempt } => ...,
|
```rust
|
||||||
EventKind::SpeedChange { speed_kbs } => ...,
|
// URL-based — any source to any destination
|
||||||
EventKind::Complete { bytes, errors } => ...,
|
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()?;
|
||||||
|
|
||||||
// Rip without events
|
|
||||||
disc.rip(&mut session, 0, output, event::ignore)?;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stream Chains
|
The `pes::Stream` trait:
|
||||||
|
|
||||||
Each stream wraps the next. Builder pattern, no `.build()`.
|
|
||||||
|
|
||||||
### Raw m2ts
|
|
||||||
```rust
|
```rust
|
||||||
disc.rip(&mut session, 0, File::create("movie.m2ts")?, event::ignore)?;
|
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
|
## IOStream (byte-level API)
|
||||||
|
|
||||||
|
For raw byte copies (disc→ISO, resume, benchmarks). Lower level than PES.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let output = MkvStream::new(File::create("movie.mkv")?)
|
let opts = InputOptions::default();
|
||||||
.title(&disc.titles[0])
|
let mut input = open_input("iso://Disc.iso", &opts)?;
|
||||||
.max_buffer(10 * 1024 * 1024);
|
let mut output = open_output("mkv://Movie.mkv", input.info())?;
|
||||||
|
io::copy(&mut *input, &mut *output)?;
|
||||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
output.finish()?;
|
||||||
```
|
```
|
||||||
|
|
||||||
### MKV with progress (CLI)
|
## 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
|
```rust
|
||||||
let output = ProgressStream::new(
|
// PES pipeline (frame-level)
|
||||||
MkvStream::new(File::create("movie.mkv")?)
|
let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream
|
||||||
.title(&disc.titles[0])
|
let input = libfreemkv::input("iso://Dune.iso", &opts)?; // IsoStream
|
||||||
.max_buffer(10 * 1024 * 1024),
|
let output = libfreemkv::output("mkv://Dune.mkv", &title)?; // MkvOutputStream
|
||||||
total_bytes,
|
let output = libfreemkv::output("m2ts://Dune.m2ts", &title)?; // M2tsOutputStream
|
||||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
let output = libfreemkv::output("network://10.1.7.11: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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Future: transcode
|
### FMKV Metadata Header
|
||||||
```rust
|
|
||||||
let output = ProgressStream::new(
|
M2tsStream and NetworkStream embed a JSON metadata header before the BD-TS data:
|
||||||
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),
|
|
||||||
);
|
|
||||||
|
|
||||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
|
||||||
```
|
```
|
||||||
|
[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
|
## Events
|
||||||
|
|
||||||
@@ -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.
|
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.1.7.11: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.1.7.11: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.
|
|
||||||
|
|
||||||
## File Layout
|
## File Layout
|
||||||
|
|
||||||
@@ -214,38 +145,57 @@ libfreemkv/src/
|
|||||||
├── lib.rs Public exports
|
├── lib.rs Public exports
|
||||||
├── error.rs Error codes (no English)
|
├── error.rs Error codes (no English)
|
||||||
├── event.rs Event types for callbacks
|
├── event.rs Event types for callbacks
|
||||||
├── drive.rs DriveSession (open, init, read)
|
├── drive/ Drive (open, init, read with recovery)
|
||||||
├── disc.rs Disc (scan, rip, titles)
|
│ ├── mod.rs Drive struct, init, read, reset, eject
|
||||||
├── scsi/ SCSI transport (Linux, macOS)
|
│ ├── 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)
|
├── platform/ Drive unlock (MT1959 A/B)
|
||||||
├── aacs/ AACS decryption
|
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
|
||||||
├── udf.rs UDF filesystem parser
|
├── css/ DVD CSS cipher
|
||||||
├── mpls.rs Playlist parser
|
├── decrypt.rs Unified decrypt dispatcher (AACS/CSS/None)
|
||||||
├── clpi.rs Clip info parser
|
├── 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/
|
├── mux/
|
||||||
│ ├── mod.rs IOStream trait, public exports
|
│ ├── mod.rs IOStream trait, public exports
|
||||||
│ ├── resolve.rs URL parser + open_input/open_output
|
│ ├── resolve.rs URL parser + open_input/open_output + input/output
|
||||||
│ ├── meta.rs M2tsMeta (FMKV header format)
|
│ ├── meta.rs FMKV header format
|
||||||
│ ├── disc.rs DiscStream (optical drive)
|
│ ├── disc.rs DiscStream (optical drive → PES)
|
||||||
│ ├── mkvstream.rs MkvStream (bidirectional Matroska)
|
│ ├── iso.rs IsoStream (ISO image read/write)
|
||||||
│ ├── m2ts.rs M2tsStream (BD-TS + FMKV header)
|
│ ├── 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)
|
│ ├── network.rs NetworkStream (TCP + FMKV header)
|
||||||
│ ├── stdio.rs StdioStream (stdin/stdout pipe)
|
│ ├── stdio.rs StdioStream (stdin/stdout pipe)
|
||||||
│ ├── iso.rs IsoStream (Blu-ray ISO image)
|
|
||||||
│ ├── null.rs NullStream (discard + byte counter)
|
│ ├── null.rs NullStream (discard + byte counter)
|
||||||
│ ├── lookahead.rs LookaheadBuffer (codec header scanning)
|
│ ├── lookahead.rs LookaheadBuffer (codec header scanning)
|
||||||
│ ├── ts.rs BD-TS demuxer + PAT/PMT scanner
|
│ ├── 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
|
│ ├── ebml.rs EBML read/write primitives
|
||||||
│ ├── mkv.rs MKV muxer (tracks, clusters, cues)
|
│ ├── 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/
|
freemkv/src/
|
||||||
├── main.rs CLI dispatcher (URL routing)
|
├── main.rs CLI dispatcher (URL routing)
|
||||||
├── pipe.rs Generic source → dest copy
|
├── pipe.rs PES pipeline — source → dest copy
|
||||||
├── rip.rs Rip with progress display
|
├── disc_info.rs Disc/file info display
|
||||||
├── remux.rs Remux with progress display
|
|
||||||
├── disc_info.rs Disc info display
|
|
||||||
├── info.rs Drive info + profile submission
|
├── info.rs Drive info + profile submission
|
||||||
├── strings.rs i18n string table
|
├── strings.rs i18n string table
|
||||||
├── output.rs Verbosity-filtered output
|
├── output.rs Verbosity-filtered output
|
||||||
|
|||||||
+63
-35
@@ -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
|
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
|
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
|
`include_str!`. No configuration directory, no runtime file lookups for drive
|
||||||
support.
|
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
|
available. Callers read cleartext sectors without knowing whether the disc
|
||||||
was encrypted.
|
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.
|
The library never formats user-facing messages -- applications do that.
|
||||||
|
|
||||||
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
||||||
policy level. If a profile exists, the library uses it.
|
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
|
## Module Map
|
||||||
@@ -37,26 +41,39 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
|||||||
libfreemkv (lib.rs)
|
libfreemkv (lib.rs)
|
||||||
│
|
│
|
||||||
├── Drive Access
|
├── Drive Access
|
||||||
│ ├── drive DriveSession — open, identify, unlock, read
|
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
|
||||||
│ ├── scsi ScsiTransport trait + SG_IO implementation
|
│ ├── scsi ScsiTransport trait + platform backends (SG_IO, IOKit, SPTI)
|
||||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
│ ├── 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
|
│ ├── profile DriveProfile loading, matching, bundled JSON
|
||||||
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
|
│ ├── 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 Scanning
|
||||||
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
|
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
|
||||||
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
|
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
|
||||||
│ ├── mpls MPLS playlist parser — clips, streams, STN table
|
│ ├── mpls MPLS playlist parser — clips, streams, STN table
|
||||||
│ ├── clpi CLPI clip info parser — EP map, sector extents
|
│ ├── 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
|
├── Encryption
|
||||||
│ ├── aacs KEYDB parsing, VUK lookup, MKB processing, unit decryption
|
│ ├── aacs/ AACS handshake, KEYDB, VUK lookup, MKB, unit decryption
|
||||||
│ └── aacs_handshake ECDH bus authentication, Volume ID, Read Data Key
|
│ ├── 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
|
## 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
|
├─ 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::new() Instantiate chipset driver (Mt1959)
|
||||||
└─ Platform::unlock() Activate raw disc access mode
|
└─ Drive ready for init/unlock/read
|
||||||
```
|
```
|
||||||
|
|
||||||
After open, the session provides:
|
After open:
|
||||||
- `read_sectors(lba, count, buf)` -- raw sector reads (through platform driver)
|
- `init()` -- unlock + firmware upload + speed calibration
|
||||||
- `read_disc(lba, count, buf)` -- standard READ(10) for filesystem data
|
- `probe_disc()` -- probe disc surface for optimal speeds
|
||||||
- `scsi_execute(cdb, dir, buf, timeout)` -- arbitrary SCSI commands
|
- `read(lba, count, buf)` -- single read method with built-in error recovery
|
||||||
- `status()`, `calibrate()`, `read_config()`, `read_register()`
|
- `wait_ready()` -- wait for disc insertion
|
||||||
|
- `eject()` -- eject tray
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Disc Scanning Flow
|
## Disc Scanning Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Disc::scan(&mut session, &ScanOptions)
|
Disc::scan(&mut drive, &ScanOptions)
|
||||||
│
|
│
|
||||||
├─ READ CAPACITY Get disc size in sectors
|
├─ READ CAPACITY Get disc size in sectors
|
||||||
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
|
├─ 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
|
│ ├─ mpls::parse() Extract play items, STN streams
|
||||||
│ └─ For each clip:
|
│ └─ For each clip:
|
||||||
│ └─ clpi::parse() EP map → sector extents for the clip's time range
|
│ └─ 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
|
├─ Detect AACS Check for /AACS directory on disc
|
||||||
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
|
└─ 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:
|
The result is a `Disc` with:
|
||||||
- `titles: Vec<Title>` -- sorted by duration, each with streams and sector extents
|
- `titles: Vec<DiscTitle>` -- sorted by duration, each with streams, sector extents, codec_privates
|
||||||
- `aacs: Option<AacsState>` -- decryption keys if available
|
- `decrypt_keys()` -- DecryptKeys for content decryption
|
||||||
- `encrypted: bool` -- whether the disc uses AACS
|
- `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 |
|
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
|
||||||
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
|
| 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:
|
AACS 1.0 160-bit elliptic curve to obtain:
|
||||||
- **Volume ID** -- needed for VUK derivation (paths 2-4)
|
- **Volume ID** -- needed for VUK derivation (paths 2-4)
|
||||||
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
|
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
|
||||||
|
|
||||||
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
|
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`) |
|
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
|
||||||
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
|
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
|
||||||
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
|
| 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 |
|
| Platform | Transport | Status |
|
||||||
|----------|-----------|--------|
|
|----------|-----------|--------|
|
||||||
| Linux | SG_IO ioctl on `/dev/sr*` | Implemented |
|
| Linux | SG_IO ioctl on `/dev/sg*` | Supported |
|
||||||
| macOS | IOKit SCSI passthrough | Planned |
|
| macOS | IOKit SCSITask | Supported |
|
||||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Planned |
|
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
|
||||||
|
|
||||||
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
|
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
|
||||||
implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
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 |
|
| 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 |
|
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
|
||||||
|
|
||||||
The `Platform` trait abstracts chipset-specific commands. Each chipset implements
|
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
|
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
|
||||||
chipset-specific mode and buffer ID bytes.
|
chipset-specific mode and buffer ID bytes.
|
||||||
|
|
||||||
@@ -174,7 +204,5 @@ chipset-specific mode and buffer ID bytes.
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux builds produce a static library and two binaries (`freemkv-info`,
|
Produces a Rust library crate. The `libc` dependency is unix-only (gated).
|
||||||
`freemkv-test`). The `libc` dependency is Linux-only. On non-Linux platforms,
|
All three platforms build and pass CI.
|
||||||
the library compiles but `scsi::open()` returns a platform-not-supported error
|
|
||||||
until the IOKit/SPTI backends are implemented.
|
|
||||||
|
|||||||
+51
-36
@@ -9,12 +9,18 @@ This is the starting point for understanding the library.
|
|||||||
Insert disc
|
Insert disc
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
1. Open drive (drive.rs)
|
1. Open drive (drive/mod.rs)
|
||||||
│ INQUIRY → identify drive
|
│ INQUIRY → identify drive
|
||||||
│ Match bundled profile → chipset, unlock parameters
|
│ 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
|
│ Allocate AGID
|
||||||
│ Exchange certificates + nonces (ECDH)
|
│ Exchange certificates + nonces (ECDH)
|
||||||
│ Derive bus key
|
│ Derive bus key
|
||||||
@@ -22,11 +28,6 @@ Insert disc
|
|||||||
│ (fails gracefully if drive doesn't support AACS for this 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)
|
4. Read UDF filesystem (udf.rs)
|
||||||
│ Sector 256: AVDP → find Volume Descriptor Sequence
|
│ Sector 256: AVDP → find Volume Descriptor Sequence
|
||||||
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
|
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
|
||||||
@@ -35,80 +36,94 @@ Insert disc
|
|||||||
│ → docs/udf.md
|
│ → 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/Unit_Key_RO.inf → SHA1 = disc hash
|
||||||
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
|
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
|
||||||
│ MKB via SCSI → for key derivation fallback
|
│ MKB via SCSI → for key derivation fallback
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
6. Resolve AACS keys (aacs.rs → resolve_keys)
|
6. Resolve encryption keys (decrypt.rs → resolve_encryption)
|
||||||
|
│ BD AACS:
|
||||||
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||||||
│ Path 2: KEYDB media key + Volume ID → VUK
|
│ Path 2: KEYDB media key + Volume ID → VUK
|
||||||
│ Path 3: MKB + processing keys → media key → VUK
|
│ Path 3: MKB + processing keys → media key → VUK
|
||||||
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
||||||
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
||||||
|
│ DVD CSS:
|
||||||
|
│ Table-driven cipher — no KEYDB needed
|
||||||
│ → docs/aacs.md
|
│ → docs/aacs.md
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
7. Parse playlists (mpls.rs)
|
7. Parse playlists (mpls.rs) — BD/UHD only
|
||||||
│ BDMV/PLAYLIST/*.mpls → titles with play items
|
│ BDMV/PLAYLIST/*.mpls → titles with play items
|
||||||
│ Each play item: clip ID, in/out timestamps
|
│ Each play item: clip ID, in/out timestamps
|
||||||
│ STN table: video, audio, subtitle streams with codec + language
|
│ STN table: video, audio, subtitle streams with codec + language
|
||||||
│ → docs/mpls.md
|
│ → 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)
|
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
|
||||||
│ Coarse + fine entries → full PTS and SPN
|
│ Coarse + fine entries → full PTS and SPN
|
||||||
│ SPN → byte offset → sector extents for reading
|
│ SPN → byte offset → sector extents for reading
|
||||||
│ → docs/clpi.md
|
│ → 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
|
│ 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.
|
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
10. Read + decrypt content (disc.rs → ContentReader)
|
10. Stream content (mux/disc.rs → DiscStream)
|
||||||
│ For each aligned unit (6144 bytes = 3 sectors):
|
│ Read sectors → decrypt → TS demux → PES frames
|
||||||
│ Read 3 sectors from disc
|
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
||||||
│ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC)
|
│ Drive::read() handles error recovery (min speed → reset → retry)
|
||||||
│ If encrypted: unit decrypt (per-unit key derivation + AES-CBC)
|
|
||||||
│ Output decrypted content
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Decrypted m2ts stream → ready for muxing/backup
|
PES frames → output stream (MKV, M2TS, network, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Summary
|
## API Summary
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Steps 1 + 3 (open + unlock)
|
// Open + init drive
|
||||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
|
drive.wait_ready()?;
|
||||||
|
drive.init()?;
|
||||||
|
drive.probe_disc()?;
|
||||||
|
|
||||||
// Steps 2 + 4-9 (AACS + scan)
|
// Scan disc (UDF + playlists + AACS — all automatic)
|
||||||
let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?;
|
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||||
|
|
||||||
// Step 10 (read + decrypt)
|
// Stream pipeline — PES frames from any source to any output
|
||||||
let mut reader = disc.open_title(&mut session, 0)?;
|
let opts = InputOptions::default();
|
||||||
while let Some(unit) = reader.read_unit()? {
|
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||||
output.write_all(&unit)?;
|
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 Reference
|
||||||
|
|
||||||
| Module | Doc | Purpose |
|
| Module | Doc | Purpose |
|
||||||
|--------|-----|---------|
|
|--------|-----|---------|
|
||||||
| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read |
|
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, read (with recovery) |
|
||||||
| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport |
|
| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) |
|
||||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||||
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
|
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
|
||||||
| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt |
|
| ifo.rs | -- | DVD IFO parser |
|
||||||
| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication |
|
| aacs/ | [aacs.md](aacs.md) | Key resolution + content decrypt + bus handshake |
|
||||||
| disc.rs | -- | High-level scan + read API |
|
| css/ | -- | DVD CSS cipher |
|
||||||
| jar.rs | -- | BD-J audio track labels |
|
| decrypt.rs | -- | Unified decrypt dispatcher (AACS/CSS/None) |
|
||||||
| error.rs | -- | Error codes (E1xxx-E7xxx) |
|
| 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
@@ -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.
|
drive profile, and the chipset-specific platform driver.
|
||||||
|
|
||||||
### Opening a Drive
|
### Opening a Drive
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Full open: identify → match profile → unlock
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
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)?;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**`open()`** performs the full sequence: open device, send INQUIRY, match
|
`open()` performs: open device → send INQUIRY → match profile → instantiate
|
||||||
profile, instantiate platform driver, and unlock. Unlock failures are silently
|
platform driver. The drive is ready for `wait_ready()` and `init()`.
|
||||||
ignored (unencrypted discs do not need it). After `open()`, both raw sector
|
|
||||||
reads and standard READ(10) work immediately.
|
|
||||||
|
|
||||||
**`open_no_unlock()`** skips the unlock step. This is required when AACS bus
|
### Drive Operations
|
||||||
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
|
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `unlock()` | Activate raw disc access mode via platform driver |
|
| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) |
|
||||||
| `is_unlocked()` | Check if raw mode is active |
|
| `init()` | Firmware upload + unlock + speed calibration |
|
||||||
| `calibrate()` | Build speed lookup table for the current disc |
|
| `probe_disc()` | Probe disc surface for optimal speeds |
|
||||||
| `read_sectors(lba, count, buf)` | Raw sector read (requires unlock + calibrate) |
|
| `read(lba, count, buf)` | Read sectors with built-in error recovery |
|
||||||
| `read_disc(lba, count, buf)` | Standard READ(10) with 5s timeout |
|
| `reset()` | Close/reopen device, TUR, escalate if needed |
|
||||||
| `status()` | Query drive status and feature flags |
|
| `lock_tray()` | Prevent tray ejection during rip |
|
||||||
| `read_config()` | Read drive configuration block (1888 bytes) |
|
| `unlock_tray()` | Allow tray ejection (also runs on Drop) |
|
||||||
| `read_register(index)` | Read 16-byte hardware register |
|
| `eject()` | Eject disc tray |
|
||||||
| `probe(sub_cmd, addr, len)` | Generic READ BUFFER with caller parameters |
|
| `drive_status()` | Query physical state (disc present, tray open, etc.) |
|
||||||
| `scsi_execute(cdb, dir, buf, timeout)` | Send an arbitrary SCSI CDB |
|
| `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
|
### Trait
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub trait ScsiTransport {
|
pub trait ScsiTransport: Send {
|
||||||
fn execute(
|
fn execute(
|
||||||
&mut self,
|
&mut self,
|
||||||
cdb: &[u8],
|
cdb: &[u8],
|
||||||
@@ -66,20 +70,24 @@ pub trait ScsiTransport {
|
|||||||
data: &mut [u8],
|
data: &mut [u8],
|
||||||
timeout_ms: u32,
|
timeout_ms: u32,
|
||||||
) -> Result<ScsiResult>;
|
) -> Result<ScsiResult>;
|
||||||
|
|
||||||
|
fn reset(&mut self, device: &str) -> Result<()>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All drive communication goes through this trait. The library never opens file
|
All drive communication goes through this trait. The library never opens file
|
||||||
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
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`.
|
The Linux backend opens with `O_RDWR | O_NONBLOCK`, constructs `sg_io_hdr`,
|
||||||
2. Constructs an `sg_io_hdr` struct with the CDB, data buffer, and timeout.
|
and returns `ScsiResult` with status, bytes transferred, and sense data.
|
||||||
3. Calls `ioctl(fd, SG_IO, &hdr)`.
|
|
||||||
4. Returns `ScsiResult` with status, bytes transferred, and sense data.
|
|
||||||
|
|
||||||
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
|
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
|
||||||
sense buffer and returns `Error::ScsiError`.
|
sense buffer and returns `Error::ScsiError`.
|
||||||
@@ -119,8 +127,8 @@ date for drives where Feature 010C is unavailable.
|
|||||||
|
|
||||||
## Drive Profiles
|
## Drive Profiles
|
||||||
|
|
||||||
Profiles are JSON objects compiled into the binary (`profiles.json`,
|
Profiles are JSON objects compiled into the binary (`profiles.json`).
|
||||||
206 entries). Each profile contains:
|
Each profile contains:
|
||||||
|
|
||||||
| Field | Purpose |
|
| Field | Purpose |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
@@ -148,7 +156,7 @@ let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
|
|||||||
|
|
||||||
### MediaTek MT1959
|
### 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:
|
logic with different SCSI parameters:
|
||||||
|
|
||||||
| Variant | READ BUFFER mode | Buffer ID |
|
| Variant | READ BUFFER mode | Buffer ID |
|
||||||
@@ -156,7 +164,7 @@ logic with different SCSI parameters:
|
|||||||
| MT1959-A | 0x01 | 0x44 |
|
| MT1959-A | 0x01 | 0x44 |
|
||||||
| MT1959-B | 0x02 | 0x77 |
|
| MT1959-B | 0x02 | 0x77 |
|
||||||
|
|
||||||
The Platform trait maps to 10 command handlers:
|
The Platform trait maps to command handlers:
|
||||||
|
|
||||||
| Handler | Function | Description |
|
| Handler | Function | Description |
|
||||||
|---------|----------|-------------|
|
|---------|----------|-------------|
|
||||||
@@ -183,64 +191,32 @@ Optical drive firmware restricts what applications can read from disc. Without
|
|||||||
unlock:
|
unlock:
|
||||||
|
|
||||||
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
|
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
|
||||||
playlists, and CLPI clip info are readable without unlock. The `read_disc()`
|
playlists, and CLPI clip info are readable without unlock. Standard READ(10)
|
||||||
method uses standard READ(10) and works on any drive.
|
works on any drive.
|
||||||
|
|
||||||
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
|
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
|
||||||
SCSI errors (sense key 0x05, illegal request) when an application attempts to
|
SCSI errors (sense key 0x05, illegal request) when an application attempts to
|
||||||
read sectors containing encrypted m2ts content without prior AACS
|
read sectors containing encrypted m2ts content without prior AACS
|
||||||
authentication via the bus key.
|
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
|
- **Raw mode bypasses firmware restrictions.** After unlock, the drive accepts
|
||||||
READ(10) with the raw read flag (CDB byte 1 = 0x08) for all sectors,
|
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.
|
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
|
||||||
These must execute before unlock because:
|
On some drives these must execute before unlock. The `Disc::scan()` handles
|
||||||
|
this internally — it manages the handshake/unlock ordering automatically.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Speed Control
|
## Speed Control
|
||||||
|
|
||||||
After `calibrate()`, the platform driver maintains a 64-entry speed lookup table
|
After `probe_disc()`, the platform driver maintains a speed lookup table
|
||||||
built by probing the disc surface. On each `read_sectors()` call, the driver:
|
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.
|
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
|
||||||
3. Performs the READ(10).
|
3. Performs the READ(10).
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -54,13 +54,7 @@ pub fn decrypt_sectors(
|
|||||||
let uk = match unit_keys.get(unit_key_idx) {
|
let uk = match unit_keys.get(unit_key_idx) {
|
||||||
Some((_, k)) => *k,
|
Some((_, k)) => *k,
|
||||||
None => {
|
None => {
|
||||||
return Err(crate::error::Error::DecryptFailed {
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
reason: format!(
|
|
||||||
"unit_key_idx {} out of range (have {} keys)",
|
|
||||||
unit_key_idx,
|
|
||||||
unit_keys.len()
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let rdk = read_data_key.as_ref();
|
let rdk = read_data_key.as_ref();
|
||||||
|
|||||||
+82
-328
@@ -15,7 +15,6 @@ mod encrypt;
|
|||||||
use crate::drive::Drive;
|
use crate::drive::Drive;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::sector::SectorReader;
|
use crate::sector::SectorReader;
|
||||||
use crate::speed::DriveSpeed;
|
|
||||||
use crate::udf;
|
use crate::udf;
|
||||||
|
|
||||||
use encrypt::HandshakeResult;
|
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 {
|
impl Disc {
|
||||||
/// Disc capacity in GB
|
/// Disc capacity in GB
|
||||||
pub fn capacity_gb(&self) -> f64 {
|
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 {
|
impl Disc {
|
||||||
/// Get the resolved decryption keys for this disc.
|
/// Get the resolved decryption keys for this disc.
|
||||||
/// Used by disc-to-ISO and other full-disc operations.
|
/// 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
|
/// Raw sector copy — write the entire disc image to a file.
|
||||||
/// is encrypted and keys were found during scan(), content is decrypted
|
|
||||||
/// on the fly. Unencrypted discs pass through unchanged.
|
|
||||||
///
|
///
|
||||||
pub fn open_title<'a>(
|
/// This is NOT a stream operation. It copies sectors 0→capacity byte-for-byte,
|
||||||
&'a self,
|
/// producing a valid ISO/UDF image. The disc's filesystem structure is preserved.
|
||||||
session: &'a mut Drive,
|
///
|
||||||
title_idx: usize,
|
/// If `decrypt` is true and keys are available, sectors are decrypted on the fly.
|
||||||
) -> Result<ContentReader<'a>> {
|
/// If `resume` is true and the file already exists, resumes from the last safe position.
|
||||||
let title = self.titles.get(title_idx).ok_or(Error::DiscTitleRange {
|
///
|
||||||
index: title_idx,
|
/// `on_progress` is called periodically with (bytes_done, total_bytes).
|
||||||
count: self.titles.len(),
|
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.
|
let total_bytes = self.capacity_sectors as u64 * 2048;
|
||||||
// SET_CD_SPEED is only used reactively by the error handler to slow
|
let keys = if decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None };
|
||||||
// down on read errors, then let the drive recover.
|
|
||||||
|
|
||||||
// Detect kernel max transfer size for this device
|
// Resume: check existing file
|
||||||
let max_batch = detect_max_batch_sectors(session.device_path());
|
let (start_lba, file) = if resume {
|
||||||
|
match std::fs::metadata(path) {
|
||||||
let decrypt_keys = if let Some(ref aacs) = self.aacs {
|
Ok(meta) if meta.len() > 0 => {
|
||||||
crate::decrypt::DecryptKeys::Aacs {
|
let safe_sectors = (meta.len() / 2048).saturating_sub(5) as u32;
|
||||||
unit_keys: aacs.unit_keys.clone(),
|
let mut f = std::fs::OpenOptions::new()
|
||||||
read_data_key: aacs.read_data_key,
|
.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 if let Some(ref css) = self.css {
|
|
||||||
crate::decrypt::DecryptKeys::Css {
|
|
||||||
title_key: css.title_key,
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::decrypt::DecryptKeys::None
|
let f = std::fs::File::create(path)
|
||||||
|
.map_err(|e| Error::IoError { source: e })?;
|
||||||
|
(0u32, f)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(ContentReader {
|
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||||
session,
|
let batch: u16 = 64; // 128 KB per read
|
||||||
decrypt_keys,
|
let mut lba = start_lba;
|
||||||
extents: title.extents.clone(),
|
let mut bytes_done = start_lba as u64 * 2048;
|
||||||
current_extent: 0,
|
let mut buf = vec![0u8; batch as usize * 2048];
|
||||||
current_offset: 0,
|
|
||||||
unit_key_idx: 0,
|
while lba < self.capacity_sectors {
|
||||||
read_buf: Vec::with_capacity(max_batch as usize * 2048),
|
let remaining = self.capacity_sectors - lba;
|
||||||
buf_pos: 0,
|
let count = remaining.min(batch as u32) as u16;
|
||||||
buf_len: 0,
|
let bytes = count as usize * 2048;
|
||||||
batch_sectors: max_batch,
|
|
||||||
max_batch_sectors: max_batch,
|
reader
|
||||||
ok_streak: 0,
|
.read_sectors(lba, count, &mut buf[..bytes])
|
||||||
error_streak: 0,
|
.map_err(|e| Error::IoError {
|
||||||
errors: 0,
|
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.
|
/// Detect the maximum transfer size in sectors for a device.
|
||||||
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
|
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
|
||||||
/// For sg devices, resolves the corresponding block device via sysfs.
|
/// 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
|
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 ────────────────────────────────────────────────────────
|
// ─── Format helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums
|
// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums
|
||||||
|
|||||||
+4
-4
@@ -141,8 +141,8 @@ impl Drive {
|
|||||||
}
|
}
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
}
|
}
|
||||||
Err(Error::DeviceNotFound {
|
Err(Error::DeviceNotReady {
|
||||||
path: format!("{}: drive not ready after 30s", self.device_path),
|
path: self.device_path.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,8 +264,8 @@ impl Drive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(Error::DeviceNotFound {
|
Err(Error::DeviceResetFailed {
|
||||||
path: format!("{}: drive reset failed", self.device_path),
|
path: self.device_path.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+103
-127
@@ -15,32 +15,40 @@
|
|||||||
//! | E6xxx | Disc format errors |
|
//! | E6xxx | Disc format errors |
|
||||||
//! | E7xxx | AACS errors |
|
//! | E7xxx | AACS errors |
|
||||||
//! | E8xxx | Keydb errors |
|
//! | E8xxx | Keydb errors |
|
||||||
//! | E9xxx | Mux errors |
|
//! | E9xxx | Stream/mux errors |
|
||||||
|
|
||||||
// ── Error codes ─────────────────────────────────────────────────────────────
|
// ── Error codes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Device (1xxx)
|
||||||
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
|
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
|
||||||
pub const E_DEVICE_PERMISSION: u16 = 1001;
|
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_UNSUPPORTED_DRIVE: u16 = 2000;
|
||||||
pub const E_PROFILE_NOT_FOUND: u16 = 2001;
|
|
||||||
pub const E_PROFILE_PARSE: u16 = 2002;
|
pub const E_PROFILE_PARSE: u16 = 2002;
|
||||||
|
|
||||||
|
// Unlock (3xxx)
|
||||||
pub const E_UNLOCK_FAILED: u16 = 3000;
|
pub const E_UNLOCK_FAILED: u16 = 3000;
|
||||||
pub const E_SIGNATURE_MISMATCH: u16 = 3001;
|
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_ERROR: u16 = 4000;
|
||||||
pub const E_SCSI_TIMEOUT: u16 = 4001;
|
|
||||||
|
// I/O (5xxx)
|
||||||
pub const E_IO_ERROR: u16 = 5000;
|
pub const E_IO_ERROR: u16 = 5000;
|
||||||
pub const E_WRITE_ERROR: u16 = 5001;
|
|
||||||
// Disc format (6xxx)
|
// Disc format (6xxx)
|
||||||
pub const E_DISC_READ: u16 = 6000;
|
pub const E_DISC_READ: u16 = 6000;
|
||||||
pub const E_MPLS_PARSE: u16 = 6001;
|
pub const E_MPLS_PARSE: u16 = 6001;
|
||||||
pub const E_CLPI_PARSE: u16 = 6002;
|
pub const E_CLPI_PARSE: u16 = 6002;
|
||||||
pub const E_UDF_NOT_FOUND: u16 = 6003;
|
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_TITLE_RANGE: u16 = 6005;
|
||||||
pub const E_DISC_NO_EXTENTS: u16 = 6006;
|
|
||||||
pub const E_IFO_PARSE: u16 = 6007;
|
pub const E_IFO_PARSE: u16 = 6007;
|
||||||
|
pub const E_MKV_INVALID: u16 = 6008;
|
||||||
|
pub const E_NO_STREAMS: u16 = 6009;
|
||||||
|
|
||||||
// AACS (7xxx)
|
// AACS (7xxx)
|
||||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||||
pub const E_AACS_CERT_SHORT: u16 = 7001;
|
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_READ: u16 = 7009;
|
||||||
pub const E_AACS_VID_MAC: u16 = 7010;
|
pub const E_AACS_VID_MAC: u16 = 7010;
|
||||||
pub const E_AACS_DATA_KEY: u16 = 7011;
|
pub const E_AACS_DATA_KEY: u16 = 7011;
|
||||||
pub const E_AACS_VUK_DERIVE: u16 = 7012;
|
|
||||||
pub const E_DECRYPT_FAILED: u16 = 7013;
|
pub const E_DECRYPT_FAILED: u16 = 7013;
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||||
pub const E_KEYDB_HTTP: u16 = 8001;
|
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_WRITE: u16 = 8003;
|
||||||
pub const E_KEYDB_PARSE: u16 = 8004;
|
pub const E_KEYDB_PARSE: u16 = 8004;
|
||||||
pub const E_KEYDB_LOAD: u16 = 8005;
|
pub const E_KEYDB_LOAD: u16 = 8005;
|
||||||
// Mux (9xxx)
|
|
||||||
pub const E_MUX_LOOKAHEAD: u16 = 9000;
|
// Stream/mux (9xxx)
|
||||||
pub const E_MUX_WRITE: u16 = 9001;
|
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 ──────────────────────────────────────────────────────────────
|
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Structured error with numeric code and context data. No English text.
|
/// Structured error with numeric code and context data. No English text.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
/// Device not found at the given path.
|
// Device (1xxx)
|
||||||
DeviceNotFound { path: String },
|
DeviceNotFound { path: String },
|
||||||
/// Insufficient permissions to open the device.
|
|
||||||
DevicePermission { path: String },
|
DevicePermission { path: String },
|
||||||
|
DeviceNotReady { path: String },
|
||||||
|
DeviceResetFailed { path: String },
|
||||||
|
|
||||||
/// Drive model is not in the profile database.
|
// Profile (2xxx)
|
||||||
UnsupportedDrive {
|
UnsupportedDrive {
|
||||||
vendor_id: String,
|
vendor_id: String,
|
||||||
product_id: String,
|
product_id: String,
|
||||||
product_revision: 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,
|
ProfileParse,
|
||||||
|
|
||||||
/// Drive unlock (firmware upload) failed.
|
// Unlock (3xxx)
|
||||||
UnlockFailed,
|
UnlockFailed,
|
||||||
/// Firmware signature verification failed.
|
|
||||||
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
||||||
/// Operation requires an unlocked drive.
|
|
||||||
NotUnlocked,
|
|
||||||
/// Operation requires a calibrated drive.
|
|
||||||
NotCalibrated,
|
|
||||||
|
|
||||||
/// SCSI command returned an error status.
|
// SCSI (4xxx)
|
||||||
ScsiError {
|
ScsiError {
|
||||||
opcode: u8,
|
opcode: u8,
|
||||||
status: u8,
|
status: u8,
|
||||||
sense_key: u8,
|
sense_key: u8,
|
||||||
},
|
},
|
||||||
/// SCSI command timed out.
|
|
||||||
ScsiTimeout { opcode: u8 },
|
|
||||||
|
|
||||||
/// Underlying I/O error.
|
// I/O (5xxx)
|
||||||
IoError { source: std::io::Error },
|
IoError { source: std::io::Error },
|
||||||
/// Write operation failed.
|
|
||||||
WriteError,
|
|
||||||
|
|
||||||
/// Failed to read disc sector.
|
// Disc format (6xxx)
|
||||||
DiscRead { sector: u64 },
|
DiscRead { sector: u64 },
|
||||||
/// MPLS playlist parsing failed.
|
|
||||||
MplsParse,
|
MplsParse,
|
||||||
/// CLPI clip info parsing failed.
|
|
||||||
ClpiParse,
|
ClpiParse,
|
||||||
/// File not found on the UDF filesystem.
|
|
||||||
UdfNotFound { path: String },
|
UdfNotFound { path: String },
|
||||||
/// Disc contains no playable titles.
|
|
||||||
DiscNoTitles,
|
|
||||||
/// Title index out of range.
|
|
||||||
DiscTitleRange { index: usize, count: usize },
|
DiscTitleRange { index: usize, count: usize },
|
||||||
/// Title has no sector extents to read.
|
|
||||||
DiscNoExtents,
|
|
||||||
/// DVD IFO file parsing failed.
|
|
||||||
IfoParse,
|
IfoParse,
|
||||||
|
MkvInvalid,
|
||||||
|
NoStreams,
|
||||||
|
|
||||||
/// No AACS decryption keys available for this disc.
|
// AACS (7xxx)
|
||||||
AacsNoKeys,
|
AacsNoKeys,
|
||||||
/// Host certificate too short.
|
|
||||||
AacsCertShort,
|
AacsCertShort,
|
||||||
/// Failed to allocate AGID for AACS handshake.
|
|
||||||
AacsAgidAlloc,
|
AacsAgidAlloc,
|
||||||
/// Drive rejected the host certificate.
|
|
||||||
AacsCertRejected,
|
AacsCertRejected,
|
||||||
/// Failed to read drive certificate.
|
|
||||||
AacsCertRead,
|
AacsCertRead,
|
||||||
/// Drive certificate verification failed.
|
|
||||||
AacsCertVerify,
|
AacsCertVerify,
|
||||||
/// Failed to read host key from drive.
|
|
||||||
AacsKeyRead,
|
AacsKeyRead,
|
||||||
/// Drive rejected the host key.
|
|
||||||
AacsKeyRejected,
|
AacsKeyRejected,
|
||||||
/// Host key verification failed.
|
|
||||||
AacsKeyVerify,
|
AacsKeyVerify,
|
||||||
/// Failed to read Volume ID.
|
|
||||||
AacsVidRead,
|
AacsVidRead,
|
||||||
/// Volume ID MAC verification failed.
|
|
||||||
AacsVidMac,
|
AacsVidMac,
|
||||||
/// Failed to derive the data key.
|
|
||||||
AacsDataKey,
|
AacsDataKey,
|
||||||
/// Failed to derive the Volume Unique Key.
|
DecryptFailed,
|
||||||
AacsVukDerive,
|
|
||||||
/// Decryption failed — missing or invalid keys.
|
|
||||||
DecryptFailed { reason: String },
|
|
||||||
|
|
||||||
/// Failed to connect to the KEYDB server.
|
// Keydb (8xxx)
|
||||||
KeydbConnect { host: String },
|
KeydbConnect { host: String },
|
||||||
/// KEYDB server returned an HTTP error.
|
|
||||||
KeydbHttp { status: u16 },
|
KeydbHttp { status: u16 },
|
||||||
/// Downloaded KEYDB file is invalid (no entries found).
|
|
||||||
KeydbInvalid,
|
KeydbInvalid,
|
||||||
/// Failed to write KEYDB to disk.
|
|
||||||
KeydbWrite { path: String },
|
KeydbWrite { path: String },
|
||||||
/// Failed to parse KEYDB file.
|
|
||||||
KeydbParse,
|
KeydbParse,
|
||||||
/// Failed to load KEYDB from disk.
|
|
||||||
KeydbLoad { path: String },
|
KeydbLoad { path: String },
|
||||||
|
|
||||||
/// Lookahead buffer exhausted before codec headers found.
|
// Stream/mux (9xxx)
|
||||||
MuxLookahead,
|
StreamReadOnly,
|
||||||
/// Muxer write failed.
|
StreamWriteOnly,
|
||||||
MuxWrite,
|
StreamUrlInvalid { url: String },
|
||||||
|
StreamUrlMissingPath { scheme: String },
|
||||||
|
StreamUrlMissingPort { addr: String },
|
||||||
|
PesFrameTooLarge { size: usize },
|
||||||
|
PesInvalidMagic,
|
||||||
|
IsoTooLarge { path: String },
|
||||||
|
NoMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
@@ -185,25 +166,22 @@ impl Error {
|
|||||||
match self {
|
match self {
|
||||||
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
|
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
|
||||||
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
|
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
|
||||||
|
Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY,
|
||||||
|
Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED,
|
||||||
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
|
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
|
||||||
Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND,
|
|
||||||
Error::ProfileParse => E_PROFILE_PARSE,
|
Error::ProfileParse => E_PROFILE_PARSE,
|
||||||
Error::UnlockFailed => E_UNLOCK_FAILED,
|
Error::UnlockFailed => E_UNLOCK_FAILED,
|
||||||
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
||||||
Error::NotUnlocked => E_NOT_UNLOCKED,
|
|
||||||
Error::NotCalibrated => E_NOT_CALIBRATED,
|
|
||||||
Error::ScsiError { .. } => E_SCSI_ERROR,
|
Error::ScsiError { .. } => E_SCSI_ERROR,
|
||||||
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
|
|
||||||
Error::IoError { .. } => E_IO_ERROR,
|
Error::IoError { .. } => E_IO_ERROR,
|
||||||
Error::WriteError => E_WRITE_ERROR,
|
|
||||||
Error::DiscRead { .. } => E_DISC_READ,
|
Error::DiscRead { .. } => E_DISC_READ,
|
||||||
Error::MplsParse => E_MPLS_PARSE,
|
Error::MplsParse => E_MPLS_PARSE,
|
||||||
Error::ClpiParse => E_CLPI_PARSE,
|
Error::ClpiParse => E_CLPI_PARSE,
|
||||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||||
Error::DiscNoTitles => E_DISC_NO_TITLES,
|
|
||||||
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
||||||
Error::DiscNoExtents => E_DISC_NO_EXTENTS,
|
|
||||||
Error::IfoParse => E_IFO_PARSE,
|
Error::IfoParse => E_IFO_PARSE,
|
||||||
|
Error::MkvInvalid => E_MKV_INVALID,
|
||||||
|
Error::NoStreams => E_NO_STREAMS,
|
||||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||||
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
||||||
@@ -216,16 +194,22 @@ impl Error {
|
|||||||
Error::AacsVidRead => E_AACS_VID_READ,
|
Error::AacsVidRead => E_AACS_VID_READ,
|
||||||
Error::AacsVidMac => E_AACS_VID_MAC,
|
Error::AacsVidMac => E_AACS_VID_MAC,
|
||||||
Error::AacsDataKey => E_AACS_DATA_KEY,
|
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::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||||
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
|
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
|
||||||
Error::KeydbParse => E_KEYDB_PARSE,
|
Error::KeydbParse => E_KEYDB_PARSE,
|
||||||
Error::KeydbLoad { .. } => E_KEYDB_LOAD,
|
Error::KeydbLoad { .. } => E_KEYDB_LOAD,
|
||||||
Error::MuxLookahead => E_MUX_LOOKAHEAD,
|
Error::StreamReadOnly => E_STREAM_READ_ONLY,
|
||||||
Error::MuxWrite => E_MUX_WRITE,
|
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 {
|
match self {
|
||||||
Error::DeviceNotFound { path } => write!(f, "E{}: {}", self.code(), path),
|
Error::DeviceNotFound { path } => write!(f, "E{}: {}", self.code(), path),
|
||||||
Error::DevicePermission { 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 {
|
Error::UnsupportedDrive {
|
||||||
vendor_id,
|
vendor_id,
|
||||||
product_id,
|
product_id,
|
||||||
product_revision,
|
product_revision,
|
||||||
} => write!(
|
} => write!(
|
||||||
f,
|
f, "E{}: {} {} {}",
|
||||||
"E{}: {} {} {}",
|
self.code(), vendor_id.trim(), product_id.trim(), product_revision.trim()
|
||||||
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()
|
|
||||||
),
|
),
|
||||||
Error::SignatureMismatch { expected, got } => write!(
|
Error::SignatureMismatch { expected, got } => write!(
|
||||||
f,
|
f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
|
||||||
"E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
|
|
||||||
self.code(),
|
self.code(),
|
||||||
expected[0],
|
expected[0], expected[1], expected[2], expected[3],
|
||||||
expected[1],
|
got[0], got[1], got[2], got[3]
|
||||||
expected[2],
|
|
||||||
expected[3],
|
|
||||||
got[0],
|
|
||||||
got[1],
|
|
||||||
got[2],
|
|
||||||
got[3]
|
|
||||||
),
|
),
|
||||||
Error::ScsiError {
|
Error::ScsiError { opcode, status, sense_key } => {
|
||||||
opcode,
|
write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key)
|
||||||
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::IoError { source } => write!(f, "E{}: {}", self.code(), source),
|
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
|
||||||
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
|
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
|
||||||
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
|
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
|
||||||
Error::DiscTitleRange { index, count } => {
|
Error::DiscTitleRange { index, count } => write!(f, "E{}: {}/{}", self.code(), index, count),
|
||||||
write!(f, "E{}: {}/{}", self.code(), index, count)
|
|
||||||
}
|
|
||||||
Error::KeydbConnect { host } => write!(f, "E{}: {}", self.code(), host),
|
Error::KeydbConnect { host } => write!(f, "E{}: {}", self.code(), host),
|
||||||
Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
|
Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
|
||||||
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
|
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
|
||||||
Error::KeydbLoad { 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),
|
Error::StreamUrlInvalid { url } => write!(f, "E{}: {}", self.code(), url),
|
||||||
// Simple codes — no extra data
|
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()),
|
_ => 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>`.
|
/// Convenience alias for `Result<T, Error>`.
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|||||||
+11
-7
@@ -9,17 +9,23 @@
|
|||||||
//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
|
//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
|
||||||
//!
|
//!
|
||||||
//! let mut drive = find_drive().expect("no optical drive found");
|
//! 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();
|
//! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
|
||||||
//!
|
//!
|
||||||
//! for title in &disc.titles {
|
//! for title in &disc.titles {
|
||||||
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
|
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
|
||||||
//! }
|
//! }
|
||||||
//!
|
//!
|
||||||
//! // Read content (decrypted automatically if AACS keys available)
|
//! // Stream via PES pipeline
|
||||||
//! let mut reader = disc.open_title(&mut drive, 0).unwrap();
|
//! let opts = libfreemkv::InputOptions::default();
|
||||||
//! while let Some(unit) = reader.read_unit().unwrap() {
|
//! let mut input = libfreemkv::input("disc://", &opts).unwrap();
|
||||||
//! // 6144 bytes of decrypted content per unit
|
//! 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
|
//! # Architecture
|
||||||
@@ -103,14 +109,12 @@ pub use disc::{
|
|||||||
ScanOptions, Stream, SubtitleStream, VideoStream,
|
ScanOptions, Stream, SubtitleStream, VideoStream,
|
||||||
};
|
};
|
||||||
pub use mux::DiscStream;
|
pub use mux::DiscStream;
|
||||||
pub use mux::IOStream;
|
|
||||||
pub use mux::IsoStream;
|
|
||||||
pub use mux::M2tsStream;
|
pub use mux::M2tsStream;
|
||||||
pub use mux::MkvStream;
|
pub use mux::MkvStream;
|
||||||
pub use mux::NetworkStream;
|
pub use mux::NetworkStream;
|
||||||
pub use mux::NullStream;
|
pub use mux::NullStream;
|
||||||
pub use mux::StdioStream;
|
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 scsi::ScsiTransport;
|
||||||
pub use sector::SectorReader;
|
pub use sector::SectorReader;
|
||||||
pub use speed::DriveSpeed;
|
pub use speed::DriveSpeed;
|
||||||
|
|||||||
+90
-287
@@ -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:
|
//! One stream type for all disc sources. The source is a SectorReader —
|
||||||
//! drive open → wait_ready → init → probe_disc → scan
|
//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care.
|
||||||
//!
|
//!
|
||||||
//! Then reads title extents or full-disc sequentially.
|
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
|
||||||
//! No decryption — that's a caller concern.
|
|
||||||
|
|
||||||
use super::IOStream;
|
|
||||||
use crate::disc::{
|
use crate::disc::{
|
||||||
detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions,
|
detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions,
|
||||||
};
|
};
|
||||||
use crate::drive::Drive;
|
use crate::sector::SectorReader;
|
||||||
use crate::error::{Error, Result};
|
use std::io;
|
||||||
use crate::event::{Event, EventKind};
|
|
||||||
use std::io::{self, Read, Write};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// 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.
|
/// Sources: physical drive, ISO file, or any SectorReader.
|
||||||
/// Error recovery (batch reduction, retry, zero-fill) is handled internally.
|
/// Decrypt, demux, and codec parsing happen internally.
|
||||||
pub struct DiscStream {
|
pub struct DiscStream {
|
||||||
drive: Drive,
|
reader: Box<dyn SectorReader>,
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
|
disc: Option<Disc>,
|
||||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||||
|
|
||||||
// What to read
|
// Extents to read
|
||||||
mode: ReadMode,
|
extents: Vec<Extent>,
|
||||||
|
|
||||||
// Position
|
// Position
|
||||||
current_lba: u32,
|
|
||||||
current_extent: usize,
|
current_extent: usize,
|
||||||
current_offset: u32,
|
current_offset: u32,
|
||||||
|
|
||||||
// Buffer
|
// Buffer
|
||||||
read_buf: Vec<u8>,
|
read_buf: Vec<u8>,
|
||||||
buf_valid: usize,
|
buf_valid: usize,
|
||||||
buf_cursor: usize,
|
|
||||||
|
|
||||||
// Batch size for reads
|
// Batch size for reads
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
@@ -51,77 +45,24 @@ pub struct DiscStream {
|
|||||||
pid_to_track: Vec<(u16, usize)>,
|
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 {
|
impl DiscStream {
|
||||||
/// Open a disc drive, init, scan, and prepare to read a title.
|
/// Open from a physical drive. Caller must have already called
|
||||||
///
|
/// drive.wait_ready(), drive.init(), drive.probe_disc().
|
||||||
/// Steps (each does one thing):
|
/// Drive is moved into the stream — caller manages lock/unlock before/after.
|
||||||
/// 1. Drive::open (or find_drive)
|
pub fn open_drive(
|
||||||
/// 2. wait_ready
|
drive: crate::drive::Drive,
|
||||||
/// 3. init (non-fatal)
|
|
||||||
/// 4. probe_disc (non-fatal)
|
|
||||||
/// 5. Disc::scan
|
|
||||||
///
|
|
||||||
/// Pass an event callback for status reporting, or None.
|
|
||||||
pub fn open(
|
|
||||||
device: Option<&Path>,
|
|
||||||
keydb_path: Option<&str>,
|
keydb_path: Option<&str>,
|
||||||
title_index: usize,
|
title_index: usize,
|
||||||
on_event: Option<&dyn Fn(Event)>,
|
) -> crate::error::Result<(Self, Disc)> {
|
||||||
) -> 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
|
|
||||||
let scan_opts = match keydb_path {
|
let scan_opts = match keydb_path {
|
||||||
Some(kp) => ScanOptions::with_keydb(kp),
|
Some(kp) => ScanOptions::with_keydb(kp),
|
||||||
None => ScanOptions::default(),
|
None => ScanOptions::default(),
|
||||||
};
|
};
|
||||||
|
let mut drive = drive;
|
||||||
let disc = Disc::scan(&mut drive, &scan_opts)?;
|
let disc = Disc::scan(&mut drive, &scan_opts)?;
|
||||||
emit(EventKind::ScanComplete {
|
|
||||||
titles: disc.titles.len(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if title_index >= disc.titles.len() {
|
if title_index >= disc.titles.len() {
|
||||||
return Err(Error::DiscTitleRange {
|
return Err(crate::error::Error::DiscTitleRange {
|
||||||
index: title_index,
|
index: title_index,
|
||||||
count: disc.titles.len(),
|
count: disc.titles.len(),
|
||||||
});
|
});
|
||||||
@@ -129,45 +70,59 @@ impl DiscStream {
|
|||||||
|
|
||||||
let title = disc.titles[title_index].clone();
|
let title = disc.titles[title_index].clone();
|
||||||
let keys = disc.decrypt_keys();
|
let keys = disc.decrypt_keys();
|
||||||
let mut stream = Self::title(drive, title);
|
let max_batch = detect_max_batch_sectors(drive.device_path());
|
||||||
stream.decrypt_keys = keys;
|
let content_format = disc.content_format;
|
||||||
|
|
||||||
// DVD: use program stream demuxer instead of transport stream
|
let mut stream = Self::from_reader(Box::new(drive), title, keys, max_batch);
|
||||||
if disc.content_format == crate::disc::ContentFormat::MpegPs {
|
|
||||||
|
if content_format == crate::disc::ContentFormat::MpegPs {
|
||||||
stream.ts_demuxer = None;
|
stream.ts_demuxer = None;
|
||||||
stream.ps_demuxer = Some(super::ps::PsDemuxer::new());
|
stream.ps_demuxer = Some(super::ps::PsDemuxer::new());
|
||||||
}
|
}
|
||||||
|
Ok((stream, disc))
|
||||||
Ok(DiscOpenResult { stream, disc })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a stream that reads a title's extents.
|
/// Open from an ISO file.
|
||||||
/// Use this when you already have an initialized Drive.
|
pub fn open_iso(
|
||||||
pub fn title(drive: Drive, title: DiscTitle) -> Self {
|
path: &str,
|
||||||
let max_batch = detect_max_batch_sectors(drive.device_path());
|
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();
|
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 pids = Vec::new();
|
||||||
let mut parsers = Vec::new();
|
let mut parsers = Vec::new();
|
||||||
let mut pid_to_track = Vec::new();
|
let mut pid_to_track = Vec::new();
|
||||||
@@ -183,21 +138,20 @@ impl DiscStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
drive,
|
reader,
|
||||||
title,
|
title,
|
||||||
decrypt_keys: crate::decrypt::DecryptKeys::None,
|
disc: None,
|
||||||
mode,
|
decrypt_keys,
|
||||||
current_lba: 0,
|
extents,
|
||||||
current_extent: 0,
|
current_extent: 0,
|
||||||
current_offset: 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_valid: 0,
|
||||||
buf_cursor: 0,
|
batch_sectors,
|
||||||
batch_sectors: max_batch,
|
|
||||||
errors: 0,
|
errors: 0,
|
||||||
eof: false,
|
eof: false,
|
||||||
ts_demuxer: if pids.is_empty() { None } else { Some(super::ts::TsDemuxer::new(&pids)) },
|
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,
|
parsers,
|
||||||
pending_frames: std::collections::VecDeque::new(),
|
pending_frames: std::collections::VecDeque::new(),
|
||||||
pid_to_track,
|
pid_to_track,
|
||||||
@@ -209,43 +163,17 @@ impl DiscStream {
|
|||||||
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
|
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lock the tray.
|
/// Get the scanned Disc (for listing all titles).
|
||||||
pub fn lock_tray(&mut self) {
|
pub fn disc(&self) -> Option<&Disc> {
|
||||||
self.drive.lock_tray();
|
self.disc.as_ref()
|
||||||
}
|
|
||||||
|
|
||||||
/// Unlock the tray.
|
|
||||||
pub fn unlock_tray(&mut self) {
|
|
||||||
self.drive.unlock_tray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recover the drive (for batch: switch to another title).
|
|
||||||
pub fn into_drive(self) -> Drive {
|
|
||||||
self.drive
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fill ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn fill(&mut self) -> bool {
|
|
||||||
match &self.mode {
|
|
||||||
ReadMode::Extents(_) => self.fill_extents(),
|
|
||||||
ReadMode::Sequential { .. } => self.fill_sequential(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fill_extents(&mut self) -> bool {
|
fn fill_extents(&mut self) -> bool {
|
||||||
let (ext_start, ext_sectors) = match &self.mode {
|
if self.current_extent >= self.extents.len() {
|
||||||
ReadMode::Extents(exts) => {
|
|
||||||
if self.current_extent >= exts.len() {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
(
|
let ext_start = self.extents[self.current_extent].start_lba;
|
||||||
exts[self.current_extent].start_lba,
|
let ext_sectors = self.extents[self.current_extent].sector_count;
|
||||||
exts[self.current_extent].sector_count,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let remaining = ext_sectors.saturating_sub(self.current_offset);
|
let remaining = ext_sectors.saturating_sub(self.current_offset);
|
||||||
let sectors = remaining.min(self.batch_sectors as u32) as u16;
|
let sectors = remaining.min(self.batch_sectors as u32) as u16;
|
||||||
@@ -253,22 +181,16 @@ impl DiscStream {
|
|||||||
if sectors == 0 {
|
if sectors == 0 {
|
||||||
self.current_extent += 1;
|
self.current_extent += 1;
|
||||||
self.current_offset = 0;
|
self.current_offset = 0;
|
||||||
return self.fill_extents(); // next extent
|
return self.fill_extents();
|
||||||
}
|
}
|
||||||
|
|
||||||
let lba = ext_start + self.current_offset;
|
let lba = ext_start + self.current_offset;
|
||||||
let bytes = sectors as usize * 2048;
|
let bytes = sectors as usize * 2048;
|
||||||
self.read_buf.resize(bytes, 0);
|
self.read_buf.resize(bytes, 0);
|
||||||
|
|
||||||
// Drive handles all error recovery internally.
|
match self.reader.read_sectors(lba, sectors, &mut self.read_buf[..bytes]) {
|
||||||
match self.drive.read(
|
|
||||||
lba,
|
|
||||||
sectors,
|
|
||||||
&mut self.read_buf[..bytes],
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.buf_valid = bytes;
|
self.buf_valid = bytes;
|
||||||
self.buf_cursor = 0;
|
|
||||||
self.current_offset += sectors as u32;
|
self.current_offset += sectors as u32;
|
||||||
if self.current_offset >= ext_sectors {
|
if self.current_offset >= ext_sectors {
|
||||||
self.current_extent += 1;
|
self.current_extent += 1;
|
||||||
@@ -276,73 +198,13 @@ impl DiscStream {
|
|||||||
}
|
}
|
||||||
true
|
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 {
|
impl crate::pes::Stream for DiscStream {
|
||||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||||
// Return buffered frame if available
|
|
||||||
if let Some(frame) = self.pending_frames.pop_front() {
|
if let Some(frame) = self.pending_frames.pop_front() {
|
||||||
return Ok(Some(frame));
|
return Ok(Some(frame));
|
||||||
}
|
}
|
||||||
@@ -351,32 +213,22 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read sectors until we produce at least one frame
|
|
||||||
loop {
|
loop {
|
||||||
// Fill the read buffer with next batch of sectors
|
if !self.fill_extents() {
|
||||||
let got_data = match &self.mode {
|
|
||||||
ReadMode::Extents(_) => self.fill_extents(),
|
|
||||||
ReadMode::Sequential { .. } => self.fill_sequential(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !got_data {
|
|
||||||
self.eof = true;
|
self.eof = true;
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
let bytes = self.buf_valid;
|
let bytes = self.buf_valid;
|
||||||
if let Err(e) = crate::decrypt::decrypt_sectors(
|
if let Err(e) = crate::decrypt::decrypt_sectors(
|
||||||
&mut self.read_buf[..bytes],
|
&mut self.read_buf[..bytes],
|
||||||
&self.decrypt_keys,
|
&self.decrypt_keys,
|
||||||
0,
|
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 {
|
if let Some(ref mut demuxer) = self.ts_demuxer {
|
||||||
// BD: transport stream demux
|
|
||||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||||
for pes in &packets {
|
for pes in &packets {
|
||||||
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
|
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 {
|
} else if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||||
// DVD: program stream demux
|
|
||||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||||
for ps in &packets {
|
for ps in &packets {
|
||||||
// Map PS stream_id to track index
|
|
||||||
let track = match ps.stream_id {
|
let track = match ps.stream_id {
|
||||||
0xE0..=0xEF => 0, // video
|
0xE0..=0xEF => 0,
|
||||||
0xC0..=0xDF => 1, // audio
|
0xC0..=0xDF => 1,
|
||||||
0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1),
|
0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1),
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
@@ -405,36 +255,28 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
self.pending_frames.push_back(crate::pes::PesFrame {
|
self.pending_frames.push_back(crate::pes::PesFrame {
|
||||||
track,
|
track,
|
||||||
pts: pts_ns,
|
pts: pts_ns,
|
||||||
keyframe: true, // PS doesn't have keyframe flag easily
|
keyframe: true,
|
||||||
data: ps.data.clone(),
|
data: ps.data.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset buffer for next read
|
|
||||||
self.buf_valid = 0;
|
self.buf_valid = 0;
|
||||||
self.buf_cursor = 0;
|
|
||||||
|
|
||||||
if let Some(frame) = self.pending_frames.pop_front() {
|
if let Some(frame) = self.pending_frames.pop_front() {
|
||||||
return Ok(Some(frame));
|
return Ok(Some(frame));
|
||||||
}
|
}
|
||||||
// No frames produced — read more data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
|
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<()> {
|
fn finish(&mut self) -> io::Result<()> { Ok(()) }
|
||||||
self.drive.unlock_tray();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn info(&self) -> &DiscTitle {
|
fn info(&self) -> &DiscTitle { &self.title }
|
||||||
&self.title
|
|
||||||
}
|
|
||||||
|
|
||||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||||
let pid = self.pid_to_track.iter()
|
let pid = self.pid_to_track.iter()
|
||||||
@@ -456,42 +298,3 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
true
|
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
@@ -171,10 +171,7 @@ pub fn read_id(r: &mut impl Read) -> io::Result<(u32, usize)> {
|
|||||||
4,
|
4,
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
Err(io::Error::new(
|
Err(crate::error::Error::MkvInvalid.into())
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"invalid EBML ID",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,10 +322,7 @@ pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
|||||||
r.read_exact(&mut b)?;
|
r.read_exact(&mut b)?;
|
||||||
return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
|
return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
|
||||||
}
|
}
|
||||||
Err(io::Error::new(
|
Err(crate::error::Error::MkvInvalid.into())
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"unsupported VINT width",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
+9
-407
@@ -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
|
//! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048.
|
||||||
//! DiscStream (titles, streams, labels, AACS). An ISO is a flat image of
|
//! Used by DiscStream::open_iso() and Disc::scan_image().
|
||||||
//! 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.
|
|
||||||
|
|
||||||
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::error::{Error, Result};
|
||||||
use crate::sector::SectorReader;
|
use crate::sector::SectorReader;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
const SECTOR_SIZE: u64 = 2048;
|
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.
|
/// File-backed sector reader for ISO images.
|
||||||
pub struct IsoSectorReader {
|
pub struct IsoSectorReader {
|
||||||
file: File,
|
file: File,
|
||||||
@@ -29,16 +18,15 @@ pub struct IsoSectorReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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))
|
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 size = file.metadata()?.len();
|
||||||
let sectors = size / SECTOR_SIZE;
|
let sectors = size / SECTOR_SIZE;
|
||||||
if sectors > u32::MAX as u64 {
|
if sectors > u32::MAX as u64 {
|
||||||
return Err(io::Error::new(
|
return Err(crate::error::Error::IsoTooLarge {
|
||||||
io::ErrorKind::InvalidData,
|
path: path.to_string(),
|
||||||
format!("iso://{path}: image too large ({} TB, max ~8 TB)", size / (1024 * 1024 * 1024 * 1024)),
|
}.into());
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let capacity = sectors as u32;
|
let capacity = sectors as u32;
|
||||||
Ok(Self { file, capacity })
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -453,41 +92,4 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_file(&dir).ok();
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(§or_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(§or.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
@@ -1,9 +1,9 @@
|
|||||||
//! M2tsStream — BD transport stream with embedded metadata header.
|
//! M2tsStream — BD transport stream with embedded metadata header.
|
||||||
//!
|
//!
|
||||||
//! Write: prepends FMKV metadata header, then passes through BD-TS bytes.
|
//! Write: prepends FMKV metadata header, then muxes PES frames into BD-TS.
|
||||||
//! Read: extracts metadata header (or scans PMT), then yields BD-TS bytes.
|
//! 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 crate::disc::{DiscTitle, Stream as DiscStream};
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
|
|
||||||
@@ -14,8 +14,7 @@ const SCAN_SIZE: usize = 1024 * 1024;
|
|||||||
|
|
||||||
enum Mode {
|
enum Mode {
|
||||||
Write {
|
Write {
|
||||||
writer: Box<dyn Write>,
|
muxer: super::tsmux::TsMuxer<Box<dyn Write>>,
|
||||||
header_written: bool,
|
|
||||||
},
|
},
|
||||||
Read {
|
Read {
|
||||||
reader: Box<dyn 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 {
|
pub struct M2tsStream {
|
||||||
disc_title: DiscTitle,
|
disc_title: DiscTitle,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
finished: bool,
|
|
||||||
/// Content size in bytes (file size minus header), set for read mode.
|
|
||||||
content_size: Option<u64>,
|
|
||||||
// PES support
|
// PES support
|
||||||
demuxer: Option<ts::TsDemuxer>,
|
demuxer: Option<ts::TsDemuxer>,
|
||||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||||
@@ -55,23 +51,36 @@ pub struct M2tsStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl M2tsStream {
|
impl M2tsStream {
|
||||||
/// Create for writing. Metadata header is written on first write().
|
/// Create for writing PES frames → BD-TS output.
|
||||||
pub fn new(writer: impl Write + 'static) -> Self {
|
/// Writes FMKV metadata header, then muxes PES frames into BD transport stream.
|
||||||
Self {
|
pub fn create(mut writer: impl Write + 'static, title: &DiscTitle) -> io::Result<Self> {
|
||||||
disc_title: DiscTitle::empty(),
|
// Write FMKV metadata header
|
||||||
mode: Mode::Write {
|
if !title.streams.is_empty() {
|
||||||
writer: Box::new(writer),
|
let m = meta::M2tsMeta::from_title(title);
|
||||||
header_written: false,
|
meta::write_header(&mut writer, &m)?;
|
||||||
},
|
}
|
||||||
finished: false,
|
let pids: Vec<u16> = title.streams.iter().map(|s| match s {
|
||||||
content_size: None,
|
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,
|
demuxer: None,
|
||||||
parsers: Vec::new(),
|
parsers: Vec::new(),
|
||||||
pending_frames: std::collections::VecDeque::new(),
|
pending_frames: std::collections::VecDeque::new(),
|
||||||
pid_to_track: Vec::new(),
|
pid_to_track: Vec::new(),
|
||||||
pes_eof: false,
|
pes_eof: false,
|
||||||
stored_codec_privates: Vec::new(),
|
stored_codec_privates: Vec::new(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_pes(streams: &[DiscStream]) -> PesSetup {
|
fn setup_pes(streams: &[DiscStream]) -> PesSetup {
|
||||||
@@ -91,12 +100,6 @@ impl M2tsStream {
|
|||||||
(pids, parsers, pid_to_track)
|
(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.
|
/// 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.
|
/// Tries FMKV metadata header first. Falls back to PMT scan of first 1 MB.
|
||||||
@@ -118,8 +121,6 @@ impl M2tsStream {
|
|||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
disc_title: title.clone(),
|
disc_title: title.clone(),
|
||||||
mode: Mode::Read { reader: chain },
|
mode: Mode::Read { reader: chain },
|
||||||
finished: false,
|
|
||||||
content_size: None,
|
|
||||||
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
|
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
|
||||||
parsers,
|
parsers,
|
||||||
pending_frames: std::collections::VecDeque::new(),
|
pending_frames: std::collections::VecDeque::new(),
|
||||||
@@ -131,7 +132,7 @@ impl M2tsStream {
|
|||||||
|
|
||||||
// No FMKV header — scan head for PMT
|
// No FMKV header — scan head for PMT
|
||||||
let streams = ts::scan_streams(&head)
|
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);
|
let (pids, parsers, pid_to_track) = Self::setup_pes(&streams);
|
||||||
|
|
||||||
@@ -145,8 +146,6 @@ impl M2tsStream {
|
|||||||
..DiscTitle::empty()
|
..DiscTitle::empty()
|
||||||
},
|
},
|
||||||
mode: Mode::Read { reader: chain },
|
mode: Mode::Read { reader: chain },
|
||||||
finished: false,
|
|
||||||
content_size: None,
|
|
||||||
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
|
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
|
||||||
parsers,
|
parsers,
|
||||||
pending_frames: std::collections::VecDeque::new(),
|
pending_frames: std::collections::VecDeque::new(),
|
||||||
@@ -167,7 +166,7 @@ impl crate::pes::Stream for M2tsStream {
|
|||||||
loop {
|
loop {
|
||||||
let reader = match &mut self.mode {
|
let reader = match &mut self.mode {
|
||||||
Mode::Read { reader } => reader,
|
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 mut buf = vec![0u8; 192 * 1024];
|
||||||
let n = reader.read(&mut buf)?;
|
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<()> {
|
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||||
Err(io::Error::new(io::ErrorKind::Unsupported, "use M2tsOutputStream for writing"))
|
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 }
|
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
@@ -247,7 +247,7 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
|||||||
r.read_exact(&mut len_buf)?;
|
r.read_exact(&mut len_buf)?;
|
||||||
let json_len = u32::from_be_bytes(len_buf) as usize;
|
let json_len = u32::from_be_bytes(len_buf) as usize;
|
||||||
if json_len > MAX_JSON_SIZE {
|
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];
|
let mut json_buf = vec![0u8; json_len];
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
+54
-494
@@ -1,14 +1,10 @@
|
|||||||
//! MkvStream — Matroska container stream.
|
//! MkvStream — Matroska container stream.
|
||||||
//!
|
//!
|
||||||
//! Write: BD-TS bytes in → demux → codec parse → MKV container out.
|
//! Read: MKV container → demux EBML → PES frames out.
|
||||||
//! Read: MKV container in → extract frames → wrap as BD-TS → bytes 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::mkv::{MkvMuxer, MkvTrack};
|
||||||
use super::ts::TsDemuxer;
|
use super::{ebml, WriteSeek};
|
||||||
use super::{ebml, IOStream, WriteSeek};
|
|
||||||
use std::io::Seek;
|
|
||||||
|
|
||||||
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
|
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 crate::disc::*;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read};
|
||||||
|
|
||||||
/// Lookahead buffer for codec header detection (5 MB default).
|
|
||||||
const DEFAULT_MAX_BUFFER: usize = DEFAULT_LOOKAHEAD_SIZE;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
enum WritePhase {
|
|
||||||
Scanning,
|
|
||||||
Streaming,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WriteState {
|
|
||||||
demuxer: TsDemuxer,
|
|
||||||
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
|
|
||||||
writer: Option<Box<dyn WriteSeek>>,
|
|
||||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
|
||||||
pid_to_track: Vec<(u16, usize)>,
|
|
||||||
tracks: Vec<MkvTrack>,
|
|
||||||
lookahead: LookaheadBuffer,
|
|
||||||
phase: WritePhase,
|
|
||||||
video_pending: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReadState {
|
struct ReadState {
|
||||||
reader: Box<dyn Read>,
|
reader: Box<dyn Read>,
|
||||||
buf: Vec<u8>,
|
|
||||||
pos: usize,
|
|
||||||
len: usize,
|
|
||||||
cluster_ts_ms: i64,
|
cluster_ts_ms: i64,
|
||||||
/// Codec private data per track (track_number, hvcC/avcC bytes).
|
/// 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>)>,
|
codec_privates: Vec<(u16, Vec<u8>)>,
|
||||||
/// Tracks that have already had their codec_private emitted.
|
|
||||||
initialized_tracks: Vec<u16>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Mode {
|
enum Mode {
|
||||||
Write(Box<WriteState>),
|
Write {
|
||||||
|
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
|
||||||
|
},
|
||||||
Read(ReadState),
|
Read(ReadState),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,95 +35,52 @@ enum Mode {
|
|||||||
pub struct MkvStream {
|
pub struct MkvStream {
|
||||||
disc_title: DiscTitle,
|
disc_title: DiscTitle,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
max_buffer: usize,
|
|
||||||
finished: bool,
|
|
||||||
/// File size in bytes, set for read mode.
|
|
||||||
file_size: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MkvStream {
|
impl MkvStream {
|
||||||
/// Create for writing. BD-TS bytes written to this stream produce MKV output.
|
/// Create for writing PES frames → MKV container.
|
||||||
pub fn new(writer: impl Write + Seek + 'static) -> Self {
|
/// Codec privates come from title.codec_privates (populated by input stream).
|
||||||
Self {
|
pub fn create(
|
||||||
disc_title: DiscTitle::empty(),
|
writer: Box<dyn WriteSeek>,
|
||||||
mode: Mode::Write(Box::new(WriteState {
|
title: &DiscTitle,
|
||||||
demuxer: TsDemuxer::new(&[]),
|
) -> io::Result<Self> {
|
||||||
muxer: None,
|
let mut tracks = Vec::new();
|
||||||
writer: Some(Box::new(writer)),
|
for (idx, s) in title.streams.iter().enumerate() {
|
||||||
parsers: Vec::new(),
|
let mut track = match s {
|
||||||
pid_to_track: Vec::new(),
|
crate::disc::Stream::Video(v) => MkvTrack::video(v),
|
||||||
tracks: Vec::new(),
|
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
|
||||||
lookahead: LookaheadBuffer::new(DEFAULT_MAX_BUFFER),
|
crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s),
|
||||||
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();
|
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
|
||||||
pids.push(pid);
|
track.codec_private = Some(cp.clone());
|
||||||
ws.pid_to_track.push((pid, idx));
|
|
||||||
ws.parsers.push((pid, parser));
|
|
||||||
ws.tracks.push(track);
|
|
||||||
}
|
}
|
||||||
ws.demuxer = TsDemuxer::new(&pids);
|
tracks.push(track);
|
||||||
}
|
|
||||||
self.disc_title = dt.clone();
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set lookahead buffer size. Returns self.
|
let muxer = MkvMuxer::new_with_chapters(
|
||||||
pub fn max_buffer(mut self, size: usize) -> Self {
|
writer,
|
||||||
self.max_buffer = size;
|
&tracks,
|
||||||
if let Mode::Write(ref mut ws) = self.mode {
|
Some(&title.playlist),
|
||||||
ws.lookahead = LookaheadBuffer::new(size);
|
title.duration_secs,
|
||||||
}
|
&title.chapters,
|
||||||
self
|
)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
disc_title: title.clone(),
|
||||||
|
mode: Mode::Write { muxer: Some(muxer) },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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> {
|
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
|
||||||
let (disc_title, codec_privates) = parse_mkv_header(&mut reader)?;
|
let (disc_title, codec_privates) = parse_mkv_header(&mut reader)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
disc_title,
|
disc_title,
|
||||||
mode: Mode::Read(ReadState {
|
mode: Mode::Read(ReadState {
|
||||||
reader: Box::new(reader),
|
reader: Box::new(reader),
|
||||||
buf: Vec::new(),
|
|
||||||
pos: 0,
|
|
||||||
len: 0,
|
|
||||||
cluster_ts_ms: 0,
|
cluster_ts_ms: 0,
|
||||||
codec_privates,
|
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>> {
|
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||||
let rs = match self.mode {
|
let rs = match self.mode {
|
||||||
Mode::Read(ref mut rs) => rs,
|
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 {
|
loop {
|
||||||
@@ -209,11 +137,22 @@ impl crate::pes::Stream for MkvStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
|
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||||
Err(io::Error::new(io::ErrorKind::Unsupported, "use MkvOutputStream for writing"))
|
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 }
|
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) ────────────────────────────
|
// ── MKV header parsing (read side) ────────────────────────────
|
||||||
|
|
||||||
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
|
/// 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)?;
|
let (id, size, _) = ebml::read_element_header(r)?;
|
||||||
if id != ebml::EBML {
|
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 {
|
if size > i64::MAX as u64 {
|
||||||
return Err(io::Error::new(
|
return Err(crate::error::Error::MkvInvalid.into());
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"EBML header too large",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
skip_bytes(r, size)?;
|
skip_bytes(r, size)?;
|
||||||
|
|
||||||
let (id, _, _) = ebml::read_element_header(r)?;
|
let (id, _, _) = ebml::read_element_header(r)?;
|
||||||
if id != ebml::SEGMENT {
|
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);
|
let (mut got_info, mut got_tracks) = (false, false);
|
||||||
@@ -647,7 +333,7 @@ fn parse_track(
|
|||||||
SampleRate::S48
|
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 ts_pid = if tnum == 1 { 0x1011 } else { 0x1100 + (tnum - 2) };
|
||||||
|
|
||||||
let stream = match ttype {
|
let stream = match ttype {
|
||||||
@@ -685,8 +371,6 @@ fn parse_track(
|
|||||||
Ok((stream, tnum, codec_priv))
|
Ok((stream, tnum, codec_priv))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BD-TS frame wrapping (read side) ──────────────────────────
|
|
||||||
|
|
||||||
fn block_vint(d: &[u8]) -> (u64, usize) {
|
fn block_vint(d: &[u8]) -> (u64, usize) {
|
||||||
if d.is_empty() {
|
if d.is_empty() {
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
@@ -705,127 +389,3 @@ fn block_vint(d: &[u8]) -> (u64, usize) {
|
|||||||
}
|
}
|
||||||
(0, 1) // Unsupported 5+ byte VINT — treat as track 0
|
(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
@@ -1,37 +1,29 @@
|
|||||||
//! Stream-based I/O pipeline.
|
//! 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
|
//! ```text
|
||||||
//! freemkv disc:// mkv://Dune.mkv
|
//! let mut input = input("iso://Disc.iso", &opts)?;
|
||||||
//! freemkv m2ts://Dune.m2ts mkv://Dune.mkv
|
//! let title = input.info().clone();
|
||||||
//! freemkv disc:// network://10.1.7.11:9000
|
//! let mut output = output("mkv://Dune.mkv", &title)?;
|
||||||
//! freemkv disc:// stdio://
|
//! while let Ok(Some(frame)) = input.read() {
|
||||||
//! freemkv stdio:// mkv://Dune.mkv
|
//! output.write(&frame)?;
|
||||||
//! ```
|
//! }
|
||||||
//!
|
|
||||||
//! Streams implement `IOStream` for uniform handling:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! let mut input = open_input("disc://", &opts)?;
|
|
||||||
//! let mut output = open_output("mkv://Dune.mkv", input.info())?;
|
|
||||||
//! io::copy(&mut *input, &mut *output)?;
|
|
||||||
//! output.finish()?;
|
//! output.finish()?;
|
||||||
//! ```
|
//! ```
|
||||||
|
//!
|
||||||
|
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||||
|
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
pub mod disc;
|
pub mod disc;
|
||||||
pub mod ebml;
|
pub mod ebml;
|
||||||
pub mod iso;
|
pub mod iso;
|
||||||
mod isowriter;
|
|
||||||
pub mod lookahead;
|
|
||||||
mod m2ts;
|
|
||||||
pub mod meta;
|
|
||||||
pub mod mkv;
|
pub mod mkv;
|
||||||
pub mod mkvout;
|
|
||||||
pub mod pesout;
|
|
||||||
pub mod tsmux;
|
pub mod tsmux;
|
||||||
pub mod tsreader;
|
pub mod tsreader;
|
||||||
|
mod m2ts;
|
||||||
|
pub mod meta;
|
||||||
mod mkvstream;
|
mod mkvstream;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod null;
|
pub mod null;
|
||||||
@@ -40,40 +32,16 @@ pub mod resolve;
|
|||||||
pub mod stdio;
|
pub mod stdio;
|
||||||
pub mod ts;
|
pub mod ts;
|
||||||
|
|
||||||
pub use disc::{DiscOpenResult, DiscStream};
|
pub use disc::DiscStream;
|
||||||
pub use iso::{IsoSectorReader, IsoStream};
|
pub use iso::IsoSectorReader;
|
||||||
pub use m2ts::M2tsStream;
|
pub use m2ts::M2tsStream;
|
||||||
pub use mkvstream::MkvStream;
|
pub use mkvstream::MkvStream;
|
||||||
pub use network::NetworkStream;
|
pub use network::NetworkStream;
|
||||||
pub use null::NullStream;
|
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;
|
pub use stdio::StdioStream;
|
||||||
|
|
||||||
use crate::disc::DiscTitle;
|
use std::io::{Seek, Write};
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteSeek — used internally by MKV muxer (container format requires seeking).
|
// WriteSeek — used internally by MKV muxer (container format requires seeking).
|
||||||
pub trait WriteSeek: Write + Seek {}
|
pub trait WriteSeek: Write + Seek {}
|
||||||
|
|||||||
+3
-8
@@ -60,12 +60,7 @@ impl NetworkStream {
|
|||||||
|
|
||||||
// Read FMKV metadata header
|
// Read FMKV metadata header
|
||||||
let disc_title = meta::read_header(&mut reader)?
|
let disc_title = meta::read_header(&mut reader)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })?
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"no FMKV metadata header from sender",
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.to_title();
|
.to_title();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -79,7 +74,7 @@ impl crate::pes::Stream for NetworkStream {
|
|||||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||||
match &mut self.mode {
|
match &mut self.mode {
|
||||||
Mode::Read { reader } => crate::pes::PesFrame::deserialize(reader),
|
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<()> {
|
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||||
@@ -94,7 +89,7 @@ impl crate::pes::Stream for NetworkStream {
|
|||||||
}
|
}
|
||||||
frame.serialize(writer)
|
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<()> {
|
fn finish(&mut self) -> io::Result<()> {
|
||||||
|
|||||||
+11
-101
@@ -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 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 {
|
pub struct NullStream {
|
||||||
disc_title: DiscTitle,
|
disc_title: DiscTitle,
|
||||||
bytes_written: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for NullStream {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NullStream {
|
impl NullStream {
|
||||||
pub fn new() -> Self {
|
pub fn new(title: &DiscTitle) -> Self {
|
||||||
Self {
|
Self {
|
||||||
disc_title: DiscTitle::empty(),
|
disc_title: title.clone(),
|
||||||
bytes_written: 0,
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
impl crate::pes::Stream for NullStream {
|
||||||
self.disc_title = dt.clone();
|
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { Ok(None) }
|
||||||
self
|
fn write(&mut self, _: &crate::pes::PesFrame) -> io::Result<()> { Ok(()) }
|
||||||
}
|
fn finish(&mut self) -> io::Result<()> { Ok(()) }
|
||||||
|
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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`
|
//! Format: `scheme://path`
|
||||||
//!
|
//!
|
||||||
//! | Scheme | Input | Output | Path |
|
//! | Scheme | Input | Output | Path |
|
||||||
//! |--------|-------|--------|------|
|
//! |--------|-------|--------|------|
|
||||||
//! | disc:// | Yes | -- | empty (auto-detect) or /dev/sgN |
|
//! | 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) |
|
//! | mkv:// | Yes | Yes | file path (required) |
|
||||||
|
//! | m2ts:// | Yes | Yes | file path (required) |
|
||||||
//! | network:// | Yes (listen) | Yes (connect) | host:port (required) |
|
//! | network:// | Yes (listen) | Yes (connect) | host:port (required) |
|
||||||
//! | stdio:// | Yes (stdin) | Yes (stdout) | empty |
|
//! | stdio:// | Yes (stdin) | Yes (stdout) | empty |
|
||||||
//! | iso:// | Yes | -- | file path (required) |
|
|
||||||
//! | null:// | -- | Yes | empty |
|
//! | null:// | -- | Yes | empty |
|
||||||
//!
|
//!
|
||||||
//! Bare paths without a scheme are rejected.
|
//! Bare paths without a scheme are rejected.
|
||||||
|
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||||
|
|
||||||
use super::disc::DiscStream;
|
use super::disc::DiscStream;
|
||||||
use super::iso::IsoStream;
|
|
||||||
use super::network::NetworkStream;
|
use super::network::NetworkStream;
|
||||||
use super::null::NullStream;
|
use super::null::NullStream;
|
||||||
use super::stdio::StdioStream;
|
use super::stdio::StdioStream;
|
||||||
use super::{IOStream, M2tsStream, MkvStream};
|
use super::{M2tsStream, MkvStream};
|
||||||
use crate::disc::DiscTitle;
|
use std::io;
|
||||||
use std::io::{self, BufReader, BufWriter};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// I/O buffer size for file streams.
|
/// I/O buffer size for file streams.
|
||||||
const IO_BUF_SIZE: usize = 4 * 1024 * 1024;
|
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.
|
/// Parsed stream URL.
|
||||||
pub enum StreamUrl {
|
pub enum StreamUrl {
|
||||||
/// Optical disc drive. Device path is optional (auto-detect if None).
|
/// 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.
|
/// 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 {
|
pub fn parse_url(url: &str) -> StreamUrl {
|
||||||
if let Some(rest) = url.strip_prefix("disc://") {
|
if let Some(rest) = url.strip_prefix("disc://") {
|
||||||
return if rest.is_empty() {
|
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.
|
/// Validate that a file path is non-empty and has a filename component.
|
||||||
fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
|
fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
|
||||||
if path.as_os_str().is_empty() {
|
if path.as_os_str().is_empty() {
|
||||||
return Err(io::Error::new(
|
return Err(crate::error::Error::StreamUrlMissingPath {
|
||||||
io::ErrorKind::InvalidInput,
|
scheme: scheme.to_string(),
|
||||||
format!("{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"),
|
}.into());
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if path.file_name().is_none() {
|
if path.file_name().is_none() {
|
||||||
return Err(io::Error::new(
|
return Err(crate::error::Error::StreamUrlInvalid {
|
||||||
io::ErrorKind::InvalidInput,
|
url: format!("{scheme}://{}", path.display()),
|
||||||
format!(
|
}.into());
|
||||||
"{scheme}://{} is not a valid file path — must include a filename",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -163,136 +141,18 @@ fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
|
|||||||
/// Validate that a network address has host:port format.
|
/// Validate that a network address has host:port format.
|
||||||
fn validate_network_addr(addr: &str) -> io::Result<()> {
|
fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||||
if addr.is_empty() {
|
if addr.is_empty() {
|
||||||
return Err(io::Error::new(
|
return Err(crate::error::Error::StreamUrlMissingPath {
|
||||||
io::ErrorKind::InvalidInput,
|
scheme: "network".to_string(),
|
||||||
"network:// requires host:port (e.g. network://0.0.0.0:9000)",
|
}.into());
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if !addr.contains(':') {
|
if !addr.contains(':') {
|
||||||
return Err(io::Error::new(
|
return Err(crate::error::Error::StreamUrlMissingPort {
|
||||||
io::ErrorKind::InvalidInput,
|
addr: addr.to_string(),
|
||||||
format!("network://{addr} missing port — use network://{addr}:PORT"),
|
}.into());
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(())
|
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.
|
/// Options for opening an input stream.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct InputOptions {
|
pub struct InputOptions {
|
||||||
@@ -302,42 +162,43 @@ pub struct InputOptions {
|
|||||||
pub raw: bool,
|
pub raw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PES-based open ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Open a PES input stream (produces PES frames).
|
/// Open a PES input stream (produces PES frames).
|
||||||
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
|
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||||
let parsed = parse_url(url);
|
let parsed = parse_url(url);
|
||||||
match parsed {
|
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 } => {
|
StreamUrl::Iso { ref path } => {
|
||||||
validate_file_path(path, "iso")?;
|
validate_file_path(path, "iso")?;
|
||||||
let scan_opts = match &opts.keydb_path {
|
let scan_opts = match &opts.keydb_path {
|
||||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||||
None => crate::disc::ScanOptions::default(),
|
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 {
|
if opts.raw {
|
||||||
stream.set_raw();
|
stream.set_raw();
|
||||||
}
|
}
|
||||||
Ok(Box::new(stream))
|
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 } => {
|
StreamUrl::M2ts { ref path } => {
|
||||||
validate_file_path(path, "m2ts")?;
|
validate_file_path(path, "m2ts")?;
|
||||||
let file = std::fs::File::open(path)
|
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 => {
|
StreamUrl::Stdio => {
|
||||||
Ok(Box::new(StdioStream::input()))
|
Ok(Box::new(StdioStream::input()))
|
||||||
}
|
}
|
||||||
|
StreamUrl::Null => {
|
||||||
|
Err(crate::error::Error::StreamWriteOnly.into())
|
||||||
|
}
|
||||||
StreamUrl::Unknown { ref raw } => {
|
StreamUrl::Unknown { ref raw } => {
|
||||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
Err(crate::error::Error::StreamUrlInvalid {
|
||||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw)))
|
url: raw.clone(),
|
||||||
|
}.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,32 +244,35 @@ pub fn output(
|
|||||||
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
|
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
|
||||||
let writer: Box<dyn super::WriteSeek> =
|
let writer: Box<dyn super::WriteSeek> =
|
||||||
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
|
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 } => {
|
StreamUrl::M2ts { ref path } => {
|
||||||
validate_file_path(path, "m2ts")?;
|
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 } => {
|
StreamUrl::Network { ref addr } => {
|
||||||
validate_network_addr(addr)?;
|
validate_network_addr(addr)?;
|
||||||
Ok(Box::new(super::pesout::NetworkOutputStream::connect(addr, title)?))
|
Ok(Box::new(NetworkStream::connect(addr)?.meta(title)))
|
||||||
}
|
}
|
||||||
StreamUrl::Stdio => {
|
StreamUrl::Stdio => {
|
||||||
Ok(Box::new(super::pesout::StdioOutputStream::new(title)))
|
Ok(Box::new(StdioStream::output(title)))
|
||||||
}
|
}
|
||||||
StreamUrl::Null => {
|
StreamUrl::Null => {
|
||||||
Ok(Box::new(super::pesout::NullOutputStream::new(title)))
|
Ok(Box::new(NullStream::new(title)))
|
||||||
}
|
}
|
||||||
StreamUrl::Disc { .. } => {
|
StreamUrl::Disc { .. } => {
|
||||||
Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only"))
|
Err(crate::error::Error::StreamReadOnly.into())
|
||||||
}
|
}
|
||||||
StreamUrl::Iso { .. } => {
|
StreamUrl::Iso { .. } => {
|
||||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
Err(crate::error::Error::StreamReadOnly.into())
|
||||||
"ISO output from PES not supported — use disc.copy() for raw ISO"))
|
|
||||||
}
|
}
|
||||||
StreamUrl::Unknown { ref raw } => {
|
StreamUrl::Unknown { ref raw } => {
|
||||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
Err(crate::error::Error::StreamUrlInvalid {
|
||||||
format!("'{}' is not a valid stream URL", raw)))
|
url: raw.clone(),
|
||||||
|
}.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-92
@@ -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 crate::disc::DiscTitle;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
/// Stdio stream — reads from stdin, writes to stdout.
|
/// Stdio stream — reads PES from stdin, writes PES to stdout.
|
||||||
///
|
|
||||||
/// No headers, no metadata, no format opinions. Just bytes.
|
|
||||||
/// The format is determined by whatever is on the other end.
|
|
||||||
pub struct StdioStream {
|
pub struct StdioStream {
|
||||||
disc_title: DiscTitle,
|
disc_title: DiscTitle,
|
||||||
reader: Option<io::Stdin>,
|
reader: Option<io::Stdin>,
|
||||||
writer: Option<io::Stdout>,
|
writer: Option<io::BufWriter<io::Stdout>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StdioStream {
|
impl StdioStream {
|
||||||
@@ -25,32 +21,26 @@ impl StdioStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a stdio stream for writing (stdout).
|
/// Create a stdio stream for writing (stdout).
|
||||||
pub fn output() -> Self {
|
pub fn output(title: &DiscTitle) -> Self {
|
||||||
Self {
|
Self {
|
||||||
disc_title: DiscTitle::empty(),
|
disc_title: title.clone(),
|
||||||
reader: None,
|
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 {
|
impl crate::pes::Stream for StdioStream {
|
||||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||||
match &mut self.reader {
|
match &mut self.reader {
|
||||||
Some(r) => crate::pes::PesFrame::deserialize(r),
|
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<()> {
|
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||||
match &mut self.writer {
|
match &mut self.writer {
|
||||||
Some(w) => frame.serialize(w),
|
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<()> {
|
fn finish(&mut self) -> io::Result<()> {
|
||||||
@@ -59,76 +49,3 @@ impl crate::pes::Stream for StdioStream {
|
|||||||
}
|
}
|
||||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
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
@@ -23,16 +23,12 @@ impl PesFrame {
|
|||||||
/// Serialize to bytes: track(1) | pts(8) | keyframe(1) | len(4) | data
|
/// 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<()> {
|
pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
|
||||||
if self.track > 255 {
|
if self.track > 255 {
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::error::Error::PesInvalidMagic.into());
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"track index exceeds 255",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if self.data.len() > u32::MAX as usize {
|
if self.data.len() > u32::MAX as usize {
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::error::Error::PesFrameTooLarge {
|
||||||
std::io::ErrorKind::InvalidData,
|
size: self.data.len(),
|
||||||
"frame data exceeds 4 GB",
|
}.into());
|
||||||
));
|
|
||||||
}
|
}
|
||||||
w.write_all(&[self.track as u8])?;
|
w.write_all(&[self.track as u8])?;
|
||||||
w.write_all(&self.pts.to_le_bytes())?;
|
w.write_all(&self.pts.to_le_bytes())?;
|
||||||
@@ -59,10 +55,7 @@ impl PesFrame {
|
|||||||
let keyframe = header[9] != 0;
|
let keyframe = header[9] != 0;
|
||||||
let len = u32::from_le_bytes([header[10], header[11], header[12], header[13]]) as usize;
|
let len = u32::from_le_bytes([header[10], header[11], header[12], header[13]]) as usize;
|
||||||
if len > MAX_FRAME_SIZE {
|
if len > MAX_FRAME_SIZE {
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::error::Error::PesFrameTooLarge { size: len }.into());
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("frame size {} exceeds maximum {}", len, MAX_FRAME_SIZE),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let mut data = vec![0u8; len];
|
let mut data = vec![0u8; len];
|
||||||
r.read_exact(&mut data)?;
|
r.read_exact(&mut data)?;
|
||||||
@@ -100,3 +93,58 @@ pub trait Stream {
|
|||||||
/// True when codec_private is available for all video tracks.
|
/// True when codec_private is available for all video tracks.
|
||||||
fn headers_ready(&self) -> bool { true }
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user