mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4ebd7d9b | ||
|
|
168005ef34 | ||
|
|
6dc308f245 | ||
|
|
cccb6b4631 | ||
|
|
8c2f3898b8 | ||
|
|
37c98e8826 | ||
|
|
891dee3db9 | ||
|
|
e34b41967d | ||
|
|
c063dbd76e | ||
|
|
d1f8db93d6 | ||
|
|
926939f24b | ||
|
|
48d70c092c | ||
|
|
c14b319212 | ||
|
|
55b22bfb9d | ||
|
|
48b212c9d2 | ||
|
|
074f21ba58 | ||
|
|
9241d767ee | ||
|
|
65996ade59 | ||
|
|
09f5bb7816 | ||
|
|
12a5985a58 | ||
|
|
d23632931b | ||
|
|
92e7692779 | ||
|
|
0c11623666 | ||
|
|
b454d100c6 | ||
|
|
097473d371 | ||
|
|
5e3157b18d | ||
|
|
d9c5f7a0d2 | ||
|
|
6456e24bb5 | ||
|
|
0b19154bd3 | ||
|
|
ecc6cd9f6b | ||
|
|
71a21b5c6f | ||
|
|
79b1b4d5b5 | ||
|
|
85b38de4a7 | ||
|
|
686bf91bb5 | ||
|
|
f07f8210c3 | ||
|
|
4153d23652 | ||
|
|
760bab0893 | ||
|
|
ce8ddb48bb | ||
|
|
377cbe0aec | ||
|
|
a4c10887cf | ||
|
|
3fcab4d8d9 | ||
|
|
f46d9706eb | ||
|
|
9ecbf0ada5 | ||
|
|
82746ed592 | ||
|
|
da0eeae6a7 | ||
|
|
262f9a7f8d |
@@ -14,6 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
|
||||
|
||||
- name: Update version in README
|
||||
run: |
|
||||
|
||||
@@ -3,3 +3,4 @@ Cargo.lock
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.cargo/
|
||||
|
||||
@@ -1,5 +1,92 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.1 (2026-04-11)
|
||||
|
||||
### SectorReader trait
|
||||
|
||||
- **`SectorReader` trait** — decouples disc scanning from SCSI. UDF, MPLS, CLPI, labels, and AACS resolution now work with any sector source.
|
||||
- **`Disc::scan_image()`** — scan ISO images or any SectorReader. Full title/stream/label/AACS pipeline, no drive required.
|
||||
- **`resolve_encryption()`** — single function handles AACS 1.0, 2.0, or none. Uses whatever path works (KEYDB VUK, handshake, media key, device key).
|
||||
|
||||
### Stream types
|
||||
|
||||
- **7 stream types** — Disc, ISO, MKV, M2TS, Network, Stdio, Null
|
||||
- **`IsoStream`** — read/write Blu-ray ISO images. Uses `Disc::scan_image()` for full UDF parsing (not heuristic scanning).
|
||||
- **`StdioStream`** — stdin/stdout pipe, format-agnostic
|
||||
- **Strict URL format** — all URLs require `scheme://path`. Bare paths rejected with clear error messages.
|
||||
- **Validation** — empty paths, missing ports, read-only/write-only direction errors
|
||||
|
||||
### IOStream trait
|
||||
|
||||
- `IOStream` trait for all stream types (Read + Write + info + finish)
|
||||
- `open_input()` / `open_output()` resolve URL strings to stream instances
|
||||
|
||||
## 0.7.0 (2026-04-11)
|
||||
|
||||
### Stream I/O architecture
|
||||
|
||||
- **5 stream types** — Disc, MKV, M2TS, Network, Null
|
||||
- **`IOStream` trait** — common interface for all streams
|
||||
- **URL resolver** — `open_input()` / `open_output()` with scheme://path format
|
||||
- **FMKV metadata header** — JSON metadata embedded in M2TS and network streams
|
||||
- **Bidirectional MKV** — MkvStream reads and writes Matroska containers
|
||||
- **Network streaming** — TCP with metadata header, TCP_NODELAY
|
||||
- **BD-TS demuxer** — PAT/PMT scanning, PTS duration detection
|
||||
- **EBML reader** — parse existing MKV files for read-side MkvStream
|
||||
|
||||
## 0.6.0 (2026-04-10)
|
||||
|
||||
### API improvements
|
||||
|
||||
- **`open()` works on all drives** — no profile match required. Unknown drives can scan, read BD/DVD at OEM speed. `init()` is optional and adds features (riplock removal, UHD reads, speed control).
|
||||
- **`has_profile()`** — check if unlock parameters are available for this drive
|
||||
- **`find_drives()`** — returns all optical drives, not just profile-matched ones
|
||||
- **`raw_gc_010c`** on `DriveId` — raw GET_CONFIG 010C response bytes for profile sharing
|
||||
|
||||
### AACS 2.0
|
||||
|
||||
- **SCSI handshake wired end-to-end** — ECDH key agreement, real Volume ID from drive, read data key for bus decryption
|
||||
- **Bus decryption active** — UHD discs with bus encryption now decrypted transparently
|
||||
- **VUK derivation from Media Key + VID** — works for discs not in KEYDB (processing key + device key paths)
|
||||
|
||||
### MKV muxer
|
||||
|
||||
- **15 new files** — EBML writer, TS demuxer, stream assembly pipeline
|
||||
- **Codec parsers** — H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1
|
||||
- **`MkvStream`** — builder pattern, wraps any `impl Write`, configurable lookahead buffer
|
||||
|
||||
### Cleanup
|
||||
|
||||
- Removed orphaned `jar.rs` (342 lines) — replaced by `labels/` module
|
||||
- Error refactor: 40+ sites converted from English strings to typed error codes
|
||||
|
||||
## 0.5.0 (2026-04-09)
|
||||
|
||||
### Read pipeline — 5x speed improvement
|
||||
|
||||
- **Kernel transfer limit detection**: auto-detect `max_hw_sectors_kb` via sysfs, resolve sg→block device. Previously hardcoded to 510 sectors (1MB) which exceeded the 120KB kernel limit, causing all reads to error and fall back to 6KB reads at 4.8 MB/s. Now auto-tunes to 48 sectors (96KB) or whatever the device supports.
|
||||
- **Result: 12.5 MB/s sustained, 23 MB/s peak** (was 4.8 MB/s)
|
||||
|
||||
### LibreDrive — full init pipeline
|
||||
|
||||
- **All 10 ARM handlers translated**: unlock, firmware upload (A: WRITE_BUFFER, B: MODE SELECT), calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
|
||||
- **Cold boot firmware upload**: WRITE_BUFFER 1888B (A variant) or MODE SELECT 2496B (B variant) proven on hardware
|
||||
- **Speed calibration**: 256+ disc surface probes, 64-entry speed table, triple SET_CD_SPEED
|
||||
- **Platform trait locked down**: `pub(crate)`, 3 methods only (init, set_read_speed, is_ready)
|
||||
- **Init guard**: prevents double-init, signature mismatch aborts early
|
||||
|
||||
### MPLS parser fixes
|
||||
|
||||
- **PGS in audio slots**: subtitle language read at correct offset (was truncated: "ng " → "eng")
|
||||
- **Secondary PG entries**: n_pip_pg loop added for correct STN position tracking
|
||||
- **Secondary stream types**: stream_type 5 (sec audio), 6 (sec video), 7 (DV EL) attribute parsing
|
||||
- **Empty stream filter**: coding_type 0x00 entries (padding) no longer appear as "Unknown(0)"
|
||||
|
||||
### Profiles
|
||||
|
||||
- **206 profiles with full per-drive data**: ld_microcode (base64), all CDBs, speed tables, signatures
|
||||
- **Automated pipeline**: `sdf_unpack --profiles` → profiles.json (no manual merging)
|
||||
|
||||
## 0.4.0 (2026-04-07)
|
||||
|
||||
### Labels — complete rewrite
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.4.1"
|
||||
version = "0.7.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
@@ -12,6 +12,7 @@ categories = ["hardware-support", "multimedia"]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
flate2 = "1"
|
||||
@@ -21,6 +22,7 @@ num-integer = "0.1"
|
||||
rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
|
||||
# libfreemkv
|
||||
|
||||
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, AACS decryption, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, stream labels, AACS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
|
||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
|
||||
@@ -14,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.4"
|
||||
libfreemkv = "0.6"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -25,6 +29,9 @@ use std::path::Path;
|
||||
|
||||
// Open drive — profiles are bundled, auto-identified
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
session.wait_ready()?; // wait for disc
|
||||
session.init()?; // unlock + firmware upload
|
||||
session.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
@@ -42,27 +49,46 @@ while let Some(unit) = reader.read_unit()? {
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, unlock for raw reads
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info, BD-J labels
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0)
|
||||
- **Content reading** — sector reads with automatic decryption
|
||||
- **Drive access** — open, identify, unlock, firmware upload, speed calibration, eject
|
||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
|
||||
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
|
||||
- **Content reading** — adaptive batch reads with automatic decryption and error recovery
|
||||
- **Stream I/O** — unified stream pipeline for reading and writing any format
|
||||
|
||||
### Streams
|
||||
|
||||
| Stream | Input | Output | Transport |
|
||||
|--------|-------|--------|-----------|
|
||||
| DiscStream | Yes | -- | Optical drive via SCSI |
|
||||
| IsoStream | Yes | -- | Blu-ray ISO image file |
|
||||
| MkvStream | Yes | Yes | Matroska container |
|
||||
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
||||
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
|
||||
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
|
||||
|
||||
All streams implement the `IOStream` trait. `open_input()` and `open_output()` resolve URL strings to stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
|
||||
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
DriveSession — open, identify, unlock, read sectors
|
||||
DriveSession — open any drive, identify, init (optional), read sectors
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||
└── Platform — MediaTek (supported), Renesas (planned)
|
||||
└── PlatformDriver — MediaTek (supported), Renesas (planned)
|
||||
|
||||
Disc — scan titles, streams, AACS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
├── MPLS parser — playlists → titles + clips + streams
|
||||
├── CLPI parser — clip info → EP map → sector extents
|
||||
├── JAR parser — BD-J audio track labels
|
||||
└── AACS — key resolution + content decryption
|
||||
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||
├── AACS — key resolution + content decryption
|
||||
└── KEYDB — download + verify + save
|
||||
```
|
||||
|
||||
See [docs/](docs/) for detailed technical documentation on each module.
|
||||
@@ -80,6 +106,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
||||
| E5xxx | I/O errors |
|
||||
| E6xxx | Disc format errors |
|
||||
| E7xxx | AACS errors |
|
||||
| E8xxx | KEYDB update errors |
|
||||
|
||||
## Platform Support
|
||||
|
||||
@@ -91,7 +118,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
||||
|
||||
## Contributing
|
||||
|
||||
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
Run `freemkv drive-info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
fn main() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target == "macos" {
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# libfreemkv API Design
|
||||
|
||||
## Principles
|
||||
|
||||
1. Lib provides building blocks. App composes them.
|
||||
2. No English text in lib. Error codes only. App handles i18n.
|
||||
3. No display logic in lib. App decides what to show.
|
||||
4. Streams are the pipeline. Each stage wraps the next.
|
||||
5. Lib fires events. App listens.
|
||||
|
||||
## Core API
|
||||
|
||||
```rust
|
||||
// Open drive — explicit steps, app prints between them
|
||||
let mut session = DriveSession::open(path)?;
|
||||
session.wait_ready()?;
|
||||
session.init()?;
|
||||
session.probe_disc()?;
|
||||
|
||||
// Scan disc
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
|
||||
// Browse
|
||||
disc.titles // Vec<Title>
|
||||
disc.format // BD / UHD / DVD
|
||||
disc.capacity_gb()
|
||||
|
||||
// Rip with events
|
||||
disc.rip(&mut session, 0, output, |event| {
|
||||
match event.kind {
|
||||
EventKind::BytesRead { bytes, total } => ...,
|
||||
EventKind::ReadError { sector, error } => ...,
|
||||
EventKind::Retry { attempt } => ...,
|
||||
EventKind::SpeedChange { speed_kbs } => ...,
|
||||
EventKind::Complete { bytes, errors } => ...,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Rip without events
|
||||
disc.rip(&mut session, 0, output, event::ignore)?;
|
||||
```
|
||||
|
||||
## Stream Chains
|
||||
|
||||
Each stream wraps the next. Builder pattern, no `.build()`.
|
||||
|
||||
### Raw m2ts
|
||||
```rust
|
||||
disc.rip(&mut session, 0, File::create("movie.m2ts")?, event::ignore)?;
|
||||
```
|
||||
|
||||
### MKV
|
||||
```rust
|
||||
let output = MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(10 * 1024 * 1024);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
### MKV with progress (CLI)
|
||||
```rust
|
||||
let output = ProgressStream::new(
|
||||
MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(10 * 1024 * 1024),
|
||||
total_bytes,
|
||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
||||
);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
### Future: transcode
|
||||
```rust
|
||||
let output = ProgressStream::new(
|
||||
TranscodeStream::new(
|
||||
MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(50 * 1024 * 1024),
|
||||
)
|
||||
.codec(H265)
|
||||
.quality(22),
|
||||
total_bytes,
|
||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
||||
);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
Lib fires events during operations. App provides a callback. No display, no text.
|
||||
|
||||
```rust
|
||||
pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
pub enum EventKind {
|
||||
BytesRead { bytes: u64, total: u64 },
|
||||
ReadError { sector: u64, error: Error },
|
||||
Retry { attempt: u32 },
|
||||
SpeedChange { speed_kbs: u16 },
|
||||
ExtentStart { index: usize, start_sector: u64, sector_count: u64 },
|
||||
Complete { bytes: u64, errors: u32 },
|
||||
}
|
||||
```
|
||||
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI prints a line. Server logs to file.
|
||||
|
||||
## Error Codes
|
||||
|
||||
Lib errors are codes, not messages. Like HTTP status codes.
|
||||
|
||||
```rust
|
||||
pub enum Error {
|
||||
// Drive
|
||||
DriveNotFound,
|
||||
DriveOpenFailed,
|
||||
DriveNotReady,
|
||||
|
||||
// Unlock
|
||||
UnlockFailed,
|
||||
NoProfile,
|
||||
|
||||
// AACS
|
||||
AacsNoKeys,
|
||||
AacsCertVerifyFailed,
|
||||
AacsAgidAllocFailed,
|
||||
AacsHandshakeFailed,
|
||||
AacsVidMacFailed,
|
||||
|
||||
// Disc
|
||||
DiscReadError { sector: u64 },
|
||||
MplsParseError,
|
||||
ClpiParseError,
|
||||
UdfFileNotFound { path: String },
|
||||
|
||||
// Mux
|
||||
LookaheadOverflow,
|
||||
MuxWriteError,
|
||||
|
||||
// SCSI
|
||||
ScsiError { sense: u8 },
|
||||
}
|
||||
```
|
||||
|
||||
App maps codes to localized strings. Lib never contains display text.
|
||||
|
||||
## Streams
|
||||
|
||||
All streams implement the `IOStream` trait (Read + Write). URL-based resolver opens any stream by string.
|
||||
|
||||
| Stream | Input | Output | URL | Transport |
|
||||
|--------|-------|--------|-----|-----------|
|
||||
| DiscStream | Yes | -- | `disc://` `disc:///dev/sg4` | Optical drive via SCSI |
|
||||
| IsoStream | Yes | -- | `iso://path.iso` | Blu-ray ISO image |
|
||||
| MkvStream | Yes | Yes | `mkv://path` | Matroska container |
|
||||
| M2tsStream | Yes | Yes | `m2ts://path` | BD-TS with FMKV metadata header |
|
||||
| NetworkStream | Yes (listen) | Yes (connect) | `network://host:port` | TCP with FMKV metadata header |
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | `stdio://` | Raw byte pipe |
|
||||
| NullStream | -- | Yes | `null://` | Discard sink (byte counter) |
|
||||
|
||||
All URLs require a `scheme://path` format. Bare paths are rejected.
|
||||
|
||||
```rust
|
||||
// URL-based opening
|
||||
let input = open_input("disc://", &opts)?; // DiscStream (auto-detect)
|
||||
let input = open_input("disc:///dev/sg4", &opts)?; // DiscStream (specific device)
|
||||
let input = open_input("iso://Dune.iso", &opts)?; // IsoStream
|
||||
let input = open_input("m2ts:///tmp/Dune.m2ts", &opts)?; // M2tsStream
|
||||
let input = open_input("mkv://Dune.mkv", &opts)?; // MkvStream
|
||||
let input = open_input("network://0.0.0.0:9000", &opts)?; // NetworkStream (listen)
|
||||
let input = open_input("stdio://", &opts)?; // StdioStream (stdin)
|
||||
|
||||
let output = open_output("mkv://Dune.mkv", &meta)?; // MkvStream
|
||||
let output = open_output("m2ts://Dune.m2ts", &meta)?; // M2tsStream
|
||||
let output = open_output("network://10.0.0.1:9000", &meta)?;// NetworkStream (connect)
|
||||
let output = open_output("stdio://", &meta)?; // StdioStream (stdout)
|
||||
let output = open_output("null://", &meta)?; // NullStream
|
||||
|
||||
// Direct construction (for advanced use)
|
||||
let mkv = MkvStream::new(writer).meta(&title).max_buffer(10 * 1024 * 1024);
|
||||
let m2ts = M2tsStream::new(writer).meta(&title);
|
||||
let net = NetworkStream::connect("10.0.0.1:9000")?.meta(&title);
|
||||
let null = NullStream::new().meta(&title);
|
||||
```
|
||||
|
||||
### FMKV Metadata Header
|
||||
|
||||
M2tsStream and NetworkStream embed a JSON metadata header before the BD-TS data:
|
||||
|
||||
```
|
||||
[8B magic "FMKV\0\0\0\0"][4B JSON length][JSON metadata][padding to 192B boundary][BD-TS data...]
|
||||
```
|
||||
|
||||
The header carries title name, duration, and full stream layout (PIDs, codecs, languages, labels). This allows the receiving end to set up demuxing and track metadata without scanning the TS.
|
||||
|
||||
### MkvStream Internals
|
||||
|
||||
LookaheadBuffer (default 5MB, configurable):
|
||||
1. Phase 1: buffer incoming data, scan for codec setup (SPS/PPS)
|
||||
2. Found it? Write MKV header, flush buffer, switch to streaming
|
||||
3. Buffer full? Error — app handles it
|
||||
4. Phase 2: parse TS → frames → MKV clusters, direct to output
|
||||
|
||||
Reading: extracts MKV frames, wraps back into BD-TS PES packets.
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
libfreemkv/src/
|
||||
├── lib.rs Public exports
|
||||
├── error.rs Error codes (no English)
|
||||
├── event.rs Event types for callbacks
|
||||
├── drive.rs DriveSession (open, init, read)
|
||||
├── disc.rs Disc (scan, rip, titles)
|
||||
├── scsi/ SCSI transport (Linux, macOS)
|
||||
├── platform/ Drive unlock (MT1959 A/B)
|
||||
├── aacs/ AACS decryption
|
||||
├── udf.rs UDF filesystem parser
|
||||
├── mpls.rs Playlist parser
|
||||
├── clpi.rs Clip info parser
|
||||
├── mux/
|
||||
│ ├── mod.rs IOStream trait, public exports
|
||||
│ ├── resolve.rs URL parser + open_input/open_output
|
||||
│ ├── meta.rs M2tsMeta (FMKV header format)
|
||||
│ ├── disc.rs DiscStream (optical drive)
|
||||
│ ├── mkvstream.rs MkvStream (bidirectional Matroska)
|
||||
│ ├── m2ts.rs M2tsStream (BD-TS + FMKV header)
|
||||
│ ├── network.rs NetworkStream (TCP + FMKV header)
|
||||
│ ├── stdio.rs StdioStream (stdin/stdout pipe)
|
||||
│ ├── iso.rs IsoStream (Blu-ray ISO image)
|
||||
│ ├── null.rs NullStream (discard + byte counter)
|
||||
│ ├── lookahead.rs LookaheadBuffer (codec header scanning)
|
||||
│ ├── ts.rs BD-TS demuxer + PAT/PMT scanner
|
||||
│ ├── ebml.rs EBML read/write primitives
|
||||
│ ├── mkv.rs MKV muxer (tracks, clusters, cues)
|
||||
│ └── codec/ Frame parsers (H.264, HEVC, VC-1, AC3, DTS, TrueHD, PGS, LPCM)
|
||||
└── ...
|
||||
|
||||
freemkv/src/
|
||||
├── main.rs CLI dispatcher (URL routing)
|
||||
├── pipe.rs Generic source → dest copy
|
||||
├── rip.rs Rip with progress display
|
||||
├── remux.rs Remux with progress display
|
||||
├── disc_info.rs Disc info display
|
||||
├── info.rs Drive info + profile submission
|
||||
├── strings.rs i18n string table
|
||||
├── output.rs Verbosity-filtered output
|
||||
└── build.rs Bundled locale code generation
|
||||
```
|
||||
@@ -0,0 +1,240 @@
|
||||
# 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)
|
||||
+2067
-3093
File diff suppressed because it is too large
Load Diff
+441
-27
@@ -67,7 +67,54 @@ const EC_GY: [u8; 20] = [
|
||||
0x55, 0xC7, 0xF2, 0x3C, 0x9A, 0x07, 0x07, 0xF5, 0xCB, 0xB9,
|
||||
];
|
||||
|
||||
// ── AACS LA (Licensing Administrator) public key for cert verification ──────
|
||||
// ── AACS 2.0 elliptic curve parameters (P-256 / secp256r1 / NIST prime256v1)
|
||||
|
||||
const P256_P: [u8; 32] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
];
|
||||
const P256_A: [u8; 32] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC,
|
||||
];
|
||||
#[cfg(test)]
|
||||
const P256_B: [u8; 32] = [
|
||||
0x5A, 0xC6, 0x35, 0xD8, 0xAA, 0x3A, 0x93, 0xE7, 0xB3, 0xEB, 0xBD, 0x55,
|
||||
0x76, 0x98, 0x86, 0xBC, 0x65, 0x1D, 0x06, 0xB0, 0xCC, 0x53, 0xB0, 0xF6,
|
||||
0x3B, 0xCE, 0x3C, 0x3E, 0x27, 0xD2, 0x60, 0x4B,
|
||||
];
|
||||
const P256_N: [u8; 32] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xBC, 0xE6, 0xFA, 0xAD, 0xA7, 0x17, 0x9E, 0x84,
|
||||
0xF3, 0xB9, 0xCA, 0xC2, 0xFC, 0x63, 0x25, 0x51,
|
||||
];
|
||||
const P256_GX: [u8; 32] = [
|
||||
0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, 0xF8, 0xBC, 0xE6, 0xE5,
|
||||
0x63, 0xA4, 0x40, 0xF2, 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, 0x33, 0xA0,
|
||||
0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96,
|
||||
];
|
||||
const P256_GY: [u8; 32] = [
|
||||
0x4F, 0xE3, 0x42, 0xE2, 0xFE, 0x1A, 0x7F, 0x9B, 0x8E, 0xE7, 0xEB, 0x4A,
|
||||
0x7C, 0x0F, 0x9E, 0x16, 0x2B, 0xCE, 0x33, 0x57, 0x6B, 0x31, 0x5E, 0xCE,
|
||||
0xCB, 0xB6, 0x40, 0x68, 0x37, 0xBF, 0x51, 0xF5,
|
||||
];
|
||||
|
||||
/// AACS 2.0 LA public key for cert verification (P-256).
|
||||
/// From AACS2 specification — used to verify type 0x11 drive certificates.
|
||||
const AACS2_LA_PUB_X: [u8; 32] = [
|
||||
0xF9, 0x57, 0xBC, 0x1F, 0xD7, 0xE6, 0x09, 0x7E, 0xCA, 0xCC, 0x35, 0x23,
|
||||
0x4C, 0x9C, 0x66, 0xC3, 0x42, 0xEB, 0x3D, 0xB7, 0x2B, 0x41, 0x06, 0xF4,
|
||||
0x04, 0x9C, 0x6A, 0x88, 0x70, 0x00, 0xAA, 0x2C,
|
||||
];
|
||||
const AACS2_LA_PUB_Y: [u8; 32] = [
|
||||
0x39, 0x55, 0x0B, 0x41, 0x02, 0x27, 0xEA, 0x7B, 0x1A, 0x53, 0xF8, 0x67,
|
||||
0x8C, 0x5A, 0x91, 0x6F, 0xFC, 0x7C, 0x78, 0x01, 0x3E, 0x89, 0x15, 0xE3,
|
||||
0xF0, 0x81, 0xD3, 0xE9, 0x3E, 0x17, 0x55, 0x0B,
|
||||
];
|
||||
|
||||
// ── AACS 1.0 LA (Licensing Administrator) public key for cert verification ──
|
||||
|
||||
const AACS_LA_PUB_X: [u8; 20] = [
|
||||
0x01, 0xF3, 0x5D, 0xAB, 0xD8, 0xAE, 0x5F, 0x40, 0x56, 0x5E,
|
||||
@@ -156,7 +203,9 @@ fn ec_add(p1: &EcPoint, p2: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
(p - (&p1.x - &p2.x) % p) % p
|
||||
};
|
||||
|
||||
let dx_inv = mod_inv(&dx, p).unwrap();
|
||||
// Safety: mod_inv only returns None if dx == 0 (points identical),
|
||||
// which is prevented by the caller using ec_double for that case.
|
||||
let dx_inv = mod_inv(&dx, p).expect("ec_add: dx has no inverse");
|
||||
let lam = (&dy * &dx_inv) % p;
|
||||
|
||||
// x3 = λ² - x1 - x2 mod p
|
||||
@@ -200,7 +249,9 @@ fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
|
||||
let numerator = (&three * &pt.x * &pt.x + a) % p;
|
||||
let denominator = (&two * &pt.y) % p;
|
||||
let denom_inv = mod_inv(&denominator, p).unwrap();
|
||||
// Safety: mod_inv only returns None if 2*y == 0 (point at infinity),
|
||||
// which shouldn't occur with valid curve points.
|
||||
let denom_inv = mod_inv(&denominator, p).expect("ec_double: denominator has no inverse");
|
||||
let lam = (&numerator * &denom_inv) % p;
|
||||
|
||||
// x3 = λ² - 2x mod p
|
||||
@@ -350,6 +401,132 @@ fn ecdsa_verify(pub_x: &[u8; 20], pub_y: &[u8; 20], sig_r: &[u8; 20], sig_s: &[u
|
||||
&r_point.x % &n == r
|
||||
}
|
||||
|
||||
// ── P-256 ECDSA (SHA-256) for AACS 2.0 ─────────────────────────────────────
|
||||
|
||||
/// ECDSA sign with P-256/SHA-256. Returns (r, s) each 32 bytes.
|
||||
fn ecdsa_sign_p256(priv_key: &[u8; 32], data: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
use sha2::{Sha256, Digest as Sha2Digest};
|
||||
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
let d = BigUint::from_bytes_be(priv_key);
|
||||
|
||||
let hash = Sha256::digest(data);
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
loop {
|
||||
let mut k_bytes = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut k_bytes);
|
||||
let k = BigUint::from_bytes_be(&k_bytes) % &n;
|
||||
if k.is_zero() { continue; }
|
||||
|
||||
let r_point = ec_mul(&k, &g, &a, &p);
|
||||
let r = &r_point.x % &n;
|
||||
if r.is_zero() { continue; }
|
||||
|
||||
let k_inv = match mod_inv(&k, &n) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
let s = (&k_inv * ((&z + &r * &d) % &n)) % &n;
|
||||
if s.is_zero() { continue; }
|
||||
|
||||
let r_bytes = to_bytes_be_padded(&r, 32);
|
||||
let s_bytes = to_bytes_be_padded(&s, 32);
|
||||
|
||||
let mut r_out = [0u8; 32];
|
||||
let mut s_out = [0u8; 32];
|
||||
r_out.copy_from_slice(&r_bytes);
|
||||
s_out.copy_from_slice(&s_bytes);
|
||||
|
||||
return (r_out, s_out);
|
||||
}
|
||||
}
|
||||
|
||||
/// ECDSA verify with P-256/SHA-256.
|
||||
fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], data: &[u8]) -> bool {
|
||||
use sha2::{Sha256, Digest as Sha2Digest};
|
||||
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
let q = EcPoint::new(BigUint::from_bytes_be(pub_x), BigUint::from_bytes_be(pub_y));
|
||||
|
||||
let r = BigUint::from_bytes_be(sig_r);
|
||||
let s = BigUint::from_bytes_be(sig_s);
|
||||
|
||||
if r.is_zero() || r >= n || s.is_zero() || s >= n {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hash = Sha256::digest(data);
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
let s_inv = match mod_inv(&s, &n) {
|
||||
Some(v) => v,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let u1 = (&z * &s_inv) % &n;
|
||||
let u2 = (&r * &s_inv) % &n;
|
||||
|
||||
let p1 = ec_mul(&u1, &g, &a, &p);
|
||||
let p2 = ec_mul(&u2, &q, &a, &p);
|
||||
let r_point = ec_add(&p1, &p2, &a, &p);
|
||||
|
||||
if r_point.infinity {
|
||||
return false;
|
||||
}
|
||||
|
||||
&r_point.x % &n == r
|
||||
}
|
||||
|
||||
/// Verify an AACS 2.0 certificate (type 0x11, 132 bytes) against AACS 2.0 LA key.
|
||||
fn verify_cert_p256(cert: &[u8]) -> bool {
|
||||
if cert.len() < 132 { return false; }
|
||||
// AACS 2.0 cert: type(1) + flags(1) + padding(2) + serial(6) + pub_x(32) + pub_y(32) + sig_r(32) + sig_s(32)
|
||||
// Signature is over the first 74 bytes
|
||||
let sig_r = &cert[74..106];
|
||||
let sig_s = &cert[106..138]; // some certs may be padded differently
|
||||
|
||||
// Use what we have — verify over the signed portion
|
||||
if cert.len() >= 138 {
|
||||
ecdsa_verify_p256(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y, sig_r, sig_s, &cert[..74])
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract public key from an AACS 2.0 certificate (32-byte x,y).
|
||||
fn cert_pub_key_p256(cert: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
let mut x = [0u8; 32];
|
||||
let mut y = [0u8; 32];
|
||||
x.copy_from_slice(&cert[10..42]);
|
||||
y.copy_from_slice(&cert[42..74]);
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// Compute bus key via ECDH on P-256 curve.
|
||||
fn compute_bus_key_p256(host_priv: &[u8; 32], drive_key_point_x: &[u8], drive_key_point_y: &[u8]) -> [u8; 16] {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
|
||||
let d = BigUint::from_bytes_be(host_priv);
|
||||
let dkp = EcPoint::new(BigUint::from_bytes_be(drive_key_point_x), BigUint::from_bytes_be(drive_key_point_y));
|
||||
|
||||
let shared = ec_mul(&d, &dkp, &a, &p);
|
||||
|
||||
// Bus key = lowest 128 bits of x-coordinate
|
||||
let x_bytes = to_bytes_be_padded(&shared.x, 32);
|
||||
let mut bus_key = [0u8; 16];
|
||||
bus_key.copy_from_slice(&x_bytes[16..32]);
|
||||
bus_key
|
||||
}
|
||||
|
||||
// ── AACS certificate handling ───────────────────────────────────────────────
|
||||
|
||||
/// Verify an AACS certificate (92 bytes) against the AACS LA public key.
|
||||
@@ -394,6 +571,31 @@ fn compute_bus_key(host_priv: &[u8; 20], drive_key_point_x: &[u8; 20], drive_key
|
||||
}
|
||||
|
||||
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
|
||||
/// Generate P-256 ephemeral key pair for AACS 2.0.
|
||||
fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) {
|
||||
let p_mod = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
let mut priv_bytes = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut priv_bytes);
|
||||
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
|
||||
|
||||
let q = ec_mul(&d, &g, &a, &p_mod);
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
let mut pub_x = [0u8; 32];
|
||||
let mut pub_y = [0u8; 32];
|
||||
key.copy_from_slice(&to_bytes_be_padded(&d, 32));
|
||||
pub_x.copy_from_slice(&to_bytes_be_padded(&q.x, 32));
|
||||
pub_y.copy_from_slice(&to_bytes_be_padded(&q.y, 32));
|
||||
|
||||
(key, pub_x, pub_y)
|
||||
}
|
||||
|
||||
/// Generate AACS 1.0 ephemeral key pair.
|
||||
fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
|
||||
let p_mod = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
@@ -517,7 +719,7 @@ pub fn aacs_authenticate(
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
if host_cert.len() < 92 {
|
||||
return Err(Error::AacsError { detail: "host certificate too short".into() });
|
||||
return Err(Error::AacsCertShort);
|
||||
}
|
||||
|
||||
// Step 1: Invalidate all AGIDs
|
||||
@@ -529,7 +731,7 @@ pub fn aacs_authenticate(
|
||||
// Step 2: Allocate AGID
|
||||
let cdb = cdb_report_key(0, 0x00, 8);
|
||||
let response = scsi_read(session, &cdb, 8)
|
||||
.map_err(|e| Error::AacsError { detail: format!("failed to allocate AGID: {}", e) })?;
|
||||
.map_err(|_| Error::AacsAgidAlloc)?;
|
||||
let agid = (response[7] >> 6) & 0x03;
|
||||
|
||||
// Step 3: Generate host nonce and ephemeral key pair
|
||||
@@ -546,37 +748,34 @@ pub fn aacs_authenticate(
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x01, 116);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected host certificate".into() })?;
|
||||
.map_err(|_| Error::AacsCertRejected)?;
|
||||
|
||||
// Step 5: Read drive certificate + nonce (REPORT KEY format 0x01)
|
||||
let cdb = cdb_report_key(agid, 0x01, 116);
|
||||
let response = scsi_read(session, &cdb, 116)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read drive certificate".into() })?;
|
||||
.map_err(|_| Error::AacsCertRead)?;
|
||||
|
||||
let mut drive_nonce = [0u8; 20];
|
||||
let mut drive_cert = [0u8; 92];
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
drive_cert.copy_from_slice(&response[24..116]);
|
||||
|
||||
// Detect AACS 2.0 drive certificate (type 0x11)
|
||||
// AACS 2.0 drives use P-256/SHA-256 natively but accept AACS 1.0 host certs
|
||||
// for backward compatibility. We proceed with AACS 1.0 handshake.
|
||||
if drive_cert[0] == 0x11 {
|
||||
// AACS 2.0 drive detected — falling back to AACS 1.0 handshake
|
||||
// (full P-256 AACS 2.0 handshake not yet implemented)
|
||||
// The drive should still accept our AACS 1.0 host certificate.
|
||||
// Verify drive certificate
|
||||
if drive_cert[0] == 0x01 {
|
||||
// AACS 1.0 certificate
|
||||
if !verify_cert(&drive_cert) {
|
||||
return Err(Error::AacsCertVerify);
|
||||
}
|
||||
} else if drive_cert[0] == 0x11 {
|
||||
// AACS 2.0 certificate — verify with P-256 LA key
|
||||
// Note: AACS 2.0 drives still accept AACS 1.0 host certs for compatibility
|
||||
// Verification is optional here since we proceed with AACS 1.0 flow anyway
|
||||
}
|
||||
|
||||
// Verify drive certificate (AACS 1.0 LA signature)
|
||||
if drive_cert[0] == 0x01 && !verify_cert(&drive_cert) {
|
||||
return Err(Error::AacsError { detail: "drive certificate verification failed".into() });
|
||||
}
|
||||
// Skip verification for AACS 2.0 certs (different LA key, P-256 curve)
|
||||
|
||||
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
|
||||
let cdb = cdb_report_key(agid, 0x02, 84);
|
||||
let response = scsi_read(session, &cdb, 84)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read drive key".into() })?;
|
||||
.map_err(|_| Error::AacsKeyRead)?;
|
||||
|
||||
let mut drive_key_point = [0u8; 40]; // x(20) + y(20)
|
||||
let mut drive_key_sig = [0u8; 40]; // r(20) + s(20)
|
||||
@@ -595,7 +794,7 @@ pub fn aacs_authenticate(
|
||||
sig_s.copy_from_slice(&drive_key_sig[20..40]);
|
||||
|
||||
if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) {
|
||||
return Err(Error::AacsError { detail: "drive key signature verification failed".into() });
|
||||
return Err(Error::AacsKeyVerify);
|
||||
}
|
||||
|
||||
// Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point)
|
||||
@@ -616,7 +815,7 @@ pub fn aacs_authenticate(
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x02, 84);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected host key".into() })?;
|
||||
.map_err(|_| Error::AacsKeyRejected)?;
|
||||
|
||||
// Step 9: Compute bus key via ECDH
|
||||
let mut dkp_x = [0u8; 20];
|
||||
@@ -635,12 +834,151 @@ pub fn aacs_authenticate(
|
||||
})
|
||||
}
|
||||
|
||||
/// Full AACS 2.0 authentication using P-256/SHA-256.
|
||||
///
|
||||
/// Used when both host and drive support AACS 2.0 natively.
|
||||
/// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials
|
||||
/// are not available.
|
||||
pub fn aacs2_authenticate(
|
||||
session: &mut DriveSession,
|
||||
host_priv_key_v1: &[u8; 20],
|
||||
host_cert_v1: &[u8],
|
||||
host_priv_key_v2: Option<&[u8; 32]>,
|
||||
host_cert_v2: Option<&[u8]>,
|
||||
) -> Result<AacsAuth> {
|
||||
// Try AACS 1.0 first (backward compatible with all drives)
|
||||
match aacs_authenticate(session, host_priv_key_v1, host_cert_v1) {
|
||||
Ok(auth) => return Ok(auth),
|
||||
Err(_) => {
|
||||
// AACS 1.0 rejected — try native P-256 if we have v2 credentials
|
||||
}
|
||||
}
|
||||
|
||||
// AACS 2.0 native P-256 handshake
|
||||
let host_priv_v2 = host_priv_key_v2.ok_or(Error::AacsCertShort)?;
|
||||
let host_cert_v2 = host_cert_v2.ok_or(Error::AacsCertShort)?;
|
||||
|
||||
aacs2_authenticate_p256(session, host_priv_v2, host_cert_v2)
|
||||
}
|
||||
|
||||
/// Native AACS 2.0 handshake using P-256/SHA-256.
|
||||
/// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs).
|
||||
fn aacs2_authenticate_p256(
|
||||
session: &mut DriveSession,
|
||||
host_priv_key: &[u8; 32],
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
if host_cert.len() < 132 {
|
||||
return Err(Error::AacsCertShort);
|
||||
}
|
||||
|
||||
// Step 1: Invalidate all AGIDs
|
||||
for agid in 0..4u8 {
|
||||
let cdb = cdb_report_key(agid, 0x3F, 2);
|
||||
let _ = scsi_read(session, &cdb, 2);
|
||||
}
|
||||
|
||||
// Step 2: Allocate AGID
|
||||
let cdb = cdb_report_key(0, 0x00, 8);
|
||||
let response = scsi_read(session, &cdb, 8)
|
||||
.map_err(|_| Error::AacsAgidAlloc)?;
|
||||
let agid = (response[7] >> 6) & 0x03;
|
||||
|
||||
// Step 3: Generate host nonce + P-256 ephemeral key pair
|
||||
let mut host_nonce = [0u8; 20];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut host_nonce);
|
||||
let (host_eph_key, host_eph_pub_x, host_eph_pub_y) = generate_host_key_pair_p256();
|
||||
|
||||
// Step 4: Send AACS 2.0 host certificate + nonce
|
||||
// AACS 2.0: cert is 132 bytes, total payload = 4 + 20 + 132 = 156
|
||||
let mut send_buf = vec![0u8; 156];
|
||||
send_buf[1] = 0x9a; // data length (154)
|
||||
send_buf[4..24].copy_from_slice(&host_nonce);
|
||||
send_buf[24..156].copy_from_slice(&host_cert[..132]);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x01, 156);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsCertRejected)?;
|
||||
|
||||
// Step 5: Read drive certificate + nonce
|
||||
// AACS 2.0 drive cert is also 132 bytes
|
||||
let cdb = cdb_report_key(agid, 0x01, 156);
|
||||
let response = scsi_read(session, &cdb, 156)
|
||||
.map_err(|_| Error::AacsCertRead)?;
|
||||
|
||||
let mut drive_nonce = [0u8; 20];
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
let drive_cert = &response[24..156];
|
||||
|
||||
// Verify drive certificate with AACS 2.0 LA key
|
||||
if drive_cert[0] == 0x11 && !verify_cert_p256(drive_cert) {
|
||||
// Non-fatal: some cert formats may differ
|
||||
}
|
||||
|
||||
// Step 6: Read drive key point + signature (P-256: 64+64 = 128 bytes)
|
||||
let cdb = cdb_report_key(agid, 0x02, 132);
|
||||
let response = scsi_read(session, &cdb, 132)
|
||||
.map_err(|_| Error::AacsKeyRead)?;
|
||||
|
||||
let drive_key_x = &response[4..36];
|
||||
let drive_key_y = &response[36..68];
|
||||
let drive_sig_r = &response[68..100];
|
||||
let drive_sig_s = &response[100..132];
|
||||
|
||||
// Verify drive key signature
|
||||
let (drive_pub_x, drive_pub_y) = cert_pub_key_p256(drive_cert);
|
||||
let mut verify_data = Vec::with_capacity(84);
|
||||
verify_data.extend_from_slice(&host_nonce);
|
||||
verify_data.extend_from_slice(drive_key_x);
|
||||
verify_data.extend_from_slice(drive_key_y);
|
||||
|
||||
if !ecdsa_verify_p256(&drive_pub_x, &drive_pub_y, drive_sig_r, drive_sig_s, &verify_data) {
|
||||
return Err(Error::AacsKeyVerify);
|
||||
}
|
||||
|
||||
// Step 7: Sign host key point
|
||||
let mut sign_data = Vec::with_capacity(84);
|
||||
sign_data.extend_from_slice(&drive_nonce);
|
||||
sign_data.extend_from_slice(&host_eph_pub_x);
|
||||
sign_data.extend_from_slice(&host_eph_pub_y);
|
||||
|
||||
let (host_sig_r, host_sig_s) = ecdsa_sign_p256(host_priv_key, &sign_data);
|
||||
|
||||
// Step 8: Send host key point + signature (P-256: 64+64 = 128 bytes payload)
|
||||
let mut send_buf = vec![0u8; 132];
|
||||
send_buf[1] = 0x82; // data length
|
||||
send_buf[4..36].copy_from_slice(&host_eph_pub_x);
|
||||
send_buf[36..68].copy_from_slice(&host_eph_pub_y);
|
||||
send_buf[68..100].copy_from_slice(&host_sig_r);
|
||||
send_buf[100..132].copy_from_slice(&host_sig_s);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x02, 132);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsKeyRejected)?;
|
||||
|
||||
// Step 9: Compute bus key via P-256 ECDH
|
||||
let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y);
|
||||
|
||||
Ok(AacsAuth {
|
||||
bus_key,
|
||||
agid,
|
||||
volume_id: None,
|
||||
read_data_key: None,
|
||||
drive_cert: {
|
||||
let mut dc = [0u8; 92];
|
||||
dc.copy_from_slice(&drive_cert[..92.min(drive_cert.len())]);
|
||||
dc
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Read Volume ID after successful authentication.
|
||||
pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> {
|
||||
// REPORT DISC STRUCTURE format 0x80
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
|
||||
let response = scsi_read(session, &cdb, 36)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read Volume ID".into() })?;
|
||||
.map_err(|_| Error::AacsVidRead)?;
|
||||
|
||||
let mut vid = [0u8; 16];
|
||||
let mut mac = [0u8; 16];
|
||||
@@ -650,7 +988,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
|
||||
// Verify MAC: AES-CMAC(VID, bus_key) should equal mac
|
||||
let calc_mac = aes_cmac_16(&vid, &auth.bus_key);
|
||||
if calc_mac != mac {
|
||||
return Err(Error::AacsError { detail: "VID MAC verification failed".into() });
|
||||
return Err(Error::AacsVidMac);
|
||||
}
|
||||
|
||||
auth.volume_id = Some(vid);
|
||||
@@ -662,7 +1000,7 @@ pub fn read_data_keys(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
|
||||
// REPORT DISC STRUCTURE format 0x84
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
|
||||
let response = scsi_read(session, &cdb, 36)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read data keys".into() })?;
|
||||
.map_err(|_| Error::AacsDataKey)?;
|
||||
|
||||
let mut enc_rdk = [0u8; 16];
|
||||
let mut enc_wdk = [0u8; 16];
|
||||
@@ -754,6 +1092,82 @@ mod tests {
|
||||
assert_eq!(shared_a, shared_b, "ECDH shared secrets should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p256_generator_on_curve() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let b = BigUint::from_bytes_be(&P256_B);
|
||||
let gx = BigUint::from_bytes_be(&P256_GX);
|
||||
let gy = BigUint::from_bytes_be(&P256_GY);
|
||||
|
||||
let lhs = (&gy * &gy) % &p;
|
||||
let rhs = (&gx * &gx * &gx + &a * &gx + &b) % &p;
|
||||
assert_eq!(lhs, rhs, "P-256 generator not on curve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p256_mul_order() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
let result = ec_mul(&n, &g, &a, &p);
|
||||
assert!(result.infinity, "n × G should be point at infinity on P-256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p256_ecdsa_sign_verify() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
// Generate random P-256 key pair
|
||||
let mut priv_bytes = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut priv_bytes);
|
||||
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
|
||||
let priv_key: [u8; 32] = to_bytes_be_padded(&d, 32).try_into().unwrap();
|
||||
|
||||
let pub_point = ec_mul(&d, &g, &a, &p);
|
||||
let pub_x: Vec<u8> = to_bytes_be_padded(&pub_point.x, 32);
|
||||
let pub_y: Vec<u8> = to_bytes_be_padded(&pub_point.y, 32);
|
||||
|
||||
let data = b"AACS 2.0 P-256 ECDSA test";
|
||||
let (sig_r, sig_s) = ecdsa_sign_p256(&priv_key, data);
|
||||
assert!(ecdsa_verify_p256(&pub_x, &pub_y, &sig_r, &sig_s, data));
|
||||
assert!(!ecdsa_verify_p256(&pub_x, &pub_y, &sig_r, &sig_s, b"wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p256_ecdh() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
let mut pa = [0u8; 32];
|
||||
let mut pb = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut pa);
|
||||
rand::thread_rng().fill_bytes(&mut pb);
|
||||
let da = BigUint::from_bytes_be(&pa) % &n;
|
||||
let db = BigUint::from_bytes_be(&pb) % &n;
|
||||
let priv_a: [u8; 32] = to_bytes_be_padded(&da, 32).try_into().unwrap();
|
||||
let priv_b: [u8; 32] = to_bytes_be_padded(&db, 32).try_into().unwrap();
|
||||
|
||||
let pub_a = ec_mul(&da, &g, &a, &p);
|
||||
let pub_b = ec_mul(&db, &g, &a, &p);
|
||||
|
||||
let key_a = compute_bus_key_p256(&priv_a,
|
||||
&to_bytes_be_padded(&pub_b.x, 32), &to_bytes_be_padded(&pub_b.y, 32));
|
||||
let key_b = compute_bus_key_p256(&priv_b,
|
||||
&to_bytes_be_padded(&pub_a.x, 32), &to_bytes_be_padded(&pub_a.y, 32));
|
||||
|
||||
assert_eq!(key_a, key_b, "P-256 ECDH shared secrets should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes_cmac() {
|
||||
// Basic CMAC test — at minimum verify it produces consistent output
|
||||
@@ -776,7 +1190,7 @@ mod tests {
|
||||
if !keydb_path.exists() { return; }
|
||||
|
||||
let db = crate::aacs::KeyDb::load(&keydb_path).unwrap();
|
||||
if let Some(hc) = &db.host_cert {
|
||||
if let Some(hc) = db.host_certs.first() {
|
||||
let valid = verify_cert(&hc.certificate);
|
||||
eprintln!("Host cert verification: {}", if valid { "PASS" } else { "FAIL" });
|
||||
// Note: our cert is revoked but should still have valid LA signature
|
||||
|
||||
+43
-6
@@ -27,7 +27,7 @@ pub struct KeyDb {
|
||||
/// Processing keys (pre-computed media keys for specific MKB versions)
|
||||
pub processing_keys: Vec<[u8; 16]>,
|
||||
/// Host certificate + private key for SCSI authentication
|
||||
pub host_cert: Option<HostCert>,
|
||||
pub host_certs: Vec<HostCert>,
|
||||
/// Per-disc VUK entries indexed by disc hash (hex lowercase)
|
||||
pub disc_entries: HashMap<String, DiscEntry>,
|
||||
}
|
||||
@@ -44,8 +44,14 @@ pub struct DeviceKey {
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||
pub private_key: [u8; 20],
|
||||
pub certificate: Vec<u8>, // 92 bytes
|
||||
/// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes.
|
||||
pub certificate: Vec<u8>,
|
||||
/// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only.
|
||||
pub private_key_v2: Option<[u8; 32]>,
|
||||
/// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only.
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
@@ -99,7 +105,7 @@ impl KeyDb {
|
||||
let mut db = KeyDb {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
host_cert: None,
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: HashMap::new(),
|
||||
};
|
||||
|
||||
@@ -127,9 +133,22 @@ impl KeyDb {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host Certificate
|
||||
// Host Certificate (AACS 2.0)
|
||||
if line.starts_with("| HC2") {
|
||||
if let Some(hc) = db.host_certs.last_mut() {
|
||||
if let Some((pk, cert)) = Self::parse_host_cert_v2(line) {
|
||||
hc.private_key_v2 = Some(pk);
|
||||
hc.certificate_v2 = Some(cert);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host Certificate (AACS 1.0)
|
||||
if line.starts_with("| HC") {
|
||||
db.host_cert = Self::parse_host_cert(line);
|
||||
if let Some(hc) = Self::parse_host_cert(line) {
|
||||
db.host_certs.push(hc);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -201,9 +220,27 @@ impl KeyDb {
|
||||
Some(HostCert {
|
||||
private_key: parse_hex20(priv_str)?,
|
||||
certificate: parse_hex(cert_str)?,
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...`
|
||||
fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec<u8>)> {
|
||||
let priv_str = line.split("HOST_PRIV_KEY").nth(1)?.split('|').next()?.trim();
|
||||
let cert_str = line.split("HOST_CERT").nth(1)?.split(';').next()?.split('|').next()?.trim();
|
||||
|
||||
let priv_bytes = parse_hex(priv_str)?;
|
||||
if priv_bytes.len() != 32 { return None; }
|
||||
let mut pk = [0u8; 32];
|
||||
pk.copy_from_slice(&priv_bytes);
|
||||
|
||||
let cert = parse_hex(cert_str)?;
|
||||
if cert.len() < 132 { return None; }
|
||||
|
||||
Some((pk, cert))
|
||||
}
|
||||
|
||||
fn parse_disc_entry(line: &str) -> Option<DiscEntry> {
|
||||
// 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys>
|
||||
let (hash_part, rest) = line.split_once(" = ")?;
|
||||
@@ -1307,7 +1344,7 @@ mod tests {
|
||||
|
||||
assert_eq!(db.device_keys.len(), 4);
|
||||
assert_eq!(db.processing_keys.len(), 3);
|
||||
assert!(db.host_cert.is_some());
|
||||
assert!(!db.host_certs.is_empty());
|
||||
assert!(db.disc_entries.len() > 170000);
|
||||
|
||||
// Look up Dune: Part Two
|
||||
|
||||
+2
-2
@@ -114,11 +114,11 @@ impl ClipInfo {
|
||||
/// Parse a CLPI file from raw bytes.
|
||||
pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
if data.len() < 40 {
|
||||
return Err(Error::DiscError { detail: "CLPI too short".into() });
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
|
||||
if &data[0..4] != b"HDMV" {
|
||||
return Err(Error::DiscError { detail: "not a CLPI file".into() });
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
|
||||
|
||||
+528
-212
@@ -1,4 +1,4 @@
|
||||
//! Disc structure — scan titles, streams, and sector ranges from a Blu-ray disc.
|
||||
//! Disc structure -- scan titles, streams, and sector ranges from a Blu-ray disc.
|
||||
//!
|
||||
//! This is the high-level API for disc content. The CLI calls this,
|
||||
//! never parses MPLS/CLPI/UDF directly.
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::speed::DriveSpeed;
|
||||
use crate::udf;
|
||||
use crate::mpls;
|
||||
use crate::clpi;
|
||||
@@ -33,10 +35,10 @@ pub struct Disc {
|
||||
/// Number of layers (1 = single, 2 = dual)
|
||||
pub layers: u8,
|
||||
/// Titles sorted by duration (longest first), then playlist name
|
||||
pub titles: Vec<Title>,
|
||||
pub titles: Vec<DiscTitle>,
|
||||
/// Disc region
|
||||
pub region: DiscRegion,
|
||||
/// AACS state — None if disc is unencrypted or keys unavailable
|
||||
/// AACS state -- None if disc is unencrypted or keys unavailable
|
||||
pub aacs: Option<AacsState>,
|
||||
/// Whether this disc requires AACS decryption
|
||||
pub encrypted: bool,
|
||||
@@ -69,17 +71,17 @@ pub enum DiscRegion {
|
||||
/// Blu-ray region codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BdRegion {
|
||||
/// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia)
|
||||
/// Region A/1 -- Americas, East Asia (Japan, Korea, Southeast Asia)
|
||||
A,
|
||||
/// Region B/2 — Europe, Africa, Australia, Middle East
|
||||
/// Region B/2 -- Europe, Africa, Australia, Middle East
|
||||
B,
|
||||
/// Region C/3 — Central/South Asia, China, Russia
|
||||
/// Region C/3 -- Central/South Asia, China, Russia
|
||||
C,
|
||||
}
|
||||
|
||||
/// A title (one MPLS playlist).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Title {
|
||||
pub struct DiscTitle {
|
||||
/// Playlist filename (e.g. "00800.mpls")
|
||||
pub playlist: String,
|
||||
/// Playlist number (e.g. 800)
|
||||
@@ -278,7 +280,20 @@ impl ColorSpace {
|
||||
}
|
||||
}
|
||||
|
||||
impl Title {
|
||||
impl DiscTitle {
|
||||
/// Empty DiscTitle with no streams.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
playlist: String::new(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 0.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Duration formatted as "Xh Ym"
|
||||
pub fn duration_display(&self) -> String {
|
||||
let hrs = (self.duration_secs / 3600.0) as u32;
|
||||
@@ -298,7 +313,16 @@ impl Title {
|
||||
}
|
||||
|
||||
|
||||
// ─── AACS state ─────────────────────────────────────────────────────────────
|
||||
// ─── Encryption ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||
/// Only available when scanning from a real drive, not ISO images.
|
||||
#[derive(Debug)]
|
||||
struct HandshakeResult {
|
||||
volume_id: [u8; 16],
|
||||
read_data_key: Option<[u8; 16]>,
|
||||
error: Option<crate::error::Error>,
|
||||
}
|
||||
|
||||
/// AACS decryption state for a disc.
|
||||
#[derive(Debug)]
|
||||
@@ -309,7 +333,7 @@ pub struct AacsState {
|
||||
pub bus_encryption: bool,
|
||||
/// MKB version from disc (e.g. 68, 77)
|
||||
pub mkb_version: Option<u32>,
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf) — hex string with 0x prefix
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
|
||||
pub disc_hash: String,
|
||||
/// How keys were resolved
|
||||
pub key_source: KeySource,
|
||||
@@ -317,10 +341,12 @@ pub struct AacsState {
|
||||
pub vuk: [u8; 16],
|
||||
/// Decrypted unit keys (CPS unit number, key)
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Read data key for AACS 2.0 bus decryption — None for AACS 1.0
|
||||
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
/// Volume ID (16 bytes) — from SCSI handshake
|
||||
/// Volume ID (16 bytes) -- from SCSI handshake
|
||||
pub volume_id: [u8; 16],
|
||||
/// Handshake error code if authentication failed (None = no HC, or success)
|
||||
pub handshake_error: Option<crate::error::Error>,
|
||||
}
|
||||
|
||||
/// How AACS keys were resolved.
|
||||
@@ -391,72 +417,122 @@ 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: DriveSession,
|
||||
}
|
||||
|
||||
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 = DriveSession::open(Path::new(device))?;
|
||||
session.wait_ready()?;
|
||||
|
||||
// Init (unlock + firmware) -- non-fatal if fails
|
||||
let _ = session.init();
|
||||
let _ = session.probe_disc();
|
||||
|
||||
let opts = if let Some(kp) = keydb_path {
|
||||
ScanOptions::with_keydb(kp)
|
||||
} else {
|
||||
ScanOptions::default()
|
||||
};
|
||||
|
||||
let disc = Disc::scan(&mut session, &opts)?;
|
||||
Ok(Self { disc, session })
|
||||
}
|
||||
|
||||
/// Rip a title to any output stream.
|
||||
///
|
||||
/// Reads sectors from disc, decrypts AACS, handles errors/retries,
|
||||
/// and writes decrypted BD-TS bytes to the output.
|
||||
/// Knows nothing about the output format -- just calls `write_all()`.
|
||||
///
|
||||
pub fn rip(&mut self, title_idx: usize, mut output: impl std::io::Write) -> Result<()> {
|
||||
let mut reader = self.disc.open_title(&mut self.session, title_idx)?;
|
||||
|
||||
loop {
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
output.write_all(batch).map_err(|_| Error::WriteError)?;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => {
|
||||
// ContentReader handles retries internally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Total bytes for a title (for progress tracking).
|
||||
pub fn title_size(&self, title_idx: usize) -> u64 {
|
||||
self.disc.titles.get(title_idx)
|
||||
.map(|t| t.size_bytes)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Disc capacity in GB
|
||||
pub fn capacity_gb(&self) -> f64 {
|
||||
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
/// Scan a disc — parse filesystem, playlists, streams, and set up AACS decryption.
|
||||
/// Scan a disc -- parse filesystem, playlists, streams, and set up AACS decryption.
|
||||
///
|
||||
/// This is the main entry point. After scan(), the Disc is ready:
|
||||
/// - titles are populated with streams
|
||||
/// - AACS keys are derived (if KEYDB available)
|
||||
/// - content can be read and decrypted transparently
|
||||
///
|
||||
/// ```no_run
|
||||
/// use libfreemkv::{DriveSession, Disc};
|
||||
/// use libfreemkv::disc::ScanOptions;
|
||||
/// use std::path::Path;
|
||||
/// Scan a disc. One pipeline, one order:
|
||||
/// 1. Read capacity
|
||||
/// 2. Read UDF filesystem
|
||||
/// 3. Resolve AACS keys (all via UDF, no SCSI commands)
|
||||
/// 4. Parse playlists + streams
|
||||
/// 5. Apply labels
|
||||
///
|
||||
/// let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
/// for title in &disc.titles {
|
||||
/// println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
/// }
|
||||
/// ```
|
||||
/// The session must be open and unlocked (DriveSession::open handles this).
|
||||
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
||||
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
|
||||
// Step 1: Read capacity
|
||||
let capacity = Self::read_capacity(session)?;
|
||||
let handshake = Self::do_handshake(session, opts);
|
||||
Self::scan_with(session, capacity, handshake, opts)
|
||||
}
|
||||
|
||||
// Step 2: Parse UDF filesystem
|
||||
let udf_fs = udf::read_filesystem(session)?;
|
||||
/// Scan a disc image (ISO or any SectorReader). No SCSI, no handshake.
|
||||
/// AACS resolution uses KEYDB VUK lookup only.
|
||||
pub fn scan_image(reader: &mut dyn SectorReader, capacity: u32, opts: &ScanOptions) -> Result<Self> {
|
||||
Self::scan_with(reader, capacity, None, opts)
|
||||
}
|
||||
|
||||
// Step 3: Find and parse MPLS playlists
|
||||
let mut titles = Vec::new();
|
||||
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
|
||||
for entry in &playlist_dir.entries {
|
||||
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
|
||||
if let Ok(mpls_data) = udf_fs.read_file(session, &path) {
|
||||
if let Some(title) = Self::parse_playlist(session, &udf_fs, &entry.name, &mpls_data) {
|
||||
titles.push(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Core scan pipeline — works with any SectorReader.
|
||||
fn scan_with(
|
||||
reader: &mut dyn SectorReader,
|
||||
capacity: u32,
|
||||
handshake: Option<HandshakeResult>,
|
||||
opts: &ScanOptions,
|
||||
) -> Result<Self> {
|
||||
// 1. UDF filesystem
|
||||
let udf_fs = udf::read_filesystem(reader)?;
|
||||
|
||||
// Sort: longest first
|
||||
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Step 4: Read disc title from META/DL/bdmt_eng.xml
|
||||
let meta_title = Self::read_meta_title(session, &udf_fs);
|
||||
|
||||
// Step 5: Enhance streams with disc config file labels (if available)
|
||||
crate::labels::apply(session, &udf_fs, &mut titles);
|
||||
|
||||
// Step 6: Detect AACS encryption
|
||||
// 2. Resolve encryption (AACS, CSS, or none)
|
||||
let encrypted = udf_fs.find_dir("/AACS").is_some()
|
||||
|| udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
|
||||
// Step 7: If encrypted and KEYDB available, authenticate and derive keys
|
||||
let aacs = if encrypted {
|
||||
if let Some(keydb_path) = opts.resolve_keydb() {
|
||||
match Self::setup_aacs(session, &keydb_path) {
|
||||
Ok(state) => Some(state),
|
||||
Err(_) => None, // keys not found, continue without decryption
|
||||
}
|
||||
Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -464,25 +540,34 @@ impl Disc {
|
||||
None
|
||||
};
|
||||
|
||||
// Derive disc format from main title video codec
|
||||
// 3. Playlists
|
||||
let mut titles = Vec::new();
|
||||
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
|
||||
for entry in &playlist_dir.entries {
|
||||
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
|
||||
if let Ok(mpls_data) = udf_fs.read_file(reader, &path) {
|
||||
if let Some(title) = Self::parse_playlist(reader, &udf_fs, &entry.name, &mpls_data) {
|
||||
titles.push(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// 4. Metadata + labels
|
||||
let meta_title = Self::read_meta_title(reader, &udf_fs);
|
||||
crate::labels::apply(reader, &udf_fs, &mut titles);
|
||||
|
||||
// 5. Derive format, layers, region
|
||||
let format = Self::detect_format(&titles);
|
||||
|
||||
// Derive layer count from capacity
|
||||
// BD-25 single layer: up to ~12M sectors (~25GB)
|
||||
// BD-50 dual layer: ~12M-25M sectors (~50GB)
|
||||
// BD-66/100 UHD: 25M+ sectors
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
|
||||
// UHD is always region-free. BD/DVD region parsing TODO.
|
||||
let region = if format == DiscFormat::Uhd {
|
||||
DiscRegion::Free
|
||||
} else {
|
||||
DiscRegion::Free // TODO: parse from index.bdmv
|
||||
};
|
||||
let region = if format == DiscFormat::Uhd { DiscRegion::Free } else { DiscRegion::Free };
|
||||
|
||||
Ok(Disc {
|
||||
volume_id: udf_fs.volume_id.clone(),
|
||||
meta_title: meta_title,
|
||||
meta_title,
|
||||
format,
|
||||
capacity_sectors: capacity,
|
||||
capacity_bytes: capacity as u64 * 2048,
|
||||
@@ -494,101 +579,106 @@ impl Disc {
|
||||
})
|
||||
}
|
||||
|
||||
/// Set up AACS decryption for this disc.
|
||||
/// Call after scan() to enable transparent content decryption.
|
||||
pub fn setup_aacs(
|
||||
session: &mut DriveSession,
|
||||
keydb_path: &std::path::Path,
|
||||
) -> Result<AacsState> {
|
||||
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
|
||||
/// Only available when scanning from a real drive (not ISO images).
|
||||
fn do_handshake(session: &mut DriveSession, opts: &ScanOptions) -> Option<HandshakeResult> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
use crate::aacs::handshake;
|
||||
|
||||
// Load KEYDB
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError {
|
||||
detail: format!("failed to load KEYDB: {}", e),
|
||||
})?;
|
||||
let keydb_path = opts.resolve_keydb()?;
|
||||
let keydb = KeyDb::load(&keydb_path).ok()?;
|
||||
|
||||
// Step 1: Try SCSI handshake for Volume ID + read_data_key
|
||||
// Open a separate transport (AACS auth must happen before raw mode).
|
||||
// If handshake fails (drive doesn't support AACS layer, e.g. raw-mode drives),
|
||||
// fall back to disc-hash-only KEYDB lookup.
|
||||
let device_path = session.device_path().to_string();
|
||||
let mut vid: Option<[u8; 16]> = None;
|
||||
let mut read_data_key: Option<[u8; 16]> = None;
|
||||
|
||||
if !device_path.is_empty() {
|
||||
if let Ok(mut aacs_session) = DriveSession::open_no_unlock(std::path::Path::new(&device_path)) {
|
||||
if let Ok(hc) = keydb.host_cert.as_ref().ok_or(()) {
|
||||
if let Ok(mut auth) = handshake::aacs_authenticate(
|
||||
&mut aacs_session, &hc.private_key, &hc.certificate,
|
||||
) {
|
||||
vid = handshake::read_volume_id(&mut aacs_session, &mut auth).ok();
|
||||
read_data_key = handshake::read_data_keys(&mut aacs_session, &mut auth)
|
||||
.ok().map(|(rdk, _)| rdk);
|
||||
}
|
||||
for hc in &keydb.host_certs {
|
||||
match aacs::handshake::aacs_authenticate(
|
||||
session, &hc.private_key, &hc.certificate,
|
||||
) {
|
||||
Ok(mut auth) => {
|
||||
let volume_id = aacs::handshake::read_volume_id(session, &mut auth)
|
||||
.unwrap_or([0u8; 16]);
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
.ok().map(|(rdk, _)| rdk);
|
||||
return Some(HandshakeResult { volume_id, read_data_key, error: None });
|
||||
}
|
||||
Err(e) => {
|
||||
// Try next host cert
|
||||
return Some(HandshakeResult {
|
||||
volume_id: [0u8; 16],
|
||||
read_data_key: None,
|
||||
error: Some(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Handshake failure is not fatal — we can still resolve via disc hash
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Step 2: Read Unit_Key_RO.inf from disc via UDF (uses the unlocked main session)
|
||||
let udf_fs = udf::read_filesystem(session)?;
|
||||
let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsError {
|
||||
detail: "failed to read Unit_Key_RO.inf from disc".into(),
|
||||
})?;
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
///
|
||||
/// Reads AACS files from UDF (via SectorReader), resolves keys through
|
||||
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
|
||||
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
|
||||
fn resolve_encryption(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorReader,
|
||||
keydb_path: &std::path::Path,
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
// Step 3: Read Content Certificate (optional — for AACS version detection)
|
||||
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string() })?;
|
||||
|
||||
// Read AACS files from disc/image via UDF
|
||||
let uk_ro_data = udf_fs.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
|
||||
let cc_data = udf_fs.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
.ok();
|
||||
|
||||
// Step 4: Resolve keys
|
||||
// If we have VID from handshake, use full 4-path chain.
|
||||
// If no VID (handshake failed), use disc-hash-only KEYDB lookup.
|
||||
let mkb_data = aacs::read_mkb_from_drive(session).ok();
|
||||
let mkb_data = udf_fs.read_file(reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
// Use a zero VID placeholder if handshake failed — resolve_keys
|
||||
// will still work via disc hash (path 1)
|
||||
let vid_for_resolve = vid.unwrap_or([0u8; 16]);
|
||||
// Use handshake volume ID if available, otherwise zeros
|
||||
// (KEYDB VUK lookup by disc hash works without volume ID)
|
||||
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
||||
let read_data_key = handshake.and_then(|h| h.read_data_key);
|
||||
let handshake_error = None;
|
||||
|
||||
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key
|
||||
let resolved = aacs::resolve_keys(
|
||||
&uk_ro_data,
|
||||
cc_data.as_deref(),
|
||||
&vid_for_resolve,
|
||||
&volume_id,
|
||||
&keydb,
|
||||
mkb_data.as_deref(),
|
||||
).ok_or_else(|| Error::AacsError {
|
||||
detail: "failed to resolve AACS keys — disc not in KEYDB".into(),
|
||||
})?;
|
||||
|
||||
let key_source = match resolved.key_source {
|
||||
1 => KeySource::KeyDb,
|
||||
2 => KeySource::KeyDbDerived,
|
||||
3 => KeySource::ProcessingKey,
|
||||
4 => KeySource::DeviceKey,
|
||||
_ => KeySource::KeyDb,
|
||||
};
|
||||
).ok_or_else(|| Error::AacsNoKeys)?;
|
||||
|
||||
Ok(AacsState {
|
||||
version: if resolved.aacs2 { 2 } else { 1 },
|
||||
bus_encryption: resolved.bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
||||
key_source,
|
||||
key_source: match resolved.key_source {
|
||||
1 => KeySource::KeyDb,
|
||||
2 => KeySource::KeyDbDerived,
|
||||
3 => KeySource::ProcessingKey,
|
||||
4 => KeySource::DeviceKey,
|
||||
_ => KeySource::KeyDb,
|
||||
},
|
||||
vuk: resolved.vuk,
|
||||
unit_keys: resolved.unit_keys,
|
||||
read_data_key,
|
||||
volume_id: vid.unwrap_or([0u8; 16]),
|
||||
volume_id,
|
||||
handshake_error,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// Detect disc format from the main title's video streams.
|
||||
fn detect_format(titles: &[Title]) -> DiscFormat {
|
||||
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
|
||||
for title in titles.iter().take(3) {
|
||||
for stream in &title.streams {
|
||||
if let Stream::Video(v) = stream {
|
||||
@@ -610,7 +700,7 @@ impl Disc {
|
||||
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
|
||||
/// Prefers English, falls back to first available language.
|
||||
/// Returns None if META directory is empty or XML has no usable title.
|
||||
fn read_meta_title(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> Option<String> {
|
||||
fn read_meta_title(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Option<String> {
|
||||
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
|
||||
for sub in &meta_dir.entries {
|
||||
if !sub.is_dir { continue; }
|
||||
@@ -625,7 +715,7 @@ impl Disc {
|
||||
|
||||
if let Some(entry) = target {
|
||||
let path = format!("{}/{}", dl_path, entry.name);
|
||||
if let Ok(data) = udf_fs.read_file(session, &path) {
|
||||
if let Ok(data) = udf_fs.read_file(reader, &path) {
|
||||
let xml = String::from_utf8_lossy(&data);
|
||||
if let Some(start) = xml.find("<di:name>") {
|
||||
let s = start + "<di:name>".len();
|
||||
@@ -644,7 +734,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
||||
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let cdb = [crate::scsi::SCSI_READ_CAPACITY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut buf = [0u8; 8];
|
||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
@@ -652,11 +742,11 @@ impl Disc {
|
||||
}
|
||||
|
||||
fn parse_playlist(
|
||||
session: &mut DriveSession,
|
||||
reader: &mut dyn SectorReader,
|
||||
udf_fs: &udf::UdfFs,
|
||||
filename: &str,
|
||||
data: &[u8],
|
||||
) -> Option<Title> {
|
||||
) -> Option<DiscTitle> {
|
||||
let parsed = mpls::parse(data).ok()?;
|
||||
|
||||
// Calculate duration from play items
|
||||
@@ -680,13 +770,23 @@ impl Disc {
|
||||
let mut pkt_count: u32 = 0;
|
||||
|
||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
||||
if let Ok(clpi_data) = udf_fs.read_file(session, &clpi_path) {
|
||||
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
|
||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
||||
pkt_count = clip_info.source_packet_count;
|
||||
total_size += pkt_count as u64 * 192;
|
||||
|
||||
let clip_extents = clip_info.get_extents(play_item.in_time, play_item.out_time);
|
||||
extents.extend(clip_extents);
|
||||
// Get m2ts file start LBA and compute extent from packet count.
|
||||
// BD-ROM m2ts files are contiguous on disc (mastering requirement).
|
||||
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
|
||||
let file_lba = udf_fs.file_start_lba(reader, &m2ts_path).unwrap_or(0);
|
||||
let total_bytes = pkt_count as u64 * 192;
|
||||
let total_sectors = ((total_bytes + 2047) / 2048) as u32;
|
||||
if total_sectors > 0 && file_lba > 0 {
|
||||
extents.push(Extent {
|
||||
start_lba: file_lba,
|
||||
sector_count: total_sectors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,10 +800,12 @@ impl Disc {
|
||||
}
|
||||
|
||||
// Build streams from STN table
|
||||
let streams: Vec<Stream> = parsed.streams.iter().map(|s| {
|
||||
let streams: Vec<Stream> = parsed.streams.iter().filter_map(|s| {
|
||||
// Skip empty/padding entries (coding_type 0x00)
|
||||
if s.coding_type == 0 { return None; }
|
||||
let codec = Codec::from_coding_type(s.coding_type);
|
||||
match s.stream_type {
|
||||
1 | 6 | 7 => Stream::Video(VideoStream {
|
||||
1 | 6 | 7 => Some(Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: format_resolution(s.video_format, s.video_rate),
|
||||
@@ -723,39 +825,44 @@ impl Disc {
|
||||
7 => "Dolby Vision EL".to_string(),
|
||||
_ => String::new(),
|
||||
},
|
||||
}),
|
||||
2 | 5 => Stream::Audio(AudioStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
channels: format_channels(s.audio_format),
|
||||
language: s.language.clone(),
|
||||
sample_rate: format_samplerate(s.audio_rate),
|
||||
secondary: s.stream_type == 5,
|
||||
label: String::new(),
|
||||
}),
|
||||
3 => Stream::Subtitle(SubtitleStream {
|
||||
})),
|
||||
2 | 5 => {
|
||||
// Guard: if coding_type is a subtitle codec (PGS 0x90/0x91),
|
||||
// this is a misaligned stream -- treat as subtitle, not audio
|
||||
if matches!(codec, Codec::Pgs) {
|
||||
Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
language: s.language.clone(),
|
||||
forced: false,
|
||||
}))
|
||||
} else {
|
||||
Some(Stream::Audio(AudioStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
channels: format_channels(s.audio_format),
|
||||
language: s.language.clone(),
|
||||
sample_rate: format_samplerate(s.audio_rate),
|
||||
secondary: s.stream_type == 5,
|
||||
label: String::new(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
3 => Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
language: s.language.clone(),
|
||||
forced: false, // TODO: parse from MPLS stream attributes
|
||||
}),
|
||||
_ => Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: String::new(),
|
||||
frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Unknown,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}),
|
||||
forced: false,
|
||||
})),
|
||||
// Stream type 4 = IG, unknown types -- skip
|
||||
_ => None,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS");
|
||||
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
|
||||
|
||||
Some(Title {
|
||||
Some(DiscTitle {
|
||||
playlist: filename.to_string(),
|
||||
playlist_id,
|
||||
duration_secs,
|
||||
@@ -770,35 +877,51 @@ 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 DriveSession,
|
||||
aacs: Option<&'a AacsState>,
|
||||
extents: Vec<Extent>,
|
||||
current_extent: usize,
|
||||
current_offset: u32, // sectors into current extent
|
||||
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 {
|
||||
/// Open a title for reading. Decryption is automatic — if the disc
|
||||
/// Open a title for reading. Decryption is automatic -- if the disc
|
||||
/// is encrypted and keys were found during scan(), content is decrypted
|
||||
/// on the fly. Unencrypted discs pass through unchanged.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use libfreemkv::{DriveSession, Disc};
|
||||
/// # use libfreemkv::disc::ScanOptions;
|
||||
/// # use std::path::Path;
|
||||
/// # let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
/// let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
/// while let Some(unit) = reader.read_unit().unwrap() {
|
||||
/// // unit is 6144 bytes of decrypted content
|
||||
/// }
|
||||
/// ```
|
||||
pub fn open_title<'a>(&'a self, session: &'a mut DriveSession, title_idx: usize) -> Result<ContentReader<'a>> {
|
||||
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()),
|
||||
})?;
|
||||
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscTitleRange { index: title_idx, count: self.titles.len() })?;
|
||||
|
||||
// Let the drive manage its own read speed after init.
|
||||
// SET_CD_SPEED is only used reactively by the error handler to slow
|
||||
// down on read errors, then let the drive recover.
|
||||
|
||||
// Detect kernel max transfer size for this device
|
||||
let max_batch = detect_max_batch_sectors(session.device_path());
|
||||
|
||||
Ok(ContentReader {
|
||||
session,
|
||||
@@ -807,65 +930,258 @@ impl Disc {
|
||||
current_extent: 0,
|
||||
current_offset: 0,
|
||||
unit_key_idx: 0,
|
||||
read_buf: Vec::with_capacity(max_batch as usize * 2048),
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
batch_sectors: max_batch,
|
||||
max_batch_sectors: max_batch,
|
||||
ok_streak: 0,
|
||||
error_streak: 0,
|
||||
errors: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the maximum transfer size in sectors for a device.
|
||||
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
|
||||
/// For sg devices, resolves the corresponding block device via sysfs.
|
||||
/// Returns a value aligned to 3 sectors (one aligned unit).
|
||||
fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
let dev_name = device_path.rsplit('/').next().unwrap_or("");
|
||||
if dev_name.is_empty() {
|
||||
return DEFAULT_BATCH_SECTORS;
|
||||
}
|
||||
|
||||
// For sg devices, find the corresponding block device name
|
||||
let block_name = if dev_name.starts_with("sg") {
|
||||
let block_dir = format!("/sys/class/scsi_generic/{}/device/block", dev_name);
|
||||
std::fs::read_dir(&block_dir).ok()
|
||||
.and_then(|mut entries| entries.next())
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
} else {
|
||||
Some(dev_name.to_string())
|
||||
};
|
||||
|
||||
if let Some(bname) = block_name {
|
||||
let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", bname);
|
||||
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
|
||||
if let Ok(kb) = content.trim().parse::<u32>() {
|
||||
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
|
||||
let sectors = (kb / 2) as u16;
|
||||
// Align down to 3 (one aligned unit)
|
||||
let aligned = (sectors / 3) * 3;
|
||||
if aligned >= MIN_BATCH_SECTORS {
|
||||
return aligned.min(MAX_BATCH_SECTORS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: safe default well under typical kernel limits
|
||||
DEFAULT_BATCH_SECTORS
|
||||
}
|
||||
|
||||
/// Read strategy constants
|
||||
const MAX_BATCH_SECTORS: u16 = 510; // absolute max (170 aligned units ≈ 1MB)
|
||||
const DEFAULT_BATCH_SECTORS: u16 = 60; // fallback: typical kernel limit (120KB = 60 sectors)
|
||||
const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (error recovery)
|
||||
const RAMP_BATCH_AFTER: u32 = 5; // successes before doubling batch size
|
||||
const RAMP_SPEED_AFTER: u32 = 50; // successes at max batch before restoring speed
|
||||
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>>> {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
// Refill buffer if empty
|
||||
if self.buf_pos >= self.buf_len {
|
||||
if !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);
|
||||
}
|
||||
|
||||
let extent = &self.extents[self.current_extent];
|
||||
let lba = extent.start_lba + self.current_offset;
|
||||
// Decrypt all units in the buffer in-place
|
||||
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
|
||||
if let Some(aacs) = &self.aacs {
|
||||
let uk = aacs.unit_keys.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.unwrap_or([0u8; 16]);
|
||||
let rdk = aacs.read_data_key.as_ref();
|
||||
|
||||
// Read 3 sectors (one aligned unit)
|
||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
for i in 0..3u32 {
|
||||
let offset = (i as usize) * 2048;
|
||||
let mut sector = [0u8; 2048];
|
||||
session_read_sector(self.session, lba + i, &mut sector)?;
|
||||
unit[offset..offset + 2048].copy_from_slice(§or);
|
||||
for i in 0..self.buf_len {
|
||||
let start = i * unit_len;
|
||||
let end = start + unit_len;
|
||||
let unit = &mut self.read_buf[start..end];
|
||||
if crate::aacs::is_unit_encrypted(unit) {
|
||||
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt if needed
|
||||
let total_bytes = self.buf_len * unit_len;
|
||||
self.buf_pos = self.buf_len; // mark fully consumed
|
||||
Ok(Some(&self.read_buf[..total_bytes]))
|
||||
}
|
||||
|
||||
/// Decrypt a single aligned unit in-place if needed.
|
||||
fn decrypt_unit(&self, unit: &mut [u8]) {
|
||||
if let Some(aacs) = &self.aacs {
|
||||
if crate::aacs::is_unit_encrypted(&unit) {
|
||||
if crate::aacs::is_unit_encrypted(unit) {
|
||||
let uk = aacs.unit_keys.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.unwrap_or([0u8; 16]);
|
||||
|
||||
crate::aacs::decrypt_unit_full(
|
||||
&mut unit,
|
||||
unit,
|
||||
&uk,
|
||||
aacs.read_data_key.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance position
|
||||
self.current_offset += 3;
|
||||
if self.current_offset >= extent.sector_count {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
|
||||
Ok(Some(unit))
|
||||
}
|
||||
}
|
||||
|
||||
fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048]) -> Result<()> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00, 0x00, 0x01, 0x00,
|
||||
];
|
||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 10_000)?;
|
||||
Ok(())
|
||||
/// Read sectors via standard READ(10) 0x00.
|
||||
/// calibration primers. Standard reads are faster on most drives.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<()> {
|
||||
self.session.read_content(lba, count, &mut self.read_buf)?;
|
||||
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 - 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 ────────────────────────────────────────────────────────
|
||||
|
||||
+147
-118
@@ -1,69 +1,48 @@
|
||||
//! Drive session — open, identify, unlock, and read from optical drives.
|
||||
//! Drive session — open, identify, and read from optical drives.
|
||||
//!
|
||||
//! `DriveSession` is the entry point for all drive interaction. It handles
|
||||
//! device identification, profile matching, platform-specific unlock, and
|
||||
//! provides both raw sector reads and standard SCSI command execution.
|
||||
//!
|
||||
//! Two open modes:
|
||||
//! - `open()` — identify + unlock. Ready for reading immediately.
|
||||
//! - `open_no_unlock()` — identify only. Used for AACS authentication
|
||||
//! which must happen before the drive enters raw mode.
|
||||
//! Three-step open:
|
||||
//! 1. `open()` — open device, identify drive. Always OEM.
|
||||
//! 2. `wait_ready()` — wait for disc to spin up. Call before reading.
|
||||
//! 3. `init()` — activate custom firmware. Removes riplock.
|
||||
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
|
||||
|
||||
use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::identity::DriveId;
|
||||
use crate::profile::{self, DriveProfile, Chipset};
|
||||
use crate::platform::{Platform, DriveStatus};
|
||||
use crate::profile::{self, DriveProfile};
|
||||
use crate::platform::PlatformDriver;
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
|
||||
/// A drive session with identification, platform, and SCSI transport.
|
||||
///
|
||||
/// Created via `DriveSession::open()` or `DriveSession::open_no_unlock()`.
|
||||
/// All disc reading goes through this struct.
|
||||
pub struct DriveSession {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
platform: Box<dyn Platform>,
|
||||
pub profile: DriveProfile,
|
||||
driver: Option<Box<dyn PlatformDriver>>,
|
||||
pub profile: Option<DriveProfile>,
|
||||
pub platform: Option<profile::Platform>,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl DriveSession {
|
||||
/// Open a drive, identify it, match a profile, and unlock for raw reads.
|
||||
///
|
||||
/// This is the standard entry point. After `open()`, the drive is ready
|
||||
/// for sector reads, disc scanning, and content extraction.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let mut session = Self::open_no_unlock(device)?;
|
||||
session.wait_ready()?;
|
||||
let _ = session.unlock(); // silently ignore — unencrypted discs don't need it
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Open a drive WITHOUT unlocking.
|
||||
///
|
||||
/// Used when AACS authentication must happen before raw mode.
|
||||
/// The AACS SCSI handshake requires the drive's standard firmware
|
||||
/// state — unlocking puts the drive in vendor-specific raw mode
|
||||
/// which disables the AACS layer.
|
||||
pub fn open_no_unlock(device: &Path) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let profiles = profile::load_bundled()?;
|
||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
||||
|
||||
let profile = profile::find_by_drive_id(&profiles, &drive_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
product_revision: drive_id.product_revision.trim().to_string(),
|
||||
})?;
|
||||
|
||||
let platform = create_platform(&profile, &drive_id)?;
|
||||
let m = profile::find_by_drive_id(&profiles, &drive_id);
|
||||
let (driver, platform, profile) = match m {
|
||||
Some(m) => (
|
||||
create_driver(m.platform, &m.profile).ok(),
|
||||
Some(m.platform),
|
||||
Some(m.profile),
|
||||
),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
driver,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
@@ -71,25 +50,13 @@ impl DriveSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// Open with an explicit profile, skipping auto-detection.
|
||||
pub fn open_with_profile(device: &Path, profile: DriveProfile) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
||||
let platform = create_platform(&profile, &drive_id)?;
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
device_path: device.to_string_lossy().to_string(),
|
||||
})
|
||||
/// Whether this drive has a known profile (unlock parameters available).
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
}
|
||||
|
||||
/// Wait for the drive to become ready (disc spun up).
|
||||
/// Polls TEST UNIT READY up to 30 seconds.
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // TEST UNIT READY
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
for _ in 0..60 {
|
||||
let mut buf = [0u8; 0];
|
||||
if self.scsi.as_mut().execute(
|
||||
@@ -104,100 +71,162 @@ impl DriveSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// Device path this session was opened on.
|
||||
pub fn platform_name(&self) -> &str {
|
||||
match self.platform {
|
||||
Some(ref p) => p.name(),
|
||||
None => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn device_path(&self) -> &str {
|
||||
&self.device_path
|
||||
}
|
||||
|
||||
/// Activate raw disc access mode (vendor-specific unlock).
|
||||
pub fn unlock(&mut self) -> Result<()> {
|
||||
self.platform.unlock(self.scsi.as_mut())
|
||||
/// Initialize drive — unlock + firmware upload.
|
||||
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
|
||||
pub fn init(&mut self) -> Result<()> {
|
||||
match self.driver {
|
||||
Some(ref mut d) => d.init(self.scsi.as_mut()),
|
||||
None => Err(Error::UnsupportedDrive {
|
||||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||||
product_id: self.drive_id.product_id.trim().to_string(),
|
||||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if raw disc access mode is active.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.platform.is_unlocked()
|
||||
/// Probe disc surface so the drive firmware learns optimal read speeds
|
||||
/// per region. After this the host reads at max speed and the drive
|
||||
/// manages zones internally.
|
||||
pub fn probe_disc(&mut self) -> Result<()> {
|
||||
match self.driver {
|
||||
Some(ref mut d) => d.probe_disc(self.scsi.as_mut()),
|
||||
None => Err(Error::UnsupportedDrive {
|
||||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||||
product_id: self.drive_id.product_id.trim().to_string(),
|
||||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read drive status and feature flags.
|
||||
pub fn status(&mut self) -> Result<DriveStatus> {
|
||||
self.platform.status(self.scsi.as_mut())
|
||||
pub fn is_ready(&self) -> bool {
|
||||
match self.driver {
|
||||
Some(ref d) => d.is_ready(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read drive configuration block.
|
||||
pub fn read_config(&mut self) -> Result<Vec<u8>> {
|
||||
self.platform.read_config(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// Read hardware register.
|
||||
pub fn read_register(&mut self, index: u8) -> Result<[u8; 16]> {
|
||||
self.platform.read_register(self.scsi.as_mut(), index)
|
||||
}
|
||||
|
||||
/// Calibrate read speed for the current disc.
|
||||
pub fn calibrate(&mut self) -> Result<()> {
|
||||
self.platform.calibrate(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// Read raw disc sectors via platform-specific command.
|
||||
pub fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.platform.read_sectors(self.scsi.as_mut(), lba, count, buf)
|
||||
}
|
||||
|
||||
/// Platform-specific probe command.
|
||||
pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
|
||||
self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length)
|
||||
}
|
||||
|
||||
/// Standard SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI).
|
||||
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
0x00, (count >> 8) as u8, count as u8, 0x00,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// Eject the disc tray.
|
||||
///
|
||||
/// Sends PREVENT ALLOW MEDIUM REMOVAL (allow) first to release any
|
||||
/// locks, then START STOP UNIT with LoEj=1 to open the tray.
|
||||
pub fn read_content(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00, (count >> 8) as u8, count as u8, 0x00,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
pub fn set_speed(&mut self, speed_kbs: u16) {
|
||||
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
|
||||
}
|
||||
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
// PREVENT ALLOW MEDIUM REMOVAL: allow removal
|
||||
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(&allow_cdb, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
|
||||
// START STOP UNIT: LoEj=1, Start=0
|
||||
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
|
||||
self.scsi.as_mut().execute(&eject_cdb, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
|
||||
pub fn scsi_execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: crate::scsi::DataDirection,
|
||||
buf: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
&mut self, cdb: &[u8], direction: crate::scsi::DataDirection,
|
||||
buf: &mut [u8], timeout_ms: u32,
|
||||
) -> Result<crate::scsi::ScsiResult> {
|
||||
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the platform-specific driver for a given chipset.
|
||||
fn create_platform(profile: &DriveProfile, drive_id: &DriveId) -> Result<Box<dyn Platform>> {
|
||||
match profile.chipset {
|
||||
Chipset::MediaTek => Ok(Box::new(Mt1959::new(profile.clone()))),
|
||||
Chipset::Renesas => Err(Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
impl SectorReader for DriveSession {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.read_disc(lba, count, buf)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/sg{}", i);
|
||||
if !std::path::Path::new(&path).exists() { continue; }
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
// Include all optical drives (peripheral device type 0x05)
|
||||
if id.raw_inquiry.len() > 0 && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drives
|
||||
}
|
||||
|
||||
pub fn find_drive() -> Option<String> {
|
||||
find_drives().into_iter().next().map(|(path, _)| path)
|
||||
}
|
||||
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
if path.contains("/sg") {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
return Ok((path.to_string(), None));
|
||||
}
|
||||
if path.contains("/sr") {
|
||||
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
|
||||
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
|
||||
drop(sr_transport);
|
||||
for (sg_path, sg_id) in find_drives() {
|
||||
if sg_id.vendor_id == sr_id.vendor_id
|
||||
&& sg_id.product_id == sr_id.product_id
|
||||
&& sg_id.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning = format!(
|
||||
"{} is a block device (sr) — using {} (sg) for raw access", path, sg_path
|
||||
);
|
||||
return Ok((sg_path, Some(warning)));
|
||||
}
|
||||
}
|
||||
return Ok((path.to_string(), Some(format!(
|
||||
"{} is a block device (sr) — no matching sg device found", path
|
||||
))));
|
||||
}
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
Ok((path.to_string(), None))
|
||||
}
|
||||
|
||||
fn create_driver(platform: profile::Platform, profile: &DriveProfile) -> Result<Box<dyn PlatformDriver>> {
|
||||
match platform {
|
||||
profile::Platform::Mt1959A => Ok(Box::new(Mt1959::new(profile.clone(), false))),
|
||||
profile::Platform::Mt1959B => Ok(Box::new(Mt1959::new(profile.clone(), true))),
|
||||
profile::Platform::Renesas => Err(Error::UnsupportedDrive {
|
||||
vendor_id: profile.identity.vendor_id.trim().to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: "Renesas not yet implemented".to_string(),
|
||||
}),
|
||||
}
|
||||
|
||||
+144
-40
@@ -1,8 +1,7 @@
|
||||
//! Error types for libfreemkv.
|
||||
//!
|
||||
//! Every error carries a numeric code for programmatic handling.
|
||||
//! No user-facing English text — applications format their own messages.
|
||||
//! This keeps the library locale-independent and testable.
|
||||
//! Every error is a code with structured data. No English text.
|
||||
//! Applications map codes to localized messages.
|
||||
//!
|
||||
//! # Error Code Ranges
|
||||
//!
|
||||
@@ -15,8 +14,10 @@
|
||||
//! | E5xxx | I/O errors |
|
||||
//! | E6xxx | Disc format errors |
|
||||
//! | E7xxx | AACS errors |
|
||||
//! | E8xxx | Keydb errors |
|
||||
//! | E9xxx | Mux errors |
|
||||
|
||||
// ── Error codes (single source of truth) ────────────────────────────────────
|
||||
// ── Error codes ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
|
||||
pub const E_DEVICE_PERMISSION: u16 = 1001;
|
||||
@@ -30,91 +31,194 @@ pub const E_NOT_CALIBRATED: u16 = 3003;
|
||||
pub const E_SCSI_ERROR: u16 = 4000;
|
||||
pub const E_SCSI_TIMEOUT: u16 = 4001;
|
||||
pub const E_IO_ERROR: u16 = 5000;
|
||||
pub const E_DISC_ERROR: u16 = 6000;
|
||||
pub const E_AACS_ERROR: u16 = 7000;
|
||||
pub const E_WRITE_ERROR: u16 = 5001;
|
||||
// Disc format (6xxx)
|
||||
pub const E_DISC_READ: u16 = 6000;
|
||||
pub const E_MPLS_PARSE: u16 = 6001;
|
||||
pub const E_CLPI_PARSE: u16 = 6002;
|
||||
pub const E_UDF_NOT_FOUND: u16 = 6003;
|
||||
pub const E_DISC_NO_TITLES: u16 = 6004;
|
||||
pub const E_DISC_TITLE_RANGE: u16 = 6005;
|
||||
pub const E_DISC_NO_EXTENTS: u16 = 6006;
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
pub const E_AACS_CERT_SHORT: u16 = 7001;
|
||||
pub const E_AACS_AGID_ALLOC: u16 = 7002;
|
||||
pub const E_AACS_CERT_REJECTED: u16 = 7003;
|
||||
pub const E_AACS_CERT_READ: u16 = 7004;
|
||||
pub const E_AACS_CERT_VERIFY: u16 = 7005;
|
||||
pub const E_AACS_KEY_READ: u16 = 7006;
|
||||
pub const E_AACS_KEY_REJECTED: u16 = 7007;
|
||||
pub const E_AACS_KEY_VERIFY: u16 = 7008;
|
||||
pub const E_AACS_VID_READ: u16 = 7009;
|
||||
pub const E_AACS_VID_MAC: u16 = 7010;
|
||||
pub const E_AACS_DATA_KEY: u16 = 7011;
|
||||
pub const E_AACS_VUK_DERIVE: u16 = 7012;
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
pub const E_KEYDB_HTTP: u16 = 8001;
|
||||
pub const E_KEYDB_INVALID: u16 = 8002;
|
||||
pub const E_KEYDB_WRITE: u16 = 8003;
|
||||
pub const E_KEYDB_PARSE: u16 = 8004;
|
||||
pub const E_KEYDB_LOAD: u16 = 8005;
|
||||
// Mux (9xxx)
|
||||
pub const E_MUX_LOOKAHEAD: u16 = 9000;
|
||||
pub const E_MUX_WRITE: u16 = 9001;
|
||||
|
||||
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Structured error with numeric code and context data.
|
||||
/// Structured error with numeric code and context data. No English text.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
// Device
|
||||
DeviceNotFound { path: String },
|
||||
DevicePermission { path: String },
|
||||
|
||||
// Profile
|
||||
UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String },
|
||||
ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String },
|
||||
ProfileParse { detail: String },
|
||||
UnlockFailed { detail: String },
|
||||
ProfileParse,
|
||||
|
||||
// Unlock
|
||||
UnlockFailed,
|
||||
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
||||
NotUnlocked,
|
||||
NotCalibrated,
|
||||
|
||||
// SCSI
|
||||
ScsiError { opcode: u8, status: u8, sense_key: u8 },
|
||||
ScsiTimeout { opcode: u8 },
|
||||
|
||||
// I/O
|
||||
IoError { source: std::io::Error },
|
||||
DiscError { detail: String },
|
||||
AacsError { detail: String },
|
||||
WriteError,
|
||||
|
||||
// Disc format
|
||||
DiscRead { sector: u64 },
|
||||
MplsParse,
|
||||
ClpiParse,
|
||||
UdfNotFound { path: String },
|
||||
DiscNoTitles,
|
||||
DiscTitleRange { index: usize, count: usize },
|
||||
DiscNoExtents,
|
||||
|
||||
// AACS
|
||||
AacsNoKeys,
|
||||
AacsCertShort,
|
||||
AacsAgidAlloc,
|
||||
AacsCertRejected,
|
||||
AacsCertRead,
|
||||
AacsCertVerify,
|
||||
AacsKeyRead,
|
||||
AacsKeyRejected,
|
||||
AacsKeyVerify,
|
||||
AacsVidRead,
|
||||
AacsVidMac,
|
||||
AacsDataKey,
|
||||
AacsVukDerive,
|
||||
|
||||
// Keydb
|
||||
KeydbConnect { host: String },
|
||||
KeydbHttp { status: u16 },
|
||||
KeydbInvalid,
|
||||
KeydbWrite { path: String },
|
||||
KeydbParse,
|
||||
KeydbLoad { path: String },
|
||||
|
||||
// Mux
|
||||
MuxLookahead,
|
||||
MuxWrite,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Numeric error code.
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
|
||||
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
|
||||
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
|
||||
Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND,
|
||||
Error::ProfileParse { .. } => E_PROFILE_PARSE,
|
||||
Error::UnlockFailed { .. } => E_UNLOCK_FAILED,
|
||||
Error::ProfileParse => E_PROFILE_PARSE,
|
||||
Error::UnlockFailed => E_UNLOCK_FAILED,
|
||||
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
||||
Error::NotUnlocked => E_NOT_UNLOCKED,
|
||||
Error::NotCalibrated => E_NOT_CALIBRATED,
|
||||
Error::ScsiError { .. } => E_SCSI_ERROR,
|
||||
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
|
||||
Error::IoError { .. } => E_IO_ERROR,
|
||||
Error::DiscError { .. } => E_DISC_ERROR,
|
||||
Error::AacsError { .. } => E_AACS_ERROR,
|
||||
Error::WriteError => E_WRITE_ERROR,
|
||||
Error::DiscRead { .. } => E_DISC_READ,
|
||||
Error::MplsParse => E_MPLS_PARSE,
|
||||
Error::ClpiParse => E_CLPI_PARSE,
|
||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||
Error::DiscNoTitles => E_DISC_NO_TITLES,
|
||||
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
||||
Error::DiscNoExtents => E_DISC_NO_EXTENTS,
|
||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
||||
Error::AacsCertRejected => E_AACS_CERT_REJECTED,
|
||||
Error::AacsCertRead => E_AACS_CERT_READ,
|
||||
Error::AacsCertVerify => E_AACS_CERT_VERIFY,
|
||||
Error::AacsKeyRead => E_AACS_KEY_READ,
|
||||
Error::AacsKeyRejected => E_AACS_KEY_REJECTED,
|
||||
Error::AacsKeyVerify => E_AACS_KEY_VERIFY,
|
||||
Error::AacsVidRead => E_AACS_VID_READ,
|
||||
Error::AacsVidMac => E_AACS_VID_MAC,
|
||||
Error::AacsDataKey => E_AACS_DATA_KEY,
|
||||
Error::AacsVukDerive => E_AACS_VUK_DERIVE,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
|
||||
Error::KeydbParse => E_KEYDB_PARSE,
|
||||
Error::KeydbLoad { .. } => E_KEYDB_LOAD,
|
||||
Error::MuxLookahead => E_MUX_LOOKAHEAD,
|
||||
Error::MuxWrite => E_MUX_WRITE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Display format: "E{code}: {context}" — terse, for logs.
|
||||
/// Applications should format their own user-facing messages using code() and fields.
|
||||
/// Display: "E{code}" with structured data. No English words.
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::DeviceNotFound { path } =>
|
||||
write!(f, "E{}: {}", E_DEVICE_NOT_FOUND, path),
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DevicePermission { path } =>
|
||||
write!(f, "E{}: {}", E_DEVICE_PERMISSION, path),
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::UnsupportedDrive { vendor_id, product_id, product_revision } =>
|
||||
write!(f, "E{}: {} {} {}", E_UNSUPPORTED_DRIVE,
|
||||
write!(f, "E{}: {} {} {}", self.code(),
|
||||
vendor_id.trim(), product_id.trim(), product_revision.trim()),
|
||||
Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } =>
|
||||
write!(f, "E{}: {} {} {}", E_PROFILE_NOT_FOUND,
|
||||
write!(f, "E{}: {} {} {}", self.code(),
|
||||
vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
|
||||
Error::ProfileParse { detail } =>
|
||||
write!(f, "E{}: {}", E_PROFILE_PARSE, detail),
|
||||
Error::UnlockFailed { detail } =>
|
||||
write!(f, "E{}: {}", E_UNLOCK_FAILED, detail),
|
||||
Error::SignatureMismatch { expected, got } =>
|
||||
write!(f, "E{}: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
|
||||
E_SIGNATURE_MISMATCH,
|
||||
write!(f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
|
||||
self.code(),
|
||||
expected[0], expected[1], expected[2], expected[3],
|
||||
got[0], got[1], got[2], got[3]),
|
||||
Error::NotUnlocked =>
|
||||
write!(f, "E{}", E_NOT_UNLOCKED),
|
||||
Error::NotCalibrated =>
|
||||
write!(f, "E{}", E_NOT_CALIBRATED),
|
||||
Error::ScsiError { opcode, status, sense_key } =>
|
||||
write!(f, "E{}: opcode=0x{:02x} status=0x{:02x} sense=0x{:02x}",
|
||||
E_SCSI_ERROR, opcode, status, sense_key),
|
||||
write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key),
|
||||
Error::ScsiTimeout { opcode } =>
|
||||
write!(f, "E{}: opcode=0x{:02x}", E_SCSI_TIMEOUT, opcode),
|
||||
write!(f, "E{}: 0x{:02x}", self.code(), opcode),
|
||||
Error::IoError { source } =>
|
||||
write!(f, "E{}: {}", E_IO_ERROR, source),
|
||||
Error::DiscError { detail } =>
|
||||
write!(f, "E{}: {}", E_DISC_ERROR, detail),
|
||||
Error::AacsError { detail } =>
|
||||
write!(f, "E{}: {}", E_AACS_ERROR, detail),
|
||||
write!(f, "E{}: {}", self.code(), source),
|
||||
Error::DiscRead { sector } =>
|
||||
write!(f, "E{}: {}", self.code(), sector),
|
||||
Error::UdfNotFound { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DiscTitleRange { index, count } =>
|
||||
write!(f, "E{}: {}/{}", self.code(), index, count),
|
||||
Error::KeydbConnect { host } =>
|
||||
write!(f, "E{}: {}", self.code(), host),
|
||||
Error::KeydbHttp { status } =>
|
||||
write!(f, "E{}: {}", self.code(), status),
|
||||
Error::KeydbWrite { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::KeydbLoad { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
// Simple codes — no extra data
|
||||
_ => write!(f, "E{}", self.code()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Event system for progress and status reporting.
|
||||
//!
|
||||
//! The lib fires events during operations like rip().
|
||||
//! The app registers a callback to receive them.
|
||||
//! No display logic, no English text — just data.
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! disc.rip(&mut session, 0, output, |event| {
|
||||
//! match event.kind {
|
||||
//! EventKind::BytesRead { bytes, total } => update_progress(bytes, total),
|
||||
//! EventKind::ReadError { sector, .. } => log_error(sector),
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! });
|
||||
//! ```
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// An event fired by the lib during operations.
|
||||
#[derive(Debug)]
|
||||
pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// Types of events the lib can fire.
|
||||
#[derive(Debug)]
|
||||
pub enum EventKind {
|
||||
/// Bytes successfully read and written to output.
|
||||
BytesRead {
|
||||
/// Bytes written so far.
|
||||
bytes: u64,
|
||||
/// Total bytes expected (0 if unknown).
|
||||
total: u64,
|
||||
},
|
||||
|
||||
/// A read error occurred. The lib will retry automatically.
|
||||
ReadError {
|
||||
/// Sector that failed.
|
||||
sector: u64,
|
||||
/// Error code.
|
||||
error: Error,
|
||||
},
|
||||
|
||||
/// Retrying a failed read.
|
||||
Retry {
|
||||
/// Current attempt number (1-based).
|
||||
attempt: u32,
|
||||
},
|
||||
|
||||
/// Drive speed changed (error recovery or restoration).
|
||||
SpeedChange {
|
||||
/// New speed in KB/s (0xFFFF = max).
|
||||
speed_kbs: u16,
|
||||
},
|
||||
|
||||
/// Starting a new disc extent.
|
||||
ExtentStart {
|
||||
/// Extent index (0-based).
|
||||
index: usize,
|
||||
/// First sector of extent.
|
||||
start_sector: u64,
|
||||
/// Number of sectors in extent.
|
||||
sector_count: u64,
|
||||
},
|
||||
|
||||
/// Operation complete.
|
||||
Complete {
|
||||
/// Total bytes written.
|
||||
bytes: u64,
|
||||
/// Total read errors encountered.
|
||||
errors: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A no-op event handler. Ignores all events.
|
||||
pub fn ignore(_event: Event) {}
|
||||
@@ -44,6 +44,9 @@ pub struct DriveId {
|
||||
|
||||
/// Raw 96-byte INQUIRY response for additional parsing if needed.
|
||||
pub raw_inquiry: Vec<u8>,
|
||||
|
||||
/// Raw GET CONFIGURATION Feature 010Ch response bytes.
|
||||
pub raw_gc_010c: Vec<u8>,
|
||||
}
|
||||
|
||||
impl DriveId {
|
||||
@@ -84,6 +87,7 @@ impl DriveId {
|
||||
firmware_date,
|
||||
serial_number,
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
raw_gc_010c: gc[..result.bytes_transferred].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,6 +102,7 @@ impl DriveId {
|
||||
firmware_date: firmware_date.to_string(),
|
||||
serial_number: String::new(),
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
raw_gc_010c: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-342
@@ -1,342 +0,0 @@
|
||||
//! BD-J JAR parser — extract audio/subtitle track labels from disc menus.
|
||||
//!
|
||||
//! Blu-ray discs with BD-J menus store the menu application as Java JAR files
|
||||
//! in BDMV/JAR/. These contain .class files with string constants that label
|
||||
//! audio and subtitle tracks.
|
||||
//!
|
||||
//! Multiple label formats exist across studios:
|
||||
//! - Label format: "eng_MLP_", "fra_AudioStream3" (Warner UHD, e.g. Barbie, Dune)
|
||||
//! - TextField format: "TextField,Audio1,English Dolby Atmos,..." (A24/Lionsgate, e.g. Civil War)
|
||||
//!
|
||||
//! Labels match to streams by STN index — the Nth audio label in the JAR
|
||||
//! corresponds to the Nth audio stream in the MPLS STN table. This is defined
|
||||
//! by the BD-J API where stream numbers = STN indices (1-based).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// Labels extracted from a BD-J JAR file.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct JarLabels {
|
||||
/// Audio track labels in STN order
|
||||
pub audio: Vec<TrackLabel>,
|
||||
/// Subtitle track labels in STN order
|
||||
pub subtitle: Vec<TrackLabel>,
|
||||
/// Playlist purpose labels (MAIN_FEATURE, FORCED_TRAILER, etc.)
|
||||
pub playlists: Vec<String>,
|
||||
}
|
||||
|
||||
/// A parsed track label from the JAR.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackLabel {
|
||||
/// Human-readable description (e.g. "English Dolby Atmos", "TrueHD", "Descriptive Audio (US)")
|
||||
pub description: String,
|
||||
/// ISO 639-2 language code if available (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Codec hint if available (e.g. "MLP"=TrueHD, "AC3"=DD, "DTS")
|
||||
pub codec_hint: String,
|
||||
/// The raw string from the class file
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
/// Extract track labels from a JAR file (raw ZIP bytes).
|
||||
///
|
||||
/// Tries multiple format parsers. Returns None if no labels found.
|
||||
pub fn extract_labels(jar_data: &[u8]) -> Option<JarLabels> {
|
||||
let strings = extract_all_jar_strings(jar_data)?;
|
||||
|
||||
// Try each format parser in order
|
||||
try_textfield_format(&strings)
|
||||
.or_else(|| try_label_format(&strings))
|
||||
.or_else(|| try_playlist_only(&strings))
|
||||
}
|
||||
|
||||
// ── Format 1: TextField (A24/Lionsgate) ─────────────────────────────────────
|
||||
//
|
||||
// Pattern: "TextField,Audio{N},{description},..."
|
||||
// "TextField,Subtitle{N},{description},..."
|
||||
// Found in: Civil War, and similar A24/Lionsgate discs
|
||||
|
||||
fn try_textfield_format(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut audio: Vec<(u32, String, String)> = Vec::new(); // (index, description, raw)
|
||||
let mut subtitle: Vec<(u32, String, String)> = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
||||
for s in strings {
|
||||
if s.starts_with("TextField,Audio") {
|
||||
// TextField,Audio{N},{description},...
|
||||
let parts: Vec<&str> = s.splitn(4, ',').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key = parts[1]; // "Audio1", "Audio2", etc.
|
||||
let desc = parts[2].to_string();
|
||||
if let Some(num_str) = key.strip_prefix("Audio") {
|
||||
if let Ok(idx) = num_str.parse::<u32>() {
|
||||
if !desc.is_empty() {
|
||||
audio.push((idx, desc, s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if s.starts_with("TextField,Subtitle") {
|
||||
let parts: Vec<&str> = s.splitn(4, ',').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key = parts[1];
|
||||
let desc = parts[2].to_string();
|
||||
if let Some(num_str) = key.strip_prefix("Subtitle") {
|
||||
if let Ok(idx) = num_str.parse::<u32>() {
|
||||
if !desc.eq_ignore_ascii_case("None") && !desc.is_empty() {
|
||||
subtitle.push((idx, desc, s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
|
||||
if audio.is_empty() && subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sort by index to ensure STN order
|
||||
audio.sort_by_key(|a| a.0);
|
||||
subtitle.sort_by_key(|s| s.0);
|
||||
|
||||
Some(JarLabels {
|
||||
audio: audio.into_iter().map(|(_, desc, raw)| TrackLabel {
|
||||
description: desc, language: String::new(), codec_hint: String::new(), raw,
|
||||
}).collect(),
|
||||
subtitle: subtitle.into_iter().map(|(_, desc, raw)| TrackLabel {
|
||||
description: desc, language: String::new(), codec_hint: String::new(), raw,
|
||||
}).collect(),
|
||||
playlists,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Format 2: Label strings (Warner UHD) ────────────────────────────────────
|
||||
//
|
||||
// Pattern: "eng_MLP_", "fra_AudioStream3", "dan_PGStream4"
|
||||
// Found in: Barbie, Dune Part Two, and similar Warner UHD discs
|
||||
|
||||
fn try_label_format(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut audio = Vec::new();
|
||||
let mut subtitle = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
||||
for s in strings {
|
||||
if let Some(label) = parse_label_string(s) {
|
||||
if label.is_audio && !audio.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
audio.push(TrackLabel {
|
||||
description: label.description, language: label.language,
|
||||
codec_hint: label.codec_hint, raw: label.raw,
|
||||
});
|
||||
} else if label.is_subtitle && !subtitle.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
subtitle.push(TrackLabel {
|
||||
description: label.description, language: label.language,
|
||||
codec_hint: label.codec_hint, raw: label.raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
|
||||
if audio.is_empty() && subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(JarLabels { audio, subtitle, playlists })
|
||||
}
|
||||
|
||||
struct ParsedLabel {
|
||||
description: String,
|
||||
language: String,
|
||||
codec_hint: String,
|
||||
raw: String,
|
||||
is_audio: bool,
|
||||
is_subtitle: bool,
|
||||
}
|
||||
|
||||
fn parse_label_string(s: &str) -> Option<ParsedLabel> {
|
||||
let clean = s.trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.splitn(3, '_').collect();
|
||||
if parts.len() < 2 { return None; }
|
||||
|
||||
let language = parts[0];
|
||||
if language.len() < 2 || language.len() > 3 || !language.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hint = parts[1];
|
||||
let variant = if parts.len() > 2 { parts[2] } else { "" };
|
||||
|
||||
// Only set description when the JAR adds info beyond what MPLS provides.
|
||||
// Codec (MLP, AC3, DTS) is already known from the stream. Don't make up labels.
|
||||
let (description, codec_hint, is_audio, is_subtitle) = match hint {
|
||||
"MLP" => (String::new(), "MLP".to_string(), true, false),
|
||||
"AC3" => (String::new(), "AC3".to_string(), true, false),
|
||||
"DTS" => (String::new(), "DTS".to_string(), true, false),
|
||||
"LPCM" => (String::new(), "LPCM".to_string(), true, false),
|
||||
"ADES" => {
|
||||
// Descriptive Audio — real value-add, can't be derived from MPLS
|
||||
let d = if variant.is_empty() { "Descriptive Audio".to_string() }
|
||||
else { format!("Descriptive Audio ({})", variant) };
|
||||
(d, "ADES".to_string(), true, false)
|
||||
}
|
||||
h if h.starts_with("AudioStream") => (String::new(), String::new(), true, false),
|
||||
h if h.starts_with("PGStream") => (String::new(), String::new(), false, true),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ParsedLabel {
|
||||
description, language: language.to_string(), codec_hint,
|
||||
raw: s.to_string(), is_audio, is_subtitle,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Format 3: Playlist-only ─────────────────────────────────────────────────
|
||||
//
|
||||
// Some JARs have no track labels but do have playlist purpose markers.
|
||||
|
||||
fn try_playlist_only(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut playlists = Vec::new();
|
||||
for s in strings {
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
if playlists.is_empty() { return None; }
|
||||
Some(JarLabels { audio: Vec::new(), subtitle: Vec::new(), playlists })
|
||||
}
|
||||
|
||||
// ── Common helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_playlist_label(s: &str, playlists: &mut Vec<String>) {
|
||||
if matches!(s.as_ref(),
|
||||
"MAIN_FEATURE" | "MAIN_FEATURE_INTRO" |
|
||||
"FORCED_TRAILER" | "INTL_FORCED_TRAILER" |
|
||||
"commentary_extras" | "extras"
|
||||
) {
|
||||
if !playlists.iter().any(|p| p == s) {
|
||||
playlists.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract all UTF-8 string constants from all .class files in a JAR.
|
||||
fn extract_all_jar_strings(jar_data: &[u8]) -> Option<Vec<String>> {
|
||||
let cursor = std::io::Cursor::new(jar_data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut all_strings = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).ok()?;
|
||||
if !file.name().ends_with(".class") { continue; }
|
||||
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).ok()?;
|
||||
|
||||
extract_class_strings(&data, &mut all_strings);
|
||||
}
|
||||
|
||||
if all_strings.is_empty() { return None; }
|
||||
Some(all_strings)
|
||||
}
|
||||
|
||||
/// Extract UTF-8 string constants from a Java .class file's constant pool.
|
||||
fn extract_class_strings(data: &[u8], out: &mut Vec<String>) {
|
||||
if data.len() < 10 || &data[0..4] != &[0xCA, 0xFE, 0xBA, 0xBE] {
|
||||
return;
|
||||
}
|
||||
|
||||
let cp_count = ((data[8] as u16) << 8 | data[9] as u16) as usize;
|
||||
let mut pos = 10;
|
||||
let mut entry = 1;
|
||||
|
||||
while entry < cp_count && pos < data.len() {
|
||||
let tag = data[pos];
|
||||
pos += 1;
|
||||
|
||||
match tag {
|
||||
// CONSTANT_Utf8
|
||||
1 => {
|
||||
if pos + 2 > data.len() { break; }
|
||||
let len = ((data[pos] as usize) << 8) | data[pos + 1] as usize;
|
||||
pos += 2;
|
||||
if pos + len > data.len() { break; }
|
||||
|
||||
if let Ok(s) = std::str::from_utf8(&data[pos..pos + len]) {
|
||||
if s.len() >= 3 && s.len() <= 500 {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
3 | 4 => { pos += 4; }
|
||||
5 | 6 => { pos += 8; entry += 1; }
|
||||
7 | 8 | 16 => { pos += 2; }
|
||||
9 | 10 | 11 | 12 | 18 => { pos += 4; }
|
||||
15 => { pos += 3; }
|
||||
_ => { break; }
|
||||
}
|
||||
entry += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_textfield_audio() {
|
||||
let strings = vec![
|
||||
"TextField,Audio1,English Dolby Atmos,Font,296,763,275,25,left".to_string(),
|
||||
"TextField,Audio2,English Descriptive Audio,Font,296,803,275,25,left".to_string(),
|
||||
"TextField,Audio3,Spanish 5.1 Dolby Digital,Font,296,843,275,25,left".to_string(),
|
||||
];
|
||||
let labels = try_textfield_format(&strings).unwrap();
|
||||
assert_eq!(labels.audio.len(), 3);
|
||||
assert_eq!(labels.audio[0].description, "English Dolby Atmos");
|
||||
assert_eq!(labels.audio[1].description, "English Descriptive Audio");
|
||||
assert_eq!(labels.audio[2].description, "Spanish 5.1 Dolby Digital");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_textfield_subtitle_skips_none() {
|
||||
let strings = vec![
|
||||
"TextField,Subtitle0,None,Font,1312,843,275,25,left".to_string(),
|
||||
"TextField,Subtitle1,English SDH,Font,1312,763,275,25,left".to_string(),
|
||||
"TextField,Subtitle2,Spanish,Font,1312,803,275,25,left".to_string(),
|
||||
];
|
||||
let labels = try_textfield_format(&strings).unwrap();
|
||||
assert_eq!(labels.subtitle.len(), 2);
|
||||
assert_eq!(labels.subtitle[0].description, "English SDH");
|
||||
assert_eq!(labels.subtitle[1].description, "Spanish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_format_audio() {
|
||||
let strings = vec![
|
||||
"eng_MLP_".to_string(),
|
||||
"eng_ADES_US_".to_string(),
|
||||
"fra_AudioStream3".to_string(),
|
||||
];
|
||||
let labels = try_label_format(&strings).unwrap();
|
||||
assert_eq!(labels.audio.len(), 3);
|
||||
assert_eq!(labels.audio[0].description, ""); // MLP — codec already known from stream
|
||||
assert_eq!(labels.audio[1].description, "Descriptive Audio (US)"); // ADES — real value-add
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_format_subtitle() {
|
||||
let strings = vec!["dan_PGStream4".to_string()];
|
||||
let labels = try_label_format(&strings).unwrap();
|
||||
assert_eq!(labels.subtitle.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_labels() {
|
||||
let strings = vec!["java/lang/Object".to_string(), "toString".to_string()];
|
||||
assert!(try_textfield_format(&strings).is_none());
|
||||
assert!(try_label_format(&strings).is_none());
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
//! KEYDB.cfg updater — HTTP GET, unzip, verify, save.
|
||||
//!
|
||||
//! Zero external HTTP dependencies. Raw TCP for HTTP GET.
|
||||
//! Uses `zip` and `flate2` (already in deps) for extraction.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Standard keydb storage path.
|
||||
pub fn default_path() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").map_err(|_| Error::KeydbWrite {
|
||||
path: "HOME".into(),
|
||||
})?;
|
||||
Ok(PathBuf::from(home).join(".config").join("freemkv").join("keydb.cfg"))
|
||||
}
|
||||
|
||||
/// Download a KEYDB from a URL, verify, save to the standard path.
|
||||
pub fn update(url: &str) -> Result<UpdateResult> {
|
||||
let body = http_get(url)?;
|
||||
save(&body)
|
||||
}
|
||||
|
||||
/// Verify and save raw keydb bytes (plain text, .zip, or .gz).
|
||||
pub fn save(data: &[u8]) -> Result<UpdateResult> {
|
||||
let text = if data.starts_with(b"PK\x03\x04") {
|
||||
extract_zip(data)?
|
||||
} else if data.starts_with(&[0x1f, 0x8b]) {
|
||||
let mut dec = flate2::read::GzDecoder::new(data);
|
||||
let mut out = String::new();
|
||||
dec.read_to_string(&mut out).map_err(|_| Error::KeydbParse)?;
|
||||
out
|
||||
} else {
|
||||
String::from_utf8(data.to_vec()).map_err(|_| Error::KeydbParse)?
|
||||
};
|
||||
|
||||
let entries = text.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim();
|
||||
t.starts_with("0x") || t.starts_with("| DK") || t.starts_with("| PK") || t.starts_with("| HC")
|
||||
})
|
||||
.count();
|
||||
|
||||
if entries == 0 {
|
||||
return Err(Error::KeydbInvalid);
|
||||
}
|
||||
|
||||
let path = default_path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|_| Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
})?;
|
||||
}
|
||||
std::fs::write(&path, &text).map_err(|_| Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
})?;
|
||||
|
||||
Ok(UpdateResult { path, entries, bytes: text.len() })
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateResult {
|
||||
pub path: PathBuf,
|
||||
pub entries: usize,
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
fn http_get(url: &str) -> Result<Vec<u8>> {
|
||||
let (host, port, path) = parse_url(url)?;
|
||||
|
||||
for _ in 0..5 {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
|
||||
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n",
|
||||
path, host
|
||||
);
|
||||
stream.write_all(request.as_bytes()).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
|
||||
let header_end = find_header_end(&response).ok_or(Error::KeydbParse)?;
|
||||
let headers = std::str::from_utf8(&response[..header_end]).unwrap_or("");
|
||||
let body = &response[header_end + 4..];
|
||||
|
||||
if let Some(location) = extract_header(headers, "Location") {
|
||||
return http_get(&location);
|
||||
}
|
||||
|
||||
let status = parse_status(headers);
|
||||
if status != 200 {
|
||||
return Err(Error::KeydbHttp { status });
|
||||
}
|
||||
|
||||
return Ok(body.to_vec());
|
||||
}
|
||||
|
||||
Err(Error::KeydbHttp { status: 302 })
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Result<(String, u16, String)> {
|
||||
let url = url.strip_prefix("http://").ok_or(Error::KeydbParse)?;
|
||||
let (host_port, path) = match url.find('/') {
|
||||
Some(i) => (&url[..i], &url[i..]),
|
||||
None => (url, "/"),
|
||||
};
|
||||
let (host, port) = match host_port.find(':') {
|
||||
Some(i) => (&host_port[..i], host_port[i+1..].parse().unwrap_or(80)),
|
||||
None => (host_port, 80u16),
|
||||
};
|
||||
Ok((host.to_string(), port, path.to_string()))
|
||||
}
|
||||
|
||||
fn parse_status(headers: &str) -> u16 {
|
||||
headers.lines().next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn find_header_end(data: &[u8]) -> Option<usize> {
|
||||
data.windows(4).position(|w| w == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
fn extract_header<'a>(headers: &'a str, name: &str) -> Option<String> {
|
||||
for line in headers.lines() {
|
||||
if line.len() > name.len() + 2
|
||||
&& line[..name.len()].eq_ignore_ascii_case(name)
|
||||
&& line.as_bytes()[name.len()] == b':'
|
||||
{
|
||||
return Some(line[name.len() + 1..].trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_zip(data: &[u8]) -> Result<String> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
|
||||
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).map_err(|_| Error::KeydbParse)?;
|
||||
return Ok(text);
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::KeydbInvalid)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Clean structured XML with Content/Qualifier per stream and
|
||||
//! stream number mapping via playbackconfig.
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
use std::collections::HashMap;
|
||||
@@ -12,8 +12,8 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "streamproperties.xml")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?;
|
||||
let sp_text = std::str::from_utf8(&sp_data).ok()?;
|
||||
|
||||
let stream_infos = parse_stream_infos(sp_text);
|
||||
@@ -21,7 +21,7 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>
|
||||
|
||||
// Stream number mapping from playbackconfig.xml
|
||||
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
||||
if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") {
|
||||
if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml") {
|
||||
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
|
||||
parse_playback_config(pc_text, &mut stream_map);
|
||||
}
|
||||
|
||||
+8
-8
@@ -4,7 +4,7 @@
|
||||
//! When both exist, language_streams.txt provides structured types while
|
||||
//! menu_base.prop provides stream number → button name mapping.
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
use std::collections::HashMap;
|
||||
@@ -14,12 +14,12 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
// Try language_streams.txt first (richer structured data)
|
||||
let ls_labels = parse_language_streams(session, udf);
|
||||
let ls_labels = parse_language_streams(reader, udf);
|
||||
|
||||
// Try menu_base.prop (stream numbers + key names)
|
||||
let mb_labels = parse_menu_base(session, udf);
|
||||
let mb_labels = parse_menu_base(reader, udf);
|
||||
|
||||
// If we have both, merge: language_streams for structure, menu_base for names
|
||||
match (ls_labels, mb_labels) {
|
||||
@@ -48,8 +48,8 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
|
||||
// ── language_streams.txt parser ────────────────────────────────────────────
|
||||
|
||||
fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "language_streams.txt")?;
|
||||
fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "language_streams.txt")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
let mut labels = Vec::new();
|
||||
@@ -123,8 +123,8 @@ fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec
|
||||
|
||||
// ── menu_base.prop parser ──────────────────────────────────────────────────
|
||||
|
||||
fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "menu_base.prop")?;
|
||||
fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "menu_base.prop")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
// Parse key=value, group by prefix
|
||||
|
||||
+10
-10
@@ -4,7 +4,7 @@
|
||||
//! To add a new format:
|
||||
//! 1. Create `src/labels/myformat.rs`
|
||||
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
|
||||
//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
|
||||
//! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
|
||||
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
|
||||
|
||||
mod paramount;
|
||||
@@ -13,9 +13,9 @@ mod pixelogic;
|
||||
mod ctrm;
|
||||
pub mod vocab;
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use crate::disc::{Title, Stream};
|
||||
use crate::disc::{DiscTitle, Stream};
|
||||
|
||||
/// A stream label extracted from disc config files.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -67,7 +67,7 @@ pub enum LabelQualifier {
|
||||
// Order = priority. First match wins. Highest quality output first.
|
||||
|
||||
type DetectFn = fn(&UdfFs) -> bool;
|
||||
type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>;
|
||||
type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<Vec<StreamLabel>>;
|
||||
|
||||
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
("paramount", paramount::detect, paramount::parse),
|
||||
@@ -79,9 +79,9 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
|
||||
/// Search disc for config files, extract labels, apply to streams.
|
||||
/// This is 100% optional — if anything fails, streams are untouched.
|
||||
pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
|
||||
pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) {
|
||||
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
extract(session, udf)
|
||||
extract(reader, udf)
|
||||
})).unwrap_or_default();
|
||||
if labels.is_empty() { return; }
|
||||
|
||||
@@ -133,10 +133,10 @@ pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
for (_name, detect, parse) in PARSERS {
|
||||
if detect(udf) {
|
||||
if let Some(labels) = parse(session, udf) {
|
||||
if let Some(labels) = parse(reader, udf) {
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Read a file from any BDMV/JAR subdirectory by filename.
|
||||
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
|
||||
pub(crate) fn read_jar_file(reader: &mut dyn SectorReader, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
|
||||
let path = find_jar_file(udf, filename)?;
|
||||
udf.read_file(session, &path).ok().filter(|d| !d.is_empty())
|
||||
udf.read_file(reader, &path).ok().filter(|d| !d.is_empty())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! sub_com1_idx="23,24,25" />
|
||||
//! ```
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
|
||||
@@ -20,8 +20,8 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "playlists.xml")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "playlists.xml")?;
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "playlists.xml")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
// Find the feature playlist — longest duration or name="Feature"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
|
||||
@@ -18,8 +18,8 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "bluray_project.bin")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "bluray_project.bin")?;
|
||||
let strings = extract_strings(&data);
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
+39
-22
@@ -1,4 +1,4 @@
|
||||
//! libfreemkv — Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||
//!
|
||||
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
||||
//! sector reading. 206 bundled drive profiles. No external files needed.
|
||||
@@ -6,14 +6,15 @@
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use libfreemkv::{DriveSession, Disc, ScanOptions};
|
||||
//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive};
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
//! let device = find_drive().expect("no optical drive found");
|
||||
//! let mut session = DriveSession::open(Path::new(&device)).unwrap();
|
||||
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
//!
|
||||
//! 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)
|
||||
@@ -26,21 +27,21 @@
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! DriveSession — open, identify, unlock, read sectors
|
||||
//! ├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
//! ├── DriveProfile — per-drive unlock parameters (206 bundled)
|
||||
//! ├── DriveId — INQUIRY + GET_CONFIG identification
|
||||
//! DriveSession -- open, identify, unlock, read sectors
|
||||
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
|
||||
//! ├── DriveProfile -- per-drive unlock parameters (206 bundled)
|
||||
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
|
||||
//! └── Platform
|
||||
//! └── Mt1959 — MediaTek unlock/read (Renesas planned)
|
||||
//! └── Mt1959 -- MediaTek unlock/read (Renesas planned)
|
||||
//!
|
||||
//! Disc — scan titles, streams, AACS state
|
||||
//! ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
//! ├── MPLS parser — playlists → titles + clips + STN streams
|
||||
//! ├── CLPI parser — clip info → EP map → sector extents
|
||||
//! ├── JAR parser — BD-J audio track labels
|
||||
//! └── AACS — encryption: key resolution + content decrypt
|
||||
//! ├── aacs — KEYDB, VUK, MKB, unit decrypt
|
||||
//! └── handshake — SCSI auth, ECDH, bus key
|
||||
//! Disc -- scan titles, streams, AACS state
|
||||
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
|
||||
//! ├── MPLS parser -- playlists → titles + clips + STN streams
|
||||
//! ├── CLPI parser -- clip info → EP map → sector extents
|
||||
//! ├── JAR parser -- BD-J audio track labels
|
||||
//! └── AACS -- encryption: key resolution + content decrypt
|
||||
//! ├── aacs -- KEYDB, VUK, MKB, unit decrypt
|
||||
//! └── handshake -- SCSI auth, ECDH, bus key
|
||||
//! ```
|
||||
//!
|
||||
//! # AACS Encryption
|
||||
@@ -54,7 +55,7 @@
|
||||
//! # Error Codes
|
||||
//!
|
||||
//! All errors are structured with numeric codes. No user-facing English
|
||||
//! text — applications format their own messages.
|
||||
//! text -- applications format their own messages.
|
||||
//!
|
||||
//! | Range | Category |
|
||||
//! |-------|----------|
|
||||
@@ -67,6 +68,7 @@
|
||||
//! | E7xxx | AACS errors |
|
||||
|
||||
pub mod error;
|
||||
pub mod sector;
|
||||
pub mod scsi;
|
||||
pub mod profile;
|
||||
pub mod platform;
|
||||
@@ -79,14 +81,29 @@ pub mod clpi;
|
||||
pub mod disc;
|
||||
pub mod aacs;
|
||||
pub mod labels;
|
||||
pub mod keydb;
|
||||
pub mod event;
|
||||
pub mod mux;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use drive::DriveSession;
|
||||
pub use event::{Event, EventKind};
|
||||
pub use drive::{DriveSession, find_drive, find_drives, resolve_device};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::{DriveProfile, Chipset};
|
||||
pub use platform::{Platform, DriveStatus};
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
|
||||
pub use sector::SectorReader;
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use speed::DriveSpeed;
|
||||
pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
|
||||
pub use disc::{Disc, DiscFormat, DiscTitle, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
|
||||
Codec, HdrFormat, ColorSpace,
|
||||
Extent, ContentReader, AacsState, KeySource, ScanOptions};
|
||||
pub use mux::IOStream;
|
||||
pub use mux::MkvStream;
|
||||
pub use mux::M2tsStream;
|
||||
pub use mux::NetworkStream;
|
||||
pub use mux::DiscStream;
|
||||
pub use mux::NullStream;
|
||||
pub use mux::StdioStream;
|
||||
pub use mux::IsoStream;
|
||||
pub use mux::DiscOptions;
|
||||
pub use mux::{open_input, open_output, parse_url, InputOptions};
|
||||
|
||||
+49
-8
@@ -62,17 +62,17 @@ pub struct StreamEntry {
|
||||
/// Parse an MPLS file from raw bytes.
|
||||
pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
if data.len() < 40 {
|
||||
return Err(Error::DiscError { detail: "MPLS too short".into() });
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
if &data[0..4] != b"MPLS" {
|
||||
return Err(Error::DiscError { detail: "not an MPLS file".into() });
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
|
||||
if playlist_start + 10 > data.len() {
|
||||
return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() });
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
|
||||
let pl = &data[playlist_start..];
|
||||
@@ -111,7 +111,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
let n_ig = item[STN_OFFSET + 7] as usize;
|
||||
let n_sec_audio = item[STN_OFFSET + 8] as usize;
|
||||
let n_sec_video = item[STN_OFFSET + 9] as usize;
|
||||
let _n_pip_pg = item[STN_OFFSET + 10] as usize;
|
||||
let n_pip_pg = item[STN_OFFSET + 10] as usize;
|
||||
let n_dv = item[STN_OFFSET + 11] as usize;
|
||||
|
||||
let mut spos = STN_OFFSET + 16;
|
||||
@@ -173,6 +173,18 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Secondary PG (PiP subtitles) — must consume to keep spos aligned
|
||||
for _ in 0..n_pip_pg {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 3) {
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip reference data: num_refs(1) + reserved(1) + refs + padding
|
||||
if next < item.len() {
|
||||
let n_refs = item[next] as usize;
|
||||
spos = next + 2 + n_refs + (n_refs % 2);
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Dolby Vision enhancement layer
|
||||
for _ in 0..n_dv {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
@@ -227,6 +239,7 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
let sa = &item[se_end + 1..se_end + 1 + sa_len];
|
||||
let coding_type = sa[0];
|
||||
|
||||
|
||||
let mut video_format = 0u8;
|
||||
let mut video_rate = 0u8;
|
||||
let mut audio_format = 0u8;
|
||||
@@ -249,6 +262,29 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
}
|
||||
2 => {
|
||||
// Audio: coding_type(1) + format_rate(1) + language(3)
|
||||
// Exception: PGS (0x90/0x91) in audio slot uses PG layout: coding_type(1) + language(3)
|
||||
if coding_type == 0x90 || coding_type == 0x91 {
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
}
|
||||
} else {
|
||||
if sa.len() >= 2 {
|
||||
audio_format = (sa[1] >> 4) & 0x0F;
|
||||
audio_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if sa.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sa[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
3 | 4 => {
|
||||
// PG/IG: coding_type(1) + language(3)
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
// Secondary audio: same as primary audio
|
||||
if sa.len() >= 2 {
|
||||
audio_format = (sa[1] >> 4) & 0x0F;
|
||||
audio_rate = sa[1] & 0x0F;
|
||||
@@ -257,10 +293,15 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
language = String::from_utf8_lossy(&sa[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
3 | 4 => {
|
||||
// PG/IG: coding_type(1) + language(3)
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
6 | 7 => {
|
||||
// Secondary video: same as primary video
|
||||
if sa.len() >= 2 {
|
||||
video_format = (sa[1] >> 4) & 0x0F;
|
||||
video_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if coding_type == 0x24 && sa.len() > 2 {
|
||||
dynamic_range = (sa[2] >> 4) & 0x0F;
|
||||
color_space_val = sa[2] & 0x0F;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! AC3 (Dolby Digital) / EAC3 (Dolby Digital Plus) frame parser.
|
||||
//!
|
||||
//! AC3 frames are self-contained and always start with syncword 0x0B77.
|
||||
//! Each PES packet typically contains exactly one AC3 frame.
|
||||
//! All AC3 frames are effectively keyframes (no inter-frame dependencies).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct Ac3Parser;
|
||||
|
||||
impl Ac3Parser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for Ac3Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// Find AC3 syncword (0x0B77) — skip any garbage before it
|
||||
let data = &pes.data;
|
||||
let start = find_ac3_sync(data).unwrap_or(0);
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..].to_vec(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Find AC3 syncword (0x0B77) in data.
|
||||
fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
||||
for i in 0..data.len().saturating_sub(1) {
|
||||
if data[i] == 0x0B && data[i + 1] == 0x77 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! DTS / DTS-HD elementary stream parser.
|
||||
//!
|
||||
//! DTS core syncword: 0x7FFE8001 (32 bits).
|
||||
//! DTS-HD MA/HRA extension follows the core frame.
|
||||
//! All frames are keyframes (no inter-frame dependencies).
|
||||
//! Each PES packet = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct DtsParser;
|
||||
|
||||
impl DtsParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for DtsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! H.264 (AVC) elementary stream parser.
|
||||
//!
|
||||
//! Extracts SPS and PPS NAL units for MKV codecPrivate.
|
||||
//! Detects keyframes (IDR slices).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// H.264 NAL unit types we care about.
|
||||
const NAL_SLICE_IDR: u8 = 5;
|
||||
const NAL_SPS: u8 = 7;
|
||||
const NAL_PPS: u8 = 8;
|
||||
const NAL_AUD: u8 = 9;
|
||||
|
||||
pub struct H264Parser {
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl H264Parser {
|
||||
pub fn new() -> Self {
|
||||
Self { sps: None, pps: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for H264Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Use DTS when available (monotonic for B-frame content), fall back to PTS
|
||||
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// Scan NAL units for SPS, PPS, and IDR detection
|
||||
let mut keyframe = false;
|
||||
let mut frame_data = Vec::new();
|
||||
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
|
||||
match nal_type {
|
||||
NAL_SPS => {
|
||||
self.sps = Some(nal.to_vec());
|
||||
}
|
||||
NAL_PPS => {
|
||||
self.pps = Some(nal.to_vec());
|
||||
}
|
||||
NAL_SLICE_IDR => {
|
||||
keyframe = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B (start code prefixed) to length-prefixed NALUs.
|
||||
// MKV with AVCDecoderConfigurationRecord expects 4-byte length prefix per NALU.
|
||||
// Skip SPS/PPS/AUD NALUs — they're in codecPrivate, not in frame data.
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
// Skip parameter sets and access unit delimiters
|
||||
if nal_type == NAL_SPS || nal_type == NAL_PPS || nal_type == NAL_AUD {
|
||||
continue;
|
||||
}
|
||||
// 4-byte big-endian length prefix
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
|
||||
if frame_data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: frame_data,
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// Build AVCDecoderConfigurationRecord from SPS + PPS
|
||||
let sps = self.sps.as_ref()?;
|
||||
let pps = self.pps.as_ref()?;
|
||||
|
||||
// AVCDecoderConfigurationRecord (ISO 14496-15):
|
||||
// configurationVersion = 1
|
||||
// AVCProfileIndication = SPS[1]
|
||||
// profile_compatibility = SPS[2]
|
||||
// AVCLevelIndication = SPS[3]
|
||||
// lengthSizeMinusOne = 3 (4-byte length prefix)
|
||||
// numOfSequenceParameterSets = 1
|
||||
// sequenceParameterSetLength = sps.len()
|
||||
// sequenceParameterSetNALUnit = sps
|
||||
// numOfPictureParameterSets = 1
|
||||
// pictureParameterSetLength = pps.len()
|
||||
// pictureParameterSetNALUnit = pps
|
||||
|
||||
let mut record = Vec::new();
|
||||
record.push(1); // configurationVersion
|
||||
record.push(sps[1]); // profile
|
||||
record.push(sps[2]); // compatibility
|
||||
record.push(sps[3]); // level
|
||||
record.push(0xFF); // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
|
||||
record.push(0xE1); // 3 bits reserved (111) + 5 bits numSPS (1)
|
||||
record.push((sps.len() >> 8) as u8);
|
||||
record.push(sps.len() as u8);
|
||||
record.extend_from_slice(sps);
|
||||
record.push(1); // numPPS
|
||||
record.push((pps.len() >> 8) as u8);
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator over NAL units in Annex B byte stream.
|
||||
/// Finds start codes (00 00 01 or 00 00 00 01) and yields the data between them.
|
||||
struct NalIterator<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> NalIterator<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
// Skip to first start code
|
||||
let pos = find_start_code(data, 0).unwrap_or(data.len());
|
||||
Self { data, pos }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for NalIterator<'a> {
|
||||
type Item = &'a [u8];
|
||||
|
||||
fn next(&mut self) -> Option<&'a [u8]> {
|
||||
if self.pos >= self.data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip the start code at current position
|
||||
let nal_start = skip_start_code(self.data, self.pos)?;
|
||||
|
||||
// Find next start code (or end of data)
|
||||
let nal_end = find_start_code(self.data, nal_start).unwrap_or(self.data.len());
|
||||
|
||||
// Remove trailing zeros (part of next start code's zero prefix)
|
||||
let mut end = nal_end;
|
||||
while end > nal_start && self.data[end - 1] == 0x00 {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
self.pos = nal_end;
|
||||
|
||||
if end > nal_start {
|
||||
Some(&self.data[nal_start..end])
|
||||
} else {
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the position of the next start code (00 00 01) at or after `from`.
|
||||
pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
for i in from..data.len() - 2 {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Skip past the start code at position `pos`, returning the first byte after it.
|
||||
pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> {
|
||||
if pos + 2 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
if data[pos] == 0x00 && data[pos + 1] == 0x00 {
|
||||
if pos + 3 < data.len() && data[pos + 2] == 0x00 && data[pos + 3] == 0x01 {
|
||||
return Some(pos + 4); // 4-byte start code
|
||||
}
|
||||
if data[pos + 2] == 0x01 {
|
||||
return Some(pos + 3); // 3-byte start code
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! HEVC (H.265) elementary stream parser.
|
||||
//!
|
||||
//! Extracts VPS, SPS, PPS NAL units for MKV codecPrivate.
|
||||
//! Detects keyframes (IRAP pictures: IDR, CRA, BLA).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
use super::h264::{find_start_code, skip_start_code};
|
||||
|
||||
// HEVC NAL unit types
|
||||
const NAL_VPS: u8 = 32;
|
||||
const NAL_SPS: u8 = 33;
|
||||
const NAL_PPS: u8 = 34;
|
||||
const NAL_AUD: u8 = 35;
|
||||
// IRAP types (keyframes): BLA, IDR, CRA
|
||||
const NAL_BLA_W_LP: u8 = 16;
|
||||
const NAL_RSV_IRAP_VCL23: u8 = 23;
|
||||
|
||||
pub struct HevcParser {
|
||||
vps: Option<Vec<u8>>,
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl HevcParser {
|
||||
pub fn new() -> Self {
|
||||
Self { vps: None, sps: None, pps: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for HevcParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Use DTS when available (monotonic for B-frame content), fall back to PTS
|
||||
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
|
||||
// Scan NAL units
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(data, sc_pos) {
|
||||
let next = find_start_code(data, nal_start).unwrap_or(data.len());
|
||||
let mut end = next;
|
||||
while end > nal_start && data[end - 1] == 0x00 { end -= 1; }
|
||||
|
||||
if nal_start < data.len() {
|
||||
// HEVC NAL header: 2 bytes. Type is bits 1-6 of first byte.
|
||||
let nal_type = (data[nal_start] >> 1) & 0x3F;
|
||||
|
||||
match nal_type {
|
||||
NAL_VPS => self.vps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_SPS => self.sps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_PPS => self.pps = Some(data[nal_start..end].to_vec()),
|
||||
t if t >= NAL_BLA_W_LP && t <= NAL_RSV_IRAP_VCL23 => {
|
||||
keyframe = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B to length-prefixed NALUs.
|
||||
// Skip VPS/SPS/PPS/AUD — they're in codecPrivate.
|
||||
let mut frame_data = Vec::new();
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(&pes.data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(&pes.data, sc_pos) {
|
||||
let next = find_start_code(&pes.data, nal_start).unwrap_or(pes.data.len());
|
||||
let mut end = next;
|
||||
while end > nal_start && pes.data[end - 1] == 0x00 { end -= 1; }
|
||||
|
||||
if nal_start < pes.data.len() {
|
||||
let nal_type = (pes.data[nal_start] >> 1) & 0x3F;
|
||||
// Skip parameter sets and AUD
|
||||
if nal_type != NAL_VPS && nal_type != NAL_SPS && nal_type != NAL_PPS && nal_type != NAL_AUD {
|
||||
let nal = &pes.data[nal_start..end];
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if frame_data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: frame_data,
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// HEVCDecoderConfigurationRecord (ISO 14496-15)
|
||||
let vps = self.vps.as_ref()?;
|
||||
let sps = self.sps.as_ref()?;
|
||||
let pps = self.pps.as_ref()?;
|
||||
|
||||
// Simplified: store as arrays in Annex B format
|
||||
// Full HEVCDecoderConfigurationRecord is complex — for now, concatenate
|
||||
let mut record = Vec::new();
|
||||
|
||||
// Minimal HEVCDecoderConfigurationRecord header
|
||||
record.push(1); // configurationVersion
|
||||
// General profile space, tier flag, profile IDC from SPS
|
||||
if sps.len() > 3 {
|
||||
record.push(sps[1]); // general_profile_space + general_tier_flag + general_profile_idc
|
||||
} else {
|
||||
record.push(0);
|
||||
}
|
||||
// general_profile_compatibility_flags (4 bytes)
|
||||
record.extend_from_slice(&[0, 0, 0, 0]);
|
||||
// general_constraint_indicator_flags (6 bytes)
|
||||
record.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
|
||||
// general_level_idc
|
||||
record.push(if sps.len() > 12 { sps[12] } else { 0 });
|
||||
// min_spatial_segmentation_idc (4 + 12 bits)
|
||||
record.extend_from_slice(&[0xF0, 0x00]);
|
||||
// parallelismType (6 + 2 bits)
|
||||
record.push(0xFC);
|
||||
// chromaFormat (6 + 2 bits)
|
||||
record.push(0xFC | 1); // 4:2:0
|
||||
// bitDepthLumaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// bitDepthChromaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// avgFrameRate
|
||||
record.extend_from_slice(&[0, 0]);
|
||||
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
|
||||
record.push(0x03); // lengthSizeMinusOne = 3 (4 bytes)
|
||||
// numOfArrays
|
||||
record.push(3); // VPS, SPS, PPS
|
||||
|
||||
// VPS array
|
||||
record.push(0x20 | (NAL_VPS & 0x3F)); // array_completeness + NAL type
|
||||
record.extend_from_slice(&[0, 1]); // numNalus = 1
|
||||
record.push((vps.len() >> 8) as u8);
|
||||
record.push(vps.len() as u8);
|
||||
record.extend_from_slice(vps);
|
||||
|
||||
// SPS array
|
||||
record.push(0x20 | (NAL_SPS & 0x3F));
|
||||
record.extend_from_slice(&[0, 1]);
|
||||
record.push((sps.len() >> 8) as u8);
|
||||
record.push(sps.len() as u8);
|
||||
record.extend_from_slice(sps);
|
||||
|
||||
// PPS array
|
||||
record.push(0x20 | (NAL_PPS & 0x3F));
|
||||
record.extend_from_slice(&[0, 1]);
|
||||
record.push((pps.len() >> 8) as u8);
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Elementary stream codec parsers.
|
||||
//!
|
||||
//! Each parser takes PES packets and produces frames suitable for MKV muxing.
|
||||
//! Responsibilities:
|
||||
//! - Find frame boundaries
|
||||
//! - Extract codec initialization data (SPS/PPS, etc.)
|
||||
//! - Determine keyframe status
|
||||
//! - Convert PTS from 90kHz to nanoseconds
|
||||
|
||||
pub mod ac3;
|
||||
pub mod h264;
|
||||
pub mod hevc;
|
||||
pub mod vc1;
|
||||
pub mod dts;
|
||||
pub mod truehd;
|
||||
pub mod pgs;
|
||||
|
||||
use crate::disc::Codec;
|
||||
use super::ts::PesPacket;
|
||||
|
||||
/// A single frame ready for MKV muxing.
|
||||
pub struct Frame {
|
||||
/// Presentation timestamp in nanoseconds.
|
||||
pub pts_ns: i64,
|
||||
/// Whether this is a keyframe (used for cue points).
|
||||
pub keyframe: bool,
|
||||
/// Frame data (elementary stream bytes).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Convert 90kHz PTS to nanoseconds.
|
||||
pub fn pts_to_ns(pts: i64) -> i64 {
|
||||
pts * 100_000 / 9
|
||||
}
|
||||
|
||||
/// Trait for codec-specific elementary stream parsers.
|
||||
pub trait CodecParser: Send {
|
||||
/// Parse a PES packet into zero or more frames.
|
||||
/// Most codecs: one PES = one frame.
|
||||
/// Some (TrueHD): multiple access units per PES.
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame>;
|
||||
|
||||
/// Get codec initialization data (e.g., SPS+PPS for H.264).
|
||||
/// Returns None until enough data has been seen.
|
||||
fn codec_private(&self) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// Passthrough parser — treats each PES as one frame, no parsing.
|
||||
/// Used for codecs where PES = frame (AC3, DTS, PGS).
|
||||
pub struct PassthroughParser {
|
||||
keyframe: bool,
|
||||
}
|
||||
|
||||
impl PassthroughParser {
|
||||
pub fn new(always_keyframe: bool) -> Self {
|
||||
Self { keyframe: always_keyframe }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for PassthroughParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: self.keyframe,
|
||||
data: pes.data.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the appropriate parser for a codec.
|
||||
pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(h264::H264Parser::new()),
|
||||
Codec::Hevc => Box::new(hevc::HevcParser::new()),
|
||||
Codec::Vc1 => Box::new(vc1::Vc1Parser::new()),
|
||||
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
||||
Codec::Lpcm => Box::new(PassthroughParser::new(true)),
|
||||
_ => Box::new(PassthroughParser::new(true)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||
//!
|
||||
//! PGS segments: PCS, WDS, PDS, ODS, END.
|
||||
//! Each PES packet contains one or more segments.
|
||||
//! All segments are keyframes (no inter-segment dependencies).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct PgsParser;
|
||||
|
||||
impl PgsParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for PgsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Dolby TrueHD / Atmos elementary stream parser.
|
||||
//!
|
||||
//! TrueHD major sync: 0xF8726FBA at a 4-byte aligned position.
|
||||
//! Access units consist of a major sync followed by minor syncs.
|
||||
//! An embedded AC3 core is in substream 0 for backward compatibility.
|
||||
//! All access units are keyframes.
|
||||
//! Each PES packet = one access unit.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct TrueHdParser;
|
||||
|
||||
impl TrueHdParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for TrueHdParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! VC-1 (SMPTE 421M) elementary stream parser.
|
||||
//!
|
||||
//! VC-1 uses start codes similar to MPEG-2.
|
||||
//! Sequence header (0x0F) contains codec initialization data.
|
||||
//! Frame start = Frame header start code (0x0D).
|
||||
//! I-frames (keyframes) are identified from the frame header.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const SC_SEQUENCE_HEADER: u8 = 0x0F;
|
||||
const SC_ENTRY_POINT: u8 = 0x0E;
|
||||
const SC_FRAME: u8 = 0x0D;
|
||||
|
||||
pub struct Vc1Parser {
|
||||
seq_header: Option<Vec<u8>>,
|
||||
entry_point: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Vc1Parser {
|
||||
pub fn new() -> Self {
|
||||
Self { seq_header: None, entry_point: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for Vc1Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Use DTS when available (monotonic for B-frame content), fall back to PTS
|
||||
let ts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
let mut has_seq_header = false;
|
||||
let mut frame_start: Option<usize> = None;
|
||||
|
||||
// Scan for start codes (00 00 01 XX)
|
||||
let data = &pes.data;
|
||||
let mut i = 0;
|
||||
while i + 3 < data.len() {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
let sc_type = data[i + 3];
|
||||
match sc_type {
|
||||
SC_SEQUENCE_HEADER => {
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
self.seq_header = Some(data[i..end].to_vec());
|
||||
has_seq_header = true;
|
||||
}
|
||||
SC_ENTRY_POINT => {
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
self.entry_point = Some(data[i..end].to_vec());
|
||||
}
|
||||
SC_FRAME => {
|
||||
// Frame data starts at this start code
|
||||
if frame_start.is_none() {
|
||||
frame_start = Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyframe = this PES contains a sequence header (I-frame indicator in BD)
|
||||
let keyframe = has_seq_header;
|
||||
|
||||
// Strip sequence header + entry point from frame data — those are in codecPrivate.
|
||||
// Only include data from the frame start code onwards.
|
||||
let frame_data = match frame_start {
|
||||
Some(start) => &data[start..],
|
||||
None => data, // no frame start code found, pass through entire PES
|
||||
};
|
||||
|
||||
vec![Frame {
|
||||
pts_ns: ts_ns,
|
||||
keyframe,
|
||||
data: frame_data.to_vec(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// MKV V_MS/VFW/FOURCC requires BITMAPINFOHEADER (40 bytes) + extra codec data.
|
||||
// The sequence header + entry point go as extra data after the header.
|
||||
let sh = self.seq_header.as_ref()?;
|
||||
let ep = self.entry_point.as_ref()?;
|
||||
|
||||
let extra_len = sh.len() + ep.len();
|
||||
let header_size: u32 = 40 + extra_len as u32;
|
||||
|
||||
let mut cp = Vec::with_capacity(header_size as usize);
|
||||
|
||||
// BITMAPINFOHEADER (40 bytes, little-endian)
|
||||
cp.extend_from_slice(&header_size.to_le_bytes()); // biSize
|
||||
cp.extend_from_slice(&1920u32.to_le_bytes()); // biWidth (updated by player)
|
||||
cp.extend_from_slice(&1080u32.to_le_bytes()); // biHeight
|
||||
cp.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
|
||||
cp.extend_from_slice(&24u16.to_le_bytes()); // biBitCount
|
||||
cp.extend_from_slice(b"WVC1"); // biCompression = "WVC1" FOURCC
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biSizeImage
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biXPelsPerMeter
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biYPelsPerMeter
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biClrUsed
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biClrImportant
|
||||
|
||||
// Extra codec data: sequence header + entry point (Annex B)
|
||||
cp.extend_from_slice(sh);
|
||||
cp.extend_from_slice(ep);
|
||||
|
||||
Some(cp)
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
|
||||
for i in from..data.len().saturating_sub(2) {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
//! DiscStream — read BD-TS data from an optical disc drive.
|
||||
//!
|
||||
//! Read-only stream. Wraps DriveSession + Disc.
|
||||
//! Handles drive init, AACS decryption, and sector reading.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::Path;
|
||||
use super::IOStream;
|
||||
use crate::disc::{DiscTitle, Disc};
|
||||
use crate::drive::DriveSession;
|
||||
use crate::error::Error;
|
||||
|
||||
/// Options for opening a disc stream.
|
||||
pub struct DiscOptions {
|
||||
/// Device path (e.g. "/dev/sg4"). None = auto-detect.
|
||||
pub device: Option<String>,
|
||||
/// KEYDB.cfg path. None = search standard locations.
|
||||
pub keydb_path: Option<String>,
|
||||
/// Which title to read (0-based). None = longest title.
|
||||
pub title_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for DiscOptions {
|
||||
fn default() -> Self {
|
||||
Self { device: None, keydb_path: None, title_index: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Optical disc stream. Read-only — yields decrypted BD-TS bytes.
|
||||
pub struct DiscStream {
|
||||
disc_title: DiscTitle,
|
||||
disc: Disc,
|
||||
session: DriveSession,
|
||||
title_index: usize,
|
||||
// Read buffer: holds one batch from ContentReader
|
||||
batch_buf: Vec<u8>,
|
||||
batch_pos: usize,
|
||||
started: bool,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl DiscStream {
|
||||
/// Open the disc drive and scan disc metadata.
|
||||
pub fn open(opts: DiscOptions) -> Result<Self, Error> {
|
||||
let device = match opts.device {
|
||||
Some(ref d) => crate::drive::resolve_device(d)?.0,
|
||||
None => crate::drive::find_drive()
|
||||
.ok_or_else(|| Error::DeviceNotFound { path: String::new() })?,
|
||||
};
|
||||
|
||||
let mut session = DriveSession::open(Path::new(&device))?;
|
||||
session.wait_ready()?;
|
||||
let _ = session.init();
|
||||
let _ = session.probe_disc();
|
||||
|
||||
let scan_opts = match opts.keydb_path {
|
||||
Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
let disc = Disc::scan(&mut session, &scan_opts)?;
|
||||
|
||||
let title_index = opts.title_index.unwrap_or(0);
|
||||
if title_index >= disc.titles.len() {
|
||||
return Err(Error::DiscTitleRange { index: title_index, count: disc.titles.len() });
|
||||
}
|
||||
let disc_title = disc.titles[title_index].clone();
|
||||
|
||||
Ok(Self {
|
||||
disc_title, disc, session, title_index,
|
||||
batch_buf: Vec::new(), batch_pos: 0,
|
||||
started: false, eof: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the full Disc (for listing all titles, etc.)
|
||||
pub fn disc(&self) -> &Disc { &self.disc }
|
||||
}
|
||||
|
||||
impl IOStream for DiscStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
fn finish(&mut self) -> io::Result<()> { Ok(()) }
|
||||
}
|
||||
|
||||
impl Read for DiscStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
// Drain buffer first
|
||||
if self.batch_pos < self.batch_buf.len() {
|
||||
let n = (self.batch_buf.len() - self.batch_pos).min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.batch_buf[self.batch_pos..self.batch_pos + n]);
|
||||
self.batch_pos += n;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
if self.eof { return Ok(0); }
|
||||
|
||||
// Open reader on first call
|
||||
if !self.started {
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
// Read next batch via a temporary ContentReader
|
||||
// ContentReader borrows session and disc, so we create it inline
|
||||
let mut reader = self.disc.open_title(&mut self.session, self.title_index)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
let n = batch.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&batch[..n]);
|
||||
if batch.len() > n {
|
||||
self.batch_buf = batch.to_vec();
|
||||
self.batch_pos = n;
|
||||
} else {
|
||||
self.batch_buf.clear();
|
||||
self.batch_pos = 0;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
Ok(None) => { self.eof = true; Ok(0) }
|
||||
Err(e) => Err(io::Error::new(io::ErrorKind::Other, e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for DiscStream {
|
||||
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "disc is read-only"))
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> { Ok(()) }
|
||||
}
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
//! EBML (Extensible Binary Meta Language) write primitives for Matroska.
|
||||
//!
|
||||
//! EBML uses variable-length integers for element IDs and sizes.
|
||||
//! This module provides low-level writers for constructing MKV files.
|
||||
|
||||
use std::io::{self, Read, Write, Seek, SeekFrom};
|
||||
|
||||
/// Write an EBML element ID (1-4 bytes, already encoded).
|
||||
/// Element IDs are predefined constants — we write them verbatim.
|
||||
pub fn write_id(w: &mut impl Write, id: u32) -> io::Result<()> {
|
||||
if id <= 0xFF {
|
||||
w.write_all(&[id as u8])
|
||||
} else if id <= 0xFFFF {
|
||||
w.write_all(&[(id >> 8) as u8, id as u8])
|
||||
} else if id <= 0xFF_FFFF {
|
||||
w.write_all(&[(id >> 16) as u8, (id >> 8) as u8, id as u8])
|
||||
} else {
|
||||
w.write_all(&[(id >> 24) as u8, (id >> 16) as u8, (id >> 8) as u8, id as u8])
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an EBML variable-length size (1-8 bytes).
|
||||
/// Uses the EBML VINT encoding: leading bits indicate width.
|
||||
pub fn write_size(w: &mut impl Write, size: u64) -> io::Result<()> {
|
||||
if size < 0x7F {
|
||||
w.write_all(&[(size as u8) | 0x80])
|
||||
} else if size < 0x3FFF {
|
||||
w.write_all(&[((size >> 8) as u8) | 0x40, size as u8])
|
||||
} else if size < 0x1F_FFFF {
|
||||
w.write_all(&[
|
||||
((size >> 16) as u8) | 0x20,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
} else if size < 0x0FFF_FFFF {
|
||||
w.write_all(&[
|
||||
((size >> 24) as u8) | 0x10,
|
||||
(size >> 16) as u8,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
} else {
|
||||
// 8-byte size for large elements
|
||||
w.write_all(&[
|
||||
0x01,
|
||||
(size >> 48) as u8,
|
||||
(size >> 40) as u8,
|
||||
(size >> 32) as u8,
|
||||
(size >> 24) as u8,
|
||||
(size >> 16) as u8,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an EBML "unknown size" marker (all 1s in VINT, 8 bytes).
|
||||
/// Used for the Segment element when total size isn't known upfront.
|
||||
pub fn write_unknown_size(w: &mut impl Write) -> io::Result<()> {
|
||||
w.write_all(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
|
||||
}
|
||||
|
||||
/// Write a complete EBML unsigned integer element.
|
||||
pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
if val <= 0xFF {
|
||||
write_size(w, 1)?;
|
||||
w.write_all(&[val as u8])
|
||||
} else if val <= 0xFFFF {
|
||||
write_size(w, 2)?;
|
||||
w.write_all(&[(val >> 8) as u8, val as u8])
|
||||
} else if val <= 0xFF_FFFF {
|
||||
write_size(w, 3)?;
|
||||
w.write_all(&[(val >> 16) as u8, (val >> 8) as u8, val as u8])
|
||||
} else if val <= 0xFFFF_FFFF {
|
||||
write_size(w, 4)?;
|
||||
w.write_all(&[
|
||||
(val >> 24) as u8, (val >> 16) as u8,
|
||||
(val >> 8) as u8, val as u8,
|
||||
])
|
||||
} else {
|
||||
write_size(w, 8)?;
|
||||
w.write_all(&val.to_be_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete EBML signed integer element.
|
||||
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
|
||||
write_uint(w, id, val as u64)
|
||||
}
|
||||
|
||||
/// Write a complete EBML float element (8-byte double).
|
||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, 8)?;
|
||||
w.write_all(&val.to_be_bytes())
|
||||
}
|
||||
|
||||
/// Write a complete EBML UTF-8 string element.
|
||||
pub fn write_string(w: &mut impl Write, id: u32, val: &str) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, val.len() as u64)?;
|
||||
w.write_all(val.as_bytes())
|
||||
}
|
||||
|
||||
/// Write a complete EBML binary element.
|
||||
pub fn write_binary(w: &mut impl Write, id: u32, data: &[u8]) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, data.len() as u64)?;
|
||||
w.write_all(data)
|
||||
}
|
||||
|
||||
/// Start a master element: write ID + placeholder size.
|
||||
/// Returns the file offset of the size field for later fixup.
|
||||
pub fn start_master<W: Write + Seek>(w: &mut W, id: u32) -> io::Result<u64> {
|
||||
write_id(w, id)?;
|
||||
let size_pos = w.stream_position()?;
|
||||
// 8-byte size placeholder (will be overwritten by end_master)
|
||||
w.write_all(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])?;
|
||||
Ok(size_pos)
|
||||
}
|
||||
|
||||
/// End a master element: seek back and write the actual size.
|
||||
pub fn end_master<W: Write + Seek>(w: &mut W, size_pos: u64) -> io::Result<()> {
|
||||
let end_pos = w.stream_position()?;
|
||||
let data_size = end_pos - size_pos - 8; // subtract the 8-byte size field itself
|
||||
w.seek(SeekFrom::Start(size_pos))?;
|
||||
// Write as 8-byte VINT: 0x01 followed by 7 bytes of size
|
||||
w.write_all(&[
|
||||
0x01,
|
||||
(data_size >> 48) as u8,
|
||||
(data_size >> 40) as u8,
|
||||
(data_size >> 32) as u8,
|
||||
(data_size >> 24) as u8,
|
||||
(data_size >> 16) as u8,
|
||||
(data_size >> 8) as u8,
|
||||
data_size as u8,
|
||||
])?;
|
||||
w.seek(SeekFrom::Start(end_pos))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EBML Read primitives
|
||||
// ============================================================
|
||||
|
||||
/// Read an EBML element ID. Returns (id, bytes_consumed).
|
||||
pub fn read_id(r: &mut impl Read) -> io::Result<(u32, usize)> {
|
||||
let mut first = [0u8; 1];
|
||||
r.read_exact(&mut first)?;
|
||||
let b0 = first[0];
|
||||
|
||||
if b0 & 0x80 != 0 {
|
||||
Ok((b0 as u32, 1))
|
||||
} else if b0 & 0x40 != 0 {
|
||||
let mut b = [0u8; 1];
|
||||
r.read_exact(&mut b)?;
|
||||
Ok((((b0 as u32) << 8) | b[0] as u32, 2))
|
||||
} else if b0 & 0x20 != 0 {
|
||||
let mut b = [0u8; 2];
|
||||
r.read_exact(&mut b)?;
|
||||
Ok((((b0 as u32) << 16) | (b[0] as u32) << 8 | b[1] as u32, 3))
|
||||
} else if b0 & 0x10 != 0 {
|
||||
let mut b = [0u8; 3];
|
||||
r.read_exact(&mut b)?;
|
||||
Ok((((b0 as u32) << 24) | (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32, 4))
|
||||
} else {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidData, "invalid EBML ID"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an EBML variable-length size. Returns (size, bytes_consumed).
|
||||
/// Size of u64::MAX means "unknown size".
|
||||
pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
let mut first = [0u8; 1];
|
||||
r.read_exact(&mut first)?;
|
||||
let b0 = first[0];
|
||||
|
||||
if b0 & 0x80 != 0 {
|
||||
let val = (b0 & 0x7F) as u64;
|
||||
if val == 0x7F { return Ok((u64::MAX, 1)); } // unknown
|
||||
Ok((val, 1))
|
||||
} else if b0 & 0x40 != 0 {
|
||||
let mut b = [0u8; 1];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (((b0 & 0x3F) as u64) << 8) | b[0] as u64;
|
||||
if val == 0x3FFF { return Ok((u64::MAX, 2)); }
|
||||
Ok((val, 2))
|
||||
} else if b0 & 0x20 != 0 {
|
||||
let mut b = [0u8; 2];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (((b0 & 0x1F) as u64) << 16) | (b[0] as u64) << 8 | b[1] as u64;
|
||||
if val == 0x1FFFFF { return Ok((u64::MAX, 3)); }
|
||||
Ok((val, 3))
|
||||
} else if b0 & 0x10 != 0 {
|
||||
let mut b = [0u8; 3];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (((b0 & 0x0F) as u64) << 24) | (b[0] as u64) << 16 | (b[1] as u64) << 8 | b[2] as u64;
|
||||
if val == 0x0FFFFFFF { return Ok((u64::MAX, 4)); }
|
||||
Ok((val, 4))
|
||||
} else if b0 & 0x08 != 0 {
|
||||
let mut b = [0u8; 4];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (((b0 & 0x07) as u64) << 32) | (b[0] as u64) << 24 | (b[1] as u64) << 16 | (b[2] as u64) << 8 | b[3] as u64;
|
||||
Ok((val, 5))
|
||||
} else if b0 & 0x04 != 0 {
|
||||
let mut b = [0u8; 5];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (((b0 & 0x03) as u64) << 40) | (b[0] as u64) << 32 | (b[1] as u64) << 24 | (b[2] as u64) << 16 | (b[3] as u64) << 8 | b[4] as u64;
|
||||
Ok((val, 6))
|
||||
} else if b0 & 0x02 != 0 {
|
||||
let mut b = [0u8; 6];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (((b0 & 0x01) as u64) << 48) | (b[0] as u64) << 40 | (b[1] as u64) << 32 | (b[2] as u64) << 24 | (b[3] as u64) << 16 | (b[4] as u64) << 8 | b[5] as u64;
|
||||
Ok((val, 7))
|
||||
} else {
|
||||
let mut b = [0u8; 7];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (b[0] as u64) << 48 | (b[1] as u64) << 40 | (b[2] as u64) << 32 | (b[3] as u64) << 24 | (b[4] as u64) << 16 | (b[5] as u64) << 8 | b[6] as u64;
|
||||
if val == 0x00FFFFFFFFFFFFFF { return Ok((u64::MAX, 8)); }
|
||||
Ok((val, 8))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an EBML element header (ID + size). Returns (id, data_size, header_bytes).
|
||||
pub fn read_element_header(r: &mut impl Read) -> io::Result<(u32, u64, usize)> {
|
||||
let (id, id_len) = read_id(r)?;
|
||||
let (size, size_len) = read_size(r)?;
|
||||
Ok((id, size, id_len + size_len))
|
||||
}
|
||||
|
||||
/// Read an unsigned integer value of `len` bytes.
|
||||
pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> {
|
||||
let mut buf = [0u8; 8];
|
||||
r.read_exact(&mut buf[..len])?;
|
||||
let mut val = 0u64;
|
||||
for &b in &buf[..len] {
|
||||
val = (val << 8) | b as u64;
|
||||
}
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Read a float value (4 or 8 bytes).
|
||||
pub fn read_float_val(r: &mut impl Read, len: usize) -> io::Result<f64> {
|
||||
if len == 4 {
|
||||
let mut buf = [0u8; 4];
|
||||
r.read_exact(&mut buf)?;
|
||||
Ok(f32::from_be_bytes(buf) as f64)
|
||||
} else {
|
||||
let mut buf = [0u8; 8];
|
||||
r.read_exact(&mut buf)?;
|
||||
Ok(f64::from_be_bytes(buf))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a UTF-8 string value of `len` bytes.
|
||||
pub fn read_string_val(r: &mut impl Read, len: usize) -> io::Result<String> {
|
||||
let mut buf = vec![0u8; len];
|
||||
r.read_exact(&mut buf)?;
|
||||
// Strip trailing nulls
|
||||
while buf.last() == Some(&0) { buf.pop(); }
|
||||
String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
/// Read binary data of `len` bytes.
|
||||
pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len];
|
||||
r.read_exact(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read a VINT (track number) from a SimpleBlock. Returns (value, bytes_consumed).
|
||||
pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
let mut first = [0u8; 1];
|
||||
r.read_exact(&mut first)?;
|
||||
let b0 = first[0];
|
||||
if b0 & 0x80 != 0 { return Ok(((b0 & 0x7F) as u64, 1)); }
|
||||
if b0 & 0x40 != 0 {
|
||||
let mut b = [0u8; 1];
|
||||
r.read_exact(&mut b)?;
|
||||
return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported VINT width"))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Matroska Element IDs
|
||||
// ============================================================
|
||||
|
||||
// EBML Header
|
||||
pub const EBML: u32 = 0x1A45DFA3;
|
||||
pub const EBML_VERSION: u32 = 0x4286;
|
||||
pub const EBML_READ_VERSION: u32 = 0x42F7;
|
||||
pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2;
|
||||
pub const EBML_MAX_SIZE_LENGTH: u32 = 0x42F3;
|
||||
pub const EBML_DOC_TYPE: u32 = 0x4282;
|
||||
pub const EBML_DOC_TYPE_VERSION: u32 = 0x4287;
|
||||
pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
||||
|
||||
// Segment
|
||||
pub const SEGMENT: u32 = 0x18538067;
|
||||
|
||||
// Seek Head
|
||||
pub const SEEK_HEAD: u32 = 0x114D9B74;
|
||||
pub const SEEK: u32 = 0x4DBB;
|
||||
pub const SEEK_ID: u32 = 0x53AB;
|
||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||
|
||||
// Segment Info
|
||||
pub const INFO: u32 = 0x1549A966;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2AD7B1;
|
||||
pub const DURATION: u32 = 0x4489;
|
||||
pub const MUXING_APP: u32 = 0x4D80;
|
||||
pub const WRITING_APP: u32 = 0x5741;
|
||||
pub const TITLE: u32 = 0x7BA9;
|
||||
|
||||
// Tracks
|
||||
pub const TRACKS: u32 = 0x1654AE6B;
|
||||
pub const TRACK_ENTRY: u32 = 0xAE;
|
||||
pub const TRACK_NUMBER: u32 = 0xD7;
|
||||
pub const TRACK_UID: u32 = 0x73C5;
|
||||
pub const TRACK_TYPE: u32 = 0x83;
|
||||
pub const FLAG_LACING: u32 = 0x9C;
|
||||
pub const FLAG_DEFAULT: u32 = 0x88;
|
||||
pub const FLAG_FORCED: u32 = 0x55AA;
|
||||
pub const LANGUAGE: u32 = 0x22B59C;
|
||||
pub const CODEC_ID: u32 = 0x86;
|
||||
pub const CODEC_PRIVATE: u32 = 0x63A2;
|
||||
pub const TRACK_NAME: u32 = 0x536E;
|
||||
pub const DEFAULT_DURATION: u32 = 0x23E383;
|
||||
|
||||
// Video
|
||||
pub const VIDEO: u32 = 0xE0;
|
||||
pub const PIXEL_WIDTH: u32 = 0xB0;
|
||||
pub const PIXEL_HEIGHT: u32 = 0xBA;
|
||||
pub const DISPLAY_WIDTH: u32 = 0x54B0;
|
||||
pub const DISPLAY_HEIGHT: u32 = 0x54BA;
|
||||
pub const COLOUR: u32 = 0x55B0;
|
||||
pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA;
|
||||
pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
||||
pub const PRIMARIES: u32 = 0x55BB;
|
||||
pub const RANGE: u32 = 0x55B9;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
pub const SAMPLING_FREQUENCY: u32 = 0xB5;
|
||||
pub const CHANNELS: u32 = 0x9F;
|
||||
pub const BIT_DEPTH: u32 = 0x6264;
|
||||
|
||||
// Cluster
|
||||
pub const CLUSTER: u32 = 0x1F43B675;
|
||||
pub const CLUSTER_TIMESTAMP: u32 = 0xE7;
|
||||
pub const SIMPLE_BLOCK: u32 = 0xA3;
|
||||
|
||||
// Cues
|
||||
pub const CUES: u32 = 0x1C53BB6B;
|
||||
pub const CUE_POINT: u32 = 0xBB;
|
||||
pub const CUE_TIME: u32 = 0xB3;
|
||||
pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
|
||||
pub const CUE_TRACK: u32 = 0xF7;
|
||||
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
|
||||
|
||||
// Track types
|
||||
pub const TRACK_TYPE_VIDEO: u64 = 1;
|
||||
pub const TRACK_TYPE_AUDIO: u64 = 2;
|
||||
pub const TRACK_TYPE_SUBTITLE: u64 = 17;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn test_write_size() {
|
||||
let mut buf = Vec::new();
|
||||
write_size(&mut buf, 0).unwrap();
|
||||
assert_eq!(buf, [0x80]);
|
||||
|
||||
buf.clear();
|
||||
write_size(&mut buf, 127).unwrap();
|
||||
assert_eq!(buf, [0x40, 127]); // 127 >= 0x7F, uses 2 bytes: (0>>8)|0x40, 127
|
||||
|
||||
buf.clear();
|
||||
write_size(&mut buf, 126).unwrap();
|
||||
assert_eq!(buf, [126 | 0x80]); // 126 < 0x7F, uses 1 byte
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_uint() {
|
||||
let mut buf = Vec::new();
|
||||
write_uint(&mut buf, 0x4286, 1).unwrap(); // EBML_VERSION = 1
|
||||
// ID: 42 86, Size: 81 (1 byte), Data: 01
|
||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_string() {
|
||||
let mut buf = Vec::new();
|
||||
write_string(&mut buf, 0x4282, "matroska").unwrap();
|
||||
// ID: 42 82, Size: 88 (8 bytes), Data: "matroska"
|
||||
assert_eq!(&buf[0..2], &[0x42, 0x82]);
|
||||
assert_eq!(buf[2], 0x88); // size = 8
|
||||
assert_eq!(&buf[3..], b"matroska");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_element() {
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
let pos = start_master(&mut buf, EBML).unwrap();
|
||||
write_uint(&mut buf, EBML_VERSION, 1).unwrap();
|
||||
end_master(&mut buf, pos).unwrap();
|
||||
let data = buf.into_inner();
|
||||
// EBML header: 1A 45 DF A3, then 8-byte size, then content
|
||||
assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]);
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
//! IsoStream — read/write Blu-ray ISO disc images.
|
||||
//!
|
||||
//! Read: parses UDF filesystem inside the ISO using the same pipeline as
|
||||
//! DiscStream (titles, streams, labels, AACS). An ISO is a flat image of
|
||||
//! 2048-byte sectors — sector N starts at byte offset N * 2048.
|
||||
//!
|
||||
//! Write: creates a sector-by-sector disc image from a SectorReader source.
|
||||
|
||||
use std::io::{self, Read, Write, Seek, SeekFrom};
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use super::IOStream;
|
||||
use crate::disc::{Disc, DiscTitle, ScanOptions};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const SECTOR_SIZE: u64 = 2048;
|
||||
|
||||
/// File-backed sector reader for ISO images.
|
||||
pub struct IsoSectorReader {
|
||||
file: File,
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl IsoSectorReader {
|
||||
pub fn open(path: &str) -> io::Result<Self> {
|
||||
let file = File::open(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
|
||||
let size = file.metadata()?.len();
|
||||
let capacity = (size / SECTOR_SIZE) as u32;
|
||||
Ok(Self { file, capacity })
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> u32 { self.capacity }
|
||||
}
|
||||
|
||||
impl SectorReader for IsoSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let bytes = count as usize * SECTOR_SIZE as usize;
|
||||
self.file.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file.read_exact(&mut buf[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Blu-ray ISO image stream.
|
||||
///
|
||||
/// Read: opens ISO, parses UDF (same as DiscStream), streams BD-TS content.
|
||||
/// Write: receives sector data and writes to ISO file.
|
||||
pub struct IsoStream {
|
||||
disc_title: DiscTitle,
|
||||
disc: Option<Disc>,
|
||||
reader: Option<IsoSectorReader>,
|
||||
writer: Option<io::BufWriter<File>>,
|
||||
/// Sector ranges to read: (start_lba, sector_count)
|
||||
extents: Vec<(u32, u32)>,
|
||||
extent_idx: usize,
|
||||
sectors_remaining: u32,
|
||||
sector_buf: [u8; SECTOR_SIZE as usize],
|
||||
buf_pos: usize,
|
||||
buf_len: usize,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl IsoStream {
|
||||
/// Open an ISO file for reading. Parses UDF, scans titles, streams, labels.
|
||||
pub fn open(path: &str, title_index: Option<usize>, opts: &ScanOptions) -> io::Result<Self> {
|
||||
let mut reader = IsoSectorReader::open(path)?;
|
||||
let capacity = reader.capacity();
|
||||
|
||||
let disc = Disc::scan_image(&mut reader, capacity, opts)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||
|
||||
let idx = title_index.unwrap_or(0).min(disc.titles.len().saturating_sub(1));
|
||||
let disc_title = if disc.titles.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "no titles found in ISO image"));
|
||||
} else {
|
||||
disc.titles[idx].clone()
|
||||
};
|
||||
|
||||
let extents: Vec<(u32, u32)> = disc_title.extents.iter()
|
||||
.map(|e| (e.start_lba, e.sector_count))
|
||||
.collect();
|
||||
|
||||
let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0);
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title,
|
||||
disc: Some(disc),
|
||||
reader: Some(reader),
|
||||
writer: None,
|
||||
extents,
|
||||
extent_idx: 0,
|
||||
sectors_remaining,
|
||||
sector_buf: [0u8; SECTOR_SIZE as usize],
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an ISO file for writing. Receives raw sector data.
|
||||
pub fn create(path: &str) -> io::Result<Self> {
|
||||
let file = File::create(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
|
||||
let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title: DiscTitle::empty(),
|
||||
disc: None,
|
||||
reader: None,
|
||||
writer: Some(writer),
|
||||
extents: Vec::new(),
|
||||
extent_idx: 0,
|
||||
sectors_remaining: 0,
|
||||
sector_buf: [0u8; SECTOR_SIZE as usize],
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set metadata (for write mode).
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the full Disc (for listing all titles).
|
||||
pub fn disc(&self) -> Option<&Disc> { self.disc.as_ref() }
|
||||
|
||||
/// Read the next sector from the current extent.
|
||||
fn read_next_sector(&mut self) -> io::Result<bool> {
|
||||
let reader = match self.reader.as_mut() {
|
||||
Some(r) => r,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
if self.extent_idx >= self.extents.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let (start_lba, total) = self.extents[self.extent_idx];
|
||||
let offset = total - self.sectors_remaining;
|
||||
let lba = start_lba + offset;
|
||||
|
||||
reader.read_sectors(lba, 1, &mut self.sector_buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||
self.buf_pos = 0;
|
||||
self.buf_len = SECTOR_SIZE as usize;
|
||||
|
||||
self.sectors_remaining -= 1;
|
||||
if self.sectors_remaining == 0 {
|
||||
self.extent_idx += 1;
|
||||
if self.extent_idx < self.extents.len() {
|
||||
self.sectors_remaining = self.extents[self.extent_idx].1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for IsoStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(ref mut w) = self.writer {
|
||||
w.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for IsoStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if self.eof { return Ok(0); }
|
||||
|
||||
// Drain current sector buffer
|
||||
if self.buf_pos < self.buf_len {
|
||||
let n = (self.buf_len - self.buf_pos).min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.sector_buf[self.buf_pos..self.buf_pos + n]);
|
||||
self.buf_pos += n;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
// Read next sector
|
||||
if self.read_next_sector()? {
|
||||
let n = self.buf_len.min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.sector_buf[..n]);
|
||||
self.buf_pos = n;
|
||||
Ok(n)
|
||||
} else {
|
||||
self.eof = true;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for IsoStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.writer.as_mut() {
|
||||
Some(w) => w.write(buf),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"iso:// opened for reading — cannot write")),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.writer.as_mut() {
|
||||
Some(w) => w.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! LookaheadBuffer — generic pre-scan buffer for stream pipelines.
|
||||
//!
|
||||
//! Accumulates data up to a configurable limit. When the consumer finds
|
||||
//! what it needs, the buffer can be drained (fast path, no re-read).
|
||||
//! If the buffer fills before the consumer is satisfied, it signals
|
||||
//! overflow — the caller should discard and re-read from the source.
|
||||
//!
|
||||
//! Used by MkvStream to collect SPS/PPS before writing the MKV header.
|
||||
//! Reusable for any stream stage that needs to look ahead.
|
||||
|
||||
/// Default lookahead buffer size: 5 MB.
|
||||
pub const DEFAULT_LOOKAHEAD_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Lookahead buffer states.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LookaheadState {
|
||||
/// Still collecting data, haven't found what we need yet.
|
||||
Collecting,
|
||||
/// Found what we need, buffer has the data ready to drain.
|
||||
Ready,
|
||||
/// Buffer overflowed before finding what we need.
|
||||
/// Caller should discard buffer, finish scanning without buffering,
|
||||
/// then re-read from the source.
|
||||
Overflow,
|
||||
}
|
||||
|
||||
/// A bounded lookahead buffer.
|
||||
pub struct LookaheadBuffer {
|
||||
data: Vec<u8>,
|
||||
max_size: usize,
|
||||
state: LookaheadState,
|
||||
}
|
||||
|
||||
impl LookaheadBuffer {
|
||||
/// Create a new buffer with the given max size.
|
||||
/// Pass 0 for no buffering (always overflows immediately).
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
Self {
|
||||
data: Vec::with_capacity(max_size.min(DEFAULT_LOOKAHEAD_SIZE)),
|
||||
max_size,
|
||||
state: LookaheadState::Collecting,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push data into the buffer. Returns the new state.
|
||||
/// If the buffer would overflow, transitions to Overflow state.
|
||||
pub fn push(&mut self, chunk: &[u8]) -> LookaheadState {
|
||||
if self.state != LookaheadState::Collecting {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
if self.data.len() + chunk.len() > self.max_size {
|
||||
self.state = LookaheadState::Overflow;
|
||||
return self.state;
|
||||
}
|
||||
|
||||
self.data.extend_from_slice(chunk);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Mark the buffer as ready — we found what we need.
|
||||
pub fn mark_ready(&mut self) {
|
||||
if self.state == LookaheadState::Collecting {
|
||||
self.state = LookaheadState::Ready;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the buffered data (only valid in Ready state).
|
||||
pub fn data(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Take ownership of the buffered data, clearing the buffer.
|
||||
pub fn drain(&mut self) -> Vec<u8> {
|
||||
self.state = LookaheadState::Collecting;
|
||||
std::mem::take(&mut self.data)
|
||||
}
|
||||
|
||||
/// Current state.
|
||||
pub fn state(&self) -> LookaheadState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// How many bytes are buffered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
/// Is the buffer empty?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.data.is_empty()
|
||||
}
|
||||
|
||||
/// Max size this buffer can hold.
|
||||
pub fn max_size(&self) -> usize {
|
||||
self.max_size
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_flow() {
|
||||
let mut buf = LookaheadBuffer::new(100);
|
||||
assert_eq!(buf.push(b"hello"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.push(b"world"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.len(), 10);
|
||||
buf.mark_ready();
|
||||
assert_eq!(buf.state(), LookaheadState::Ready);
|
||||
assert_eq!(buf.data(), b"helloworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overflow() {
|
||||
let mut buf = LookaheadBuffer::new(5);
|
||||
assert_eq!(buf.push(b"abc"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.push(b"def"), LookaheadState::Overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_size() {
|
||||
let mut buf = LookaheadBuffer::new(0);
|
||||
assert_eq!(buf.push(b"a"), LookaheadState::Overflow);
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
//! M2tsStream — BD transport stream with embedded metadata header.
|
||||
//!
|
||||
//! Write: prepends FMKV metadata header, then passes through BD-TS bytes.
|
||||
//! Read: extracts metadata header (or scans PMT), then yields BD-TS bytes.
|
||||
|
||||
use std::io::{self, Read, Write, Seek, SeekFrom};
|
||||
use super::{IOStream, ReadSeek, meta, ts};
|
||||
use crate::disc::{DiscTitle, Stream as DiscStream};
|
||||
|
||||
/// Size of initial scan buffer for PMT/stream detection.
|
||||
const SCAN_SIZE: usize = 1024 * 1024;
|
||||
|
||||
enum Mode {
|
||||
Write {
|
||||
writer: Box<dyn Write>,
|
||||
header_written: bool,
|
||||
},
|
||||
Read {
|
||||
reader: Box<dyn ReadSeek>,
|
||||
},
|
||||
}
|
||||
|
||||
/// BD transport stream with embedded metadata.
|
||||
pub struct M2tsStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl M2tsStream {
|
||||
/// Create for writing. Metadata header is written on first write().
|
||||
pub fn new(writer: impl Write + 'static) -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
mode: Mode::Write {
|
||||
writer: Box::new(writer),
|
||||
header_written: false,
|
||||
},
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set stream metadata. Returns self for chaining.
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
/// Open an m2ts file for reading.
|
||||
///
|
||||
/// Tries FMKV metadata header first. Falls back to PMT scan + PTS duration.
|
||||
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
|
||||
// Try FMKV metadata header
|
||||
if let Ok(Some(m)) = meta::read_header(&mut reader) {
|
||||
return Ok(Self {
|
||||
disc_title: m.to_title(),
|
||||
mode: Mode::Read { reader: Box::new(reader) },
|
||||
finished: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: scan PMT for streams, PTS for duration
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
let mut buf = vec![0u8; SCAN_SIZE];
|
||||
let n = reader.read(&mut buf)?;
|
||||
|
||||
let streams = ts::scan_streams(&buf[..n])
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no streams found"))?;
|
||||
|
||||
let video_pid = streams.iter().find_map(|s| match s {
|
||||
DiscStream::Video(v) => Some(v.pid),
|
||||
_ => None,
|
||||
});
|
||||
let duration = video_pid
|
||||
.and_then(|pid| ts::scan_duration(&mut reader, pid))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
|
||||
Ok(Self {
|
||||
disc_title: DiscTitle {
|
||||
duration_secs: duration,
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
},
|
||||
mode: Mode::Read { reader: Box::new(reader) },
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for M2tsStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if self.finished { return Ok(()); }
|
||||
self.finished = true;
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for M2tsStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
Mode::Write { ref mut writer, ref mut header_written } => {
|
||||
if !*header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
meta::write_header(&mut *writer, &m)?;
|
||||
}
|
||||
*header_written = true;
|
||||
}
|
||||
writer.write(buf)
|
||||
}
|
||||
Mode::Read { .. } => Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for reading")),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for M2tsStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
Mode::Read { ref mut reader } => reader.read(buf),
|
||||
Mode::Write { .. } => Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for writing")),
|
||||
}
|
||||
}
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
//! M2TS metadata header — embeds title/stream info in raw m2ts files.
|
||||
//!
|
||||
//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
|
||||
//! Other tools skip the header during TS sync recovery (scan for 0x47).
|
||||
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::disc::{DiscTitle, Stream, VideoStream, AudioStream, SubtitleStream,
|
||||
Codec, HdrFormat, ColorSpace};
|
||||
|
||||
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
|
||||
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
|
||||
|
||||
/// BD-TS packet size (header must be padded to this boundary).
|
||||
const PACKET_SIZE: usize = 192;
|
||||
|
||||
/// Metadata embedded in an m2ts file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct M2tsMeta {
|
||||
/// Format version.
|
||||
pub v: u8,
|
||||
/// Title name (e.g. filename stem or disc title).
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
/// Duration in seconds.
|
||||
#[serde(default)]
|
||||
pub duration: f64,
|
||||
/// Stream descriptors.
|
||||
pub streams: Vec<MetaStream>,
|
||||
}
|
||||
|
||||
/// A single stream descriptor in the metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum MetaStream {
|
||||
#[serde(rename = "video")]
|
||||
Video {
|
||||
pid: u16,
|
||||
codec: String,
|
||||
#[serde(default)] resolution: String,
|
||||
#[serde(default)] frame_rate: String,
|
||||
#[serde(default)] hdr: String,
|
||||
#[serde(default)] label: String,
|
||||
#[serde(default)] secondary: bool,
|
||||
},
|
||||
#[serde(rename = "audio")]
|
||||
Audio {
|
||||
pid: u16,
|
||||
codec: String,
|
||||
#[serde(default)] channels: String,
|
||||
#[serde(default)] language: String,
|
||||
#[serde(default)] sample_rate: String,
|
||||
#[serde(default)] label: String,
|
||||
#[serde(default)] secondary: bool,
|
||||
},
|
||||
#[serde(rename = "subtitle")]
|
||||
Subtitle {
|
||||
pid: u16,
|
||||
codec: String,
|
||||
#[serde(default)] language: String,
|
||||
#[serde(default)] forced: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl M2tsMeta {
|
||||
/// Build metadata from a disc Title.
|
||||
pub fn from_title(title: &DiscTitle) -> Self {
|
||||
let streams = title.streams.iter().map(|s| match s {
|
||||
Stream::Video(v) => MetaStream::Video {
|
||||
pid: v.pid,
|
||||
codec: codec_to_str(v.codec),
|
||||
resolution: v.resolution.clone(),
|
||||
frame_rate: v.frame_rate.clone(),
|
||||
hdr: hdr_to_str(v.hdr),
|
||||
label: v.label.clone(),
|
||||
secondary: v.secondary,
|
||||
},
|
||||
Stream::Audio(a) => MetaStream::Audio {
|
||||
pid: a.pid,
|
||||
codec: codec_to_str(a.codec),
|
||||
channels: a.channels.clone(),
|
||||
language: a.language.clone(),
|
||||
sample_rate: a.sample_rate.clone(),
|
||||
label: a.label.clone(),
|
||||
secondary: a.secondary,
|
||||
},
|
||||
Stream::Subtitle(s) => MetaStream::Subtitle {
|
||||
pid: s.pid,
|
||||
codec: codec_to_str(s.codec),
|
||||
language: s.language.clone(),
|
||||
forced: s.forced,
|
||||
},
|
||||
}).collect();
|
||||
|
||||
Self {
|
||||
v: 1,
|
||||
title: title.playlist.clone(),
|
||||
duration: title.duration_secs,
|
||||
streams,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert back to a library Title (for remux).
|
||||
pub fn to_title(&self) -> DiscTitle {
|
||||
let streams = self.streams.iter().map(|s| match s {
|
||||
MetaStream::Video { pid, codec, resolution, frame_rate, hdr, label, secondary } => {
|
||||
Stream::Video(VideoStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
resolution: resolution.clone(),
|
||||
frame_rate: frame_rate.clone(),
|
||||
hdr: str_to_hdr(hdr),
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
})
|
||||
}
|
||||
MetaStream::Audio { pid, codec, channels, language, sample_rate, label, secondary } => {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
channels: channels.clone(),
|
||||
language: language.clone(),
|
||||
sample_rate: sample_rate.clone(),
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
})
|
||||
}
|
||||
MetaStream::Subtitle { pid, codec, language, forced } => {
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
language: language.clone(),
|
||||
forced: *forced,
|
||||
})
|
||||
}
|
||||
}).collect();
|
||||
|
||||
DiscTitle {
|
||||
playlist: self.title.clone(),
|
||||
playlist_id: 0,
|
||||
duration_secs: self.duration,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams,
|
||||
extents: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the metadata header to a writer. Padded to 192-byte boundary.
|
||||
pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
|
||||
let json = serde_json::to_vec(meta)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
let json_len = json.len() as u32;
|
||||
let raw_len = 8 + 4 + json.len(); // magic + len + json
|
||||
let padded_len = ((raw_len + PACKET_SIZE - 1) / PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
|
||||
w.write_all(&MAGIC)?;
|
||||
w.write_all(&json_len.to_be_bytes())?;
|
||||
w.write_all(&json)?;
|
||||
if padding > 0 {
|
||||
w.write_all(&vec![0u8; padding])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to read a metadata header from the start of an m2ts file.
|
||||
/// Returns None for bare m2ts files (no header).
|
||||
/// On success, leaves reader positioned at the first TS packet.
|
||||
/// On failure, seeks back to the start.
|
||||
pub fn read_header<R: Read + Seek>(r: &mut R) -> io::Result<Option<M2tsMeta>> {
|
||||
let start = r.stream_position()?;
|
||||
|
||||
let mut magic = [0u8; 8];
|
||||
if r.read_exact(&mut magic).is_err() {
|
||||
r.seek(SeekFrom::Start(start))?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if magic[..4] != MAGIC[..4] {
|
||||
// Not a freemkv m2ts — seek back
|
||||
r.seek(SeekFrom::Start(start))?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
r.read_exact(&mut len_buf)?;
|
||||
let json_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut json_buf = vec![0u8; json_len];
|
||||
r.read_exact(&mut json_buf)?;
|
||||
|
||||
let meta: M2tsMeta = serde_json::from_slice(&json_buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Skip padding to next 192-byte boundary
|
||||
let raw_len = 8 + 4 + json_len;
|
||||
let padded_len = ((raw_len + PACKET_SIZE - 1) / PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
if padding > 0 {
|
||||
r.seek(SeekFrom::Current(padding as i64))?;
|
||||
}
|
||||
|
||||
Ok(Some(meta))
|
||||
}
|
||||
|
||||
/// Read a metadata header from a forward-only stream (no Seek required).
|
||||
/// Returns None if the magic bytes don't match. Consumes the header bytes.
|
||||
pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
let mut magic = [0u8; 8];
|
||||
r.read_exact(&mut magic)?;
|
||||
|
||||
if magic[..4] != MAGIC[..4] {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
r.read_exact(&mut len_buf)?;
|
||||
let json_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut json_buf = vec![0u8; json_len];
|
||||
r.read_exact(&mut json_buf)?;
|
||||
|
||||
let meta: M2tsMeta = serde_json::from_slice(&json_buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Skip padding
|
||||
let raw_len = 8 + 4 + json_len;
|
||||
let padded_len = ((raw_len + PACKET_SIZE - 1) / PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
if padding > 0 {
|
||||
let mut skip = vec![0u8; padding];
|
||||
r.read_exact(&mut skip)?;
|
||||
}
|
||||
|
||||
Ok(Some(meta))
|
||||
}
|
||||
|
||||
// Codec string conversion (compact, no English — just codec identifiers)
|
||||
fn codec_to_str(c: Codec) -> String {
|
||||
match c {
|
||||
Codec::Hevc => "hevc",
|
||||
Codec::H264 => "h264",
|
||||
Codec::Vc1 => "vc1",
|
||||
Codec::Mpeg2 => "mpeg2",
|
||||
Codec::TrueHd => "truehd",
|
||||
Codec::DtsHdMa => "dtshd_ma",
|
||||
Codec::DtsHdHr => "dtshd_hr",
|
||||
Codec::Dts => "dts",
|
||||
Codec::Ac3 => "ac3",
|
||||
Codec::Ac3Plus => "eac3",
|
||||
Codec::Lpcm => "lpcm",
|
||||
Codec::Pgs => "pgs",
|
||||
Codec::Unknown(_) => "unknown",
|
||||
}.into()
|
||||
}
|
||||
|
||||
fn str_to_codec(s: &str) -> Codec {
|
||||
match s {
|
||||
"hevc" => Codec::Hevc,
|
||||
"h264" => Codec::H264,
|
||||
"vc1" => Codec::Vc1,
|
||||
"mpeg2" => Codec::Mpeg2,
|
||||
"truehd" => Codec::TrueHd,
|
||||
"dtshd_ma" => Codec::DtsHdMa,
|
||||
"dtshd_hr" => Codec::DtsHdHr,
|
||||
"dts" => Codec::Dts,
|
||||
"ac3" => Codec::Ac3,
|
||||
"eac3" => Codec::Ac3Plus,
|
||||
"lpcm" => Codec::Lpcm,
|
||||
"pgs" => Codec::Pgs,
|
||||
_ => Codec::Unknown(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn hdr_to_str(h: HdrFormat) -> String {
|
||||
match h {
|
||||
HdrFormat::Sdr => "sdr",
|
||||
HdrFormat::Hdr10 => "hdr10",
|
||||
HdrFormat::DolbyVision => "dv",
|
||||
}.into()
|
||||
}
|
||||
|
||||
fn str_to_hdr(s: &str) -> HdrFormat {
|
||||
match s {
|
||||
"hdr10" => HdrFormat::Hdr10,
|
||||
"dv" => HdrFormat::DolbyVision,
|
||||
_ => HdrFormat::Sdr,
|
||||
}
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
//! Matroska (MKV) muxer.
|
||||
//!
|
||||
//! Writes EBML header, Segment with tracks, clusters, and cues.
|
||||
//! Designed for streaming writes: clusters are written as data arrives,
|
||||
//! cues and seek head are finalized at the end.
|
||||
|
||||
use std::io::{self, Write, Seek, SeekFrom};
|
||||
use super::ebml;
|
||||
use crate::disc::{VideoStream, AudioStream, SubtitleStream, Codec};
|
||||
|
||||
/// MKV track definition (built from disc stream metadata).
|
||||
pub struct MkvTrack {
|
||||
pub track_type: u64, // 1=video, 2=audio, 17=subtitle
|
||||
pub codec_id: &'static str,
|
||||
pub language: String,
|
||||
pub name: String, // Track name / label (e.g. "English (Lossless)")
|
||||
pub codec_private: Option<Vec<u8>>,
|
||||
pub is_default: bool,
|
||||
pub is_forced: bool,
|
||||
// Video-specific
|
||||
pub pixel_width: u32,
|
||||
pub pixel_height: u32,
|
||||
// Audio-specific
|
||||
pub sample_rate: f64,
|
||||
pub channels: u8,
|
||||
pub bit_depth: u8,
|
||||
}
|
||||
|
||||
impl MkvTrack {
|
||||
pub fn video(v: &VideoStream) -> Self {
|
||||
let codec_id = match v.codec {
|
||||
Codec::H264 => "V_MPEG4/ISO/AVC",
|
||||
Codec::Hevc => "V_MPEGH/ISO/HEVC",
|
||||
Codec::Vc1 => "V_MS/VFW/FOURCC",
|
||||
Codec::Mpeg2 => "V_MPEG2",
|
||||
_ => "V_MPEG2",
|
||||
};
|
||||
let (w, h) = parse_resolution(&v.resolution);
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_VIDEO,
|
||||
codec_id,
|
||||
language: "und".into(),
|
||||
name: v.label.clone(),
|
||||
codec_private: None, // filled later by parser
|
||||
is_default: !v.secondary,
|
||||
is_forced: false,
|
||||
pixel_width: w,
|
||||
pixel_height: h,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio(a: &AudioStream) -> Self {
|
||||
let codec_id = match a.codec {
|
||||
Codec::Ac3 => "A_AC3",
|
||||
Codec::Ac3Plus => "A_EAC3",
|
||||
Codec::TrueHd => "A_TRUEHD",
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => "A_DTS",
|
||||
Codec::Lpcm => "A_PCM/INT/BIG",
|
||||
_ => "A_AC3",
|
||||
};
|
||||
let sr = parse_sample_rate(&a.sample_rate);
|
||||
let ch = parse_channels(&a.channels);
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||||
codec_id,
|
||||
language: a.language.clone(),
|
||||
name: a.label.clone(),
|
||||
codec_private: None,
|
||||
is_default: !a.secondary,
|
||||
is_forced: false,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
sample_rate: sr,
|
||||
channels: ch,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subtitle(s: &SubtitleStream) -> Self {
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_SUBTITLE,
|
||||
codec_id: "S_HDMV/PGS",
|
||||
language: s.language.clone(),
|
||||
name: String::new(),
|
||||
codec_private: None,
|
||||
is_default: false,
|
||||
is_forced: s.forced,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cue point for seeking.
|
||||
struct CuePoint {
|
||||
timestamp_ms: i64,
|
||||
track: usize,
|
||||
cluster_pos: u64, // relative to Segment start
|
||||
}
|
||||
|
||||
/// MKV muxer. Call write_frame() for each frame, then finish() at the end.
|
||||
pub struct MkvMuxer<W: Write + Seek> {
|
||||
writer: W,
|
||||
segment_start: u64,
|
||||
cluster_open: bool,
|
||||
cluster_pos: u64,
|
||||
cluster_size_pos: u64,
|
||||
cluster_ts_ms: i64,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
|
||||
/// Used to seek back and fill in SPS/PPS after first keyframe.
|
||||
codec_private_slots: Vec<Option<(u64, usize)>>,
|
||||
codec_private_filled: Vec<bool>,
|
||||
}
|
||||
|
||||
/// New cluster every 5 seconds.
|
||||
const CLUSTER_DURATION_MS: i64 = 5000;
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks.
|
||||
pub fn new(mut writer: W, tracks: &[MkvTrack], title: Option<&str>, duration_secs: f64) -> io::Result<Self> {
|
||||
// EBML Header
|
||||
let ebml_pos = ebml::start_master(&mut writer, ebml::EBML)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_VERSION, 1)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_READ_VERSION, 1)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_MAX_ID_LENGTH, 4)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_MAX_SIZE_LENGTH, 8)?;
|
||||
ebml::write_string(&mut writer, ebml::EBML_DOC_TYPE, "matroska")?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_DOC_TYPE_VERSION, 4)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_DOC_TYPE_READ_VERSION, 2)?;
|
||||
ebml::end_master(&mut writer, ebml_pos)?;
|
||||
|
||||
// Segment (unknown size — we'll write cues at the end)
|
||||
ebml::write_id(&mut writer, ebml::SEGMENT)?;
|
||||
ebml::write_unknown_size(&mut writer)?;
|
||||
let segment_start = writer.stream_position()?;
|
||||
|
||||
// Info
|
||||
let info_pos = ebml::start_master(&mut writer, ebml::INFO)?;
|
||||
ebml::write_uint(&mut writer, ebml::TIMESTAMP_SCALE, 1_000_000)?; // 1ms precision
|
||||
if duration_secs > 0.0 {
|
||||
ebml::write_float(&mut writer, ebml::DURATION, duration_secs * 1000.0)?; // in ms
|
||||
}
|
||||
ebml::write_string(&mut writer, ebml::MUXING_APP, "freemkv")?;
|
||||
ebml::write_string(&mut writer, ebml::WRITING_APP, "freemkv")?;
|
||||
if let Some(t) = title {
|
||||
ebml::write_string(&mut writer, ebml::TITLE, t)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, info_pos)?;
|
||||
|
||||
// Tracks
|
||||
let mut codec_private_slots: Vec<Option<(u64, usize)>> = Vec::new();
|
||||
let mut codec_private_filled: Vec<bool> = Vec::new();
|
||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
|
||||
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
|
||||
ebml::write_string(&mut writer, ebml::LANGUAGE, &track.language)?;
|
||||
if !track.name.is_empty() {
|
||||
ebml::write_string(&mut writer, ebml::TRACK_NAME, &track.name)?;
|
||||
}
|
||||
|
||||
if !track.is_default {
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
|
||||
}
|
||||
if track.is_forced {
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
|
||||
}
|
||||
|
||||
if let Some(ref cp) = track.codec_private {
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
||||
codec_private_slots.push(None); // already filled
|
||||
codec_private_filled.push(true);
|
||||
} else if track.track_type == ebml::TRACK_TYPE_VIDEO {
|
||||
// Reserve space for codecPrivate — will be filled after first keyframe
|
||||
// Reserve 256 bytes (enough for SPS+PPS or VPS+SPS+PPS)
|
||||
let cp_pos = writer.stream_position()?;
|
||||
let placeholder = vec![0u8; 256];
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &placeholder)?;
|
||||
codec_private_slots.push(Some((cp_pos, 256)));
|
||||
codec_private_filled.push(false);
|
||||
} else {
|
||||
codec_private_slots.push(None);
|
||||
codec_private_filled.push(true);
|
||||
}
|
||||
|
||||
// Video-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
|
||||
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?;
|
||||
ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
|
||||
ebml::end_master(&mut writer, vid_pos)?;
|
||||
}
|
||||
|
||||
// Audio-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_AUDIO && track.sample_rate > 0.0 {
|
||||
let aud_pos = ebml::start_master(&mut writer, ebml::AUDIO)?;
|
||||
ebml::write_float(&mut writer, ebml::SAMPLING_FREQUENCY, track.sample_rate)?;
|
||||
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
|
||||
if track.bit_depth > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, aud_pos)?;
|
||||
}
|
||||
|
||||
ebml::end_master(&mut writer, entry_pos)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, tracks_pos)?;
|
||||
|
||||
Ok(Self {
|
||||
writer,
|
||||
segment_start,
|
||||
cluster_open: false,
|
||||
cluster_pos: 0,
|
||||
cluster_size_pos: 0,
|
||||
cluster_ts_ms: 0,
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
codec_private_slots,
|
||||
codec_private_filled,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a single frame.
|
||||
pub fn write_frame(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
let pts_ms = pts_ns / 1_000_000;
|
||||
|
||||
// Start new cluster if needed
|
||||
if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS {
|
||||
if self.cluster_open {
|
||||
// Close current cluster (it's a master with unknown size — we use known size)
|
||||
// Actually, for streaming we keep clusters open-ended. Just start a new one.
|
||||
}
|
||||
self.start_cluster(pts_ms)?;
|
||||
|
||||
// Add cue point at cluster start for keyframes (video track 0)
|
||||
if keyframe && track_idx == 0 {
|
||||
self.cues.push(CuePoint {
|
||||
timestamp_ms: pts_ms,
|
||||
track: track_idx + 1,
|
||||
cluster_pos: self.cluster_pos - self.segment_start,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write SimpleBlock
|
||||
let relative_ts = (pts_ms - self.cluster_ts_ms) as i16;
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
self.frame_count += 1;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish the MKV file: write Cues element.
|
||||
pub fn finish(mut self) -> io::Result<()> {
|
||||
// Close final cluster
|
||||
self.end_cluster()?;
|
||||
|
||||
// Write Cues
|
||||
if !self.cues.is_empty() {
|
||||
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
|
||||
for cue in &self.cues {
|
||||
let cp_pos = ebml::start_master(&mut self.writer, ebml::CUE_POINT)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_TIME, cue.timestamp_ms as u64)?;
|
||||
let ctp_pos = ebml::start_master(&mut self.writer, ebml::CUE_TRACK_POSITIONS)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_TRACK, cue.track as u64)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_CLUSTER_POSITION, cue.cluster_pos)?;
|
||||
ebml::end_master(&mut self.writer, ctp_pos)?;
|
||||
ebml::end_master(&mut self.writer, cp_pos)?;
|
||||
}
|
||||
ebml::end_master(&mut self.writer, cues_pos)?;
|
||||
}
|
||||
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill in a deferred codecPrivate for a track.
|
||||
/// Seeks back to the placeholder, writes the actual data, restores position.
|
||||
pub fn fill_codec_private(&mut self, track_idx: usize, data: &[u8]) -> io::Result<()> {
|
||||
if track_idx >= self.codec_private_filled.len() || self.codec_private_filled[track_idx] {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((pos, max_size)) = self.codec_private_slots[track_idx] {
|
||||
if data.len() > max_size {
|
||||
// Data too large for reserved space — can't fill in place
|
||||
// This shouldn't happen with 256 bytes reserved
|
||||
return Ok(());
|
||||
}
|
||||
let current = self.writer.stream_position()?;
|
||||
self.writer.seek(SeekFrom::Start(pos))?;
|
||||
// Rewrite: element ID + size + data + zero-pad remainder
|
||||
let mut padded = data.to_vec();
|
||||
padded.resize(max_size, 0);
|
||||
ebml::write_binary(&mut self.writer, ebml::CODEC_PRIVATE, &padded)?;
|
||||
self.writer.seek(SeekFrom::Start(current))?;
|
||||
self.codec_private_filled[track_idx] = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_cluster(&mut self, ts_ms: i64) -> io::Result<()> {
|
||||
// Close previous cluster if open
|
||||
if self.cluster_open {
|
||||
self.end_cluster()?;
|
||||
}
|
||||
self.cluster_pos = self.writer.stream_position()?;
|
||||
self.cluster_size_pos = ebml::start_master(&mut self.writer, ebml::CLUSTER)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CLUSTER_TIMESTAMP, ts_ms as u64)?;
|
||||
self.cluster_ts_ms = ts_ms;
|
||||
self.cluster_open = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn end_cluster(&mut self) -> io::Result<()> {
|
||||
if self.cluster_open {
|
||||
ebml::end_master(&mut self.writer, self.cluster_size_pos)?;
|
||||
self.cluster_open = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_simple_block(&mut self, track_num: usize, relative_ts: i16, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
// SimpleBlock: [track_number VINT] [relative_ts i16] [flags u8] [data]
|
||||
// Track number as EBML VINT
|
||||
let track_vint = if track_num < 0x80 {
|
||||
vec![(track_num as u8) | 0x80]
|
||||
} else {
|
||||
vec![0x40 | ((track_num >> 8) as u8), track_num as u8]
|
||||
};
|
||||
|
||||
let flags: u8 = if keyframe { 0x80 } else { 0x00 };
|
||||
|
||||
let block_size = track_vint.len() + 2 + 1 + data.len(); // vint + ts(2) + flags(1) + data
|
||||
ebml::write_id(&mut self.writer, ebml::SIMPLE_BLOCK)?;
|
||||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||||
self.writer.write_all(&track_vint)?;
|
||||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||||
self.writer.write_all(&[flags])?;
|
||||
self.writer.write_all(data)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
fn parse_resolution(s: &str) -> (u32, u32) {
|
||||
if s.contains("2160") { (3840, 2160) }
|
||||
else if s.contains("1080") { (1920, 1080) }
|
||||
else if s.contains("720") { (1280, 720) }
|
||||
else if s.contains("576") { (720, 576) }
|
||||
else if s.contains("480") { (720, 480) }
|
||||
else { (1920, 1080) }
|
||||
}
|
||||
|
||||
fn parse_sample_rate(s: &str) -> f64 {
|
||||
if s.contains("96") { 96000.0 }
|
||||
else if s.contains("192") { 192000.0 }
|
||||
else { 48000.0 }
|
||||
}
|
||||
|
||||
fn parse_channels(s: &str) -> u8 {
|
||||
if s.contains("7.1") { 8 }
|
||||
else if s.contains("5.1") { 6 }
|
||||
else if s.contains("stereo") || s.contains("2.0") { 2 }
|
||||
else if s.contains("mono") { 1 }
|
||||
else { 6 }
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! MkvStream — Matroska container stream.
|
||||
//!
|
||||
//! Write: BD-TS bytes in → demux → codec parse → MKV container out.
|
||||
//! Read: MKV container in → extract frames → wrap as BD-TS → bytes out.
|
||||
|
||||
use std::io::{self, Read, Write, Seek, SeekFrom};
|
||||
use super::{IOStream, WriteSeek, ReadSeek, ebml};
|
||||
use super::ts::TsDemuxer;
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::codec::{self, CodecParser};
|
||||
use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE};
|
||||
use crate::disc::*;
|
||||
|
||||
/// Lookahead buffer for codec header detection (5 MB default).
|
||||
const DEFAULT_MAX_BUFFER: usize = DEFAULT_LOOKAHEAD_SIZE;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum WritePhase { Scanning, Streaming }
|
||||
|
||||
struct WriteState {
|
||||
demuxer: TsDemuxer,
|
||||
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
|
||||
writer: Option<Box<dyn WriteSeek>>,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
tracks: Vec<MkvTrack>,
|
||||
lookahead: LookaheadBuffer,
|
||||
phase: WritePhase,
|
||||
video_pending: usize,
|
||||
}
|
||||
|
||||
struct ReadState {
|
||||
reader: Box<dyn ReadSeek>,
|
||||
buf: Vec<u8>,
|
||||
pos: usize,
|
||||
len: usize,
|
||||
cluster_ts_ms: i64,
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Write(WriteState),
|
||||
Read(ReadState),
|
||||
}
|
||||
|
||||
/// Matroska container stream.
|
||||
pub struct MkvStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
max_buffer: usize,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl MkvStream {
|
||||
/// Create for writing. BD-TS bytes written to this stream produce MKV output.
|
||||
pub fn new(writer: impl Write + Seek + 'static) -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
mode: Mode::Write(WriteState {
|
||||
demuxer: TsDemuxer::new(&[]),
|
||||
muxer: None,
|
||||
writer: Some(Box::new(writer)),
|
||||
parsers: Vec::new(),
|
||||
pid_to_track: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
lookahead: LookaheadBuffer::new(DEFAULT_MAX_BUFFER),
|
||||
phase: WritePhase::Scanning,
|
||||
video_pending: 0,
|
||||
}),
|
||||
max_buffer: DEFAULT_MAX_BUFFER,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set stream metadata. Returns self for chaining.
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
if let Mode::Write(ref mut ws) = self.mode {
|
||||
let mut pids = Vec::new();
|
||||
for s in &dt.streams {
|
||||
let (pid, track, parser) = match s {
|
||||
crate::disc::Stream::Video(v) => {
|
||||
ws.video_pending += 1;
|
||||
(v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec))
|
||||
}
|
||||
crate::disc::Stream::Audio(a) => {
|
||||
(a.pid, MkvTrack::audio(a), codec::parser_for_codec(a.codec))
|
||||
}
|
||||
crate::disc::Stream::Subtitle(s) => {
|
||||
(s.pid, MkvTrack::subtitle(s), codec::parser_for_codec(s.codec))
|
||||
}
|
||||
};
|
||||
let idx = ws.tracks.len();
|
||||
pids.push(pid);
|
||||
ws.pid_to_track.push((pid, idx));
|
||||
ws.parsers.push((pid, parser));
|
||||
ws.tracks.push(track);
|
||||
}
|
||||
ws.demuxer = TsDemuxer::new(&pids);
|
||||
}
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set lookahead buffer size. Returns self.
|
||||
pub fn max_buffer(mut self, size: usize) -> Self {
|
||||
self.max_buffer = size;
|
||||
if let Mode::Write(ref mut ws) = self.mode {
|
||||
ws.lookahead = LookaheadBuffer::new(size);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Open an MKV file for reading.
|
||||
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
|
||||
let disc_title = parse_mkv_header(&mut reader)?;
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mode: Mode::Read(ReadState {
|
||||
reader: Box::new(reader),
|
||||
buf: Vec::new(),
|
||||
pos: 0,
|
||||
len: 0,
|
||||
cluster_ts_ms: 0,
|
||||
}),
|
||||
max_buffer: 0,
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for MkvStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if self.finished { return Ok(()); }
|
||||
self.finished = true;
|
||||
if let Mode::Write(ref mut ws) = self.mode {
|
||||
// Flush remaining PES packets
|
||||
if let Some(ref mut muxer) = ws.muxer {
|
||||
for pes in &ws.demuxer.flush() {
|
||||
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
// Write cues and finalize
|
||||
if let Some(muxer) = ws.muxer.take() {
|
||||
muxer.finish()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────
|
||||
|
||||
impl Write for MkvStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let dt = &self.disc_title;
|
||||
let ws = match self.mode {
|
||||
Mode::Write(ref mut ws) => ws,
|
||||
Mode::Read(_) => return Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for reading")),
|
||||
};
|
||||
|
||||
match ws.phase {
|
||||
WritePhase::Scanning => {
|
||||
// Feed demuxer for codec detection
|
||||
let packets = ws.demuxer.feed(buf);
|
||||
for pes in &packets {
|
||||
if let Some((_, p)) = ws.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
let _ = p.parse(pes);
|
||||
}
|
||||
}
|
||||
|
||||
let state = ws.lookahead.push(buf);
|
||||
|
||||
// Check if all video codec headers found
|
||||
if check_codec_private(ws) {
|
||||
ws.lookahead.mark_ready();
|
||||
begin_streaming(ws, dt)?;
|
||||
return Ok(buf.len());
|
||||
}
|
||||
|
||||
match state {
|
||||
LookaheadState::Collecting | LookaheadState::Ready => Ok(buf.len()),
|
||||
LookaheadState::Overflow => Err(io::Error::new(
|
||||
io::ErrorKind::OutOfMemory,
|
||||
"no codec headers found within lookahead buffer",
|
||||
)),
|
||||
}
|
||||
}
|
||||
WritePhase::Streaming => {
|
||||
let packets = ws.demuxer.feed(buf);
|
||||
if let Some(ref mut muxer) = ws.muxer {
|
||||
for pes in &packets {
|
||||
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> { Ok(()) }
|
||||
}
|
||||
|
||||
// ── Read ───────────────────────────────────────────────────────
|
||||
|
||||
impl Read for MkvStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let rs = match self.mode {
|
||||
Mode::Read(ref mut rs) => rs,
|
||||
Mode::Write(_) => return Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for writing")),
|
||||
};
|
||||
|
||||
// Drain internal buffer first
|
||||
if rs.pos < rs.len {
|
||||
let n = (rs.len - rs.pos).min(buf.len());
|
||||
buf[..n].copy_from_slice(&rs.buf[rs.pos..rs.pos + n]);
|
||||
rs.pos += n;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
// Read next element from MKV
|
||||
loop {
|
||||
let (id, size, _) = match ebml::read_element_header(&mut rs.reader) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(0),
|
||||
};
|
||||
|
||||
match id {
|
||||
ebml::CLUSTER => continue,
|
||||
ebml::CLUSTER_TIMESTAMP => {
|
||||
rs.cluster_ts_ms = ebml::read_uint_val(&mut rs.reader, size as usize)? as i64;
|
||||
continue;
|
||||
}
|
||||
ebml::SIMPLE_BLOCK => {
|
||||
let block = ebml::read_binary_val(&mut rs.reader, size as usize)?;
|
||||
if block.len() < 4 { continue; }
|
||||
|
||||
let (track, vl) = block_vint(&block);
|
||||
if vl + 3 > block.len() { continue; }
|
||||
|
||||
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
|
||||
let frame = &block[vl + 3..];
|
||||
let pts_ms = rs.cluster_ts_ms + rel_ts as i64;
|
||||
|
||||
rs.buf.clear();
|
||||
frame_to_ts(&mut rs.buf, track as u16, pts_ms, frame);
|
||||
rs.pos = 0;
|
||||
rs.len = rs.buf.len();
|
||||
|
||||
if rs.len > 0 {
|
||||
let n = rs.len.min(buf.len());
|
||||
buf[..n].copy_from_slice(&rs.buf[..n]);
|
||||
rs.pos = n;
|
||||
return Ok(n);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if size != u64::MAX && size > 0 {
|
||||
rs.reader.seek(SeekFrom::Current(size as i64))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write internals ────────────────────────────────────────────
|
||||
|
||||
fn check_codec_private(ws: &mut WriteState) -> bool {
|
||||
if ws.video_pending == 0 { return true; }
|
||||
for (pid, parser) in &ws.parsers {
|
||||
if let Some(cp) = parser.codec_private() {
|
||||
if let Some((_, idx)) = ws.pid_to_track.iter().find(|(p, _)| p == pid) {
|
||||
if ws.tracks[*idx].codec_private.is_none() {
|
||||
ws.tracks[*idx].codec_private = Some(cp);
|
||||
ws.video_pending -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ws.video_pending == 0
|
||||
}
|
||||
|
||||
fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> {
|
||||
let writer = ws.writer.take()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "writer already consumed"))?;
|
||||
|
||||
ws.muxer = Some(MkvMuxer::new(writer, &ws.tracks, Some(&dt.playlist), dt.duration_secs)?);
|
||||
ws.phase = WritePhase::Streaming;
|
||||
|
||||
// Re-parse buffered data through a fresh demuxer
|
||||
let buffered = ws.lookahead.drain();
|
||||
if !buffered.is_empty() {
|
||||
let pids: Vec<u16> = ws.pid_to_track.iter().map(|(pid, _)| *pid).collect();
|
||||
let mut temp = TsDemuxer::new(&pids);
|
||||
let packets = temp.feed(&buffered);
|
||||
if let Some(ref mut muxer) = ws.muxer {
|
||||
for pes in &packets {
|
||||
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_pes(
|
||||
pid_to_track: &[(u16, usize)],
|
||||
parsers: &mut [(u16, Box<dyn CodecParser>)],
|
||||
muxer: &mut MkvMuxer<Box<dyn WriteSeek>>,
|
||||
pes: &super::ts::PesPacket,
|
||||
) -> io::Result<()> {
|
||||
let idx = match pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
|
||||
Some((_, idx)) => *idx,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let parser = match parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
Some((_, p)) => p,
|
||||
None => return Ok(()),
|
||||
};
|
||||
for frame in parser.parse(pes) {
|
||||
muxer.write_frame(idx, frame.pts_ns, frame.keyframe, &frame.data)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── MKV header parsing (read side) ────────────────────────────
|
||||
|
||||
fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
|
||||
let mut title = String::new();
|
||||
let mut duration_ms = 0.0f64;
|
||||
let mut ts_scale: u64 = 1_000_000;
|
||||
let mut streams: Vec<crate::disc::Stream> = Vec::new();
|
||||
|
||||
let (id, size, _) = ebml::read_element_header(r)?;
|
||||
if id != ebml::EBML { return Err(io::Error::new(io::ErrorKind::InvalidData, "not EBML")); }
|
||||
r.seek(SeekFrom::Current(size as i64))?;
|
||||
|
||||
let (id, _, _) = ebml::read_element_header(r)?;
|
||||
if id != ebml::SEGMENT { return Err(io::Error::new(io::ErrorKind::InvalidData, "no Segment")); }
|
||||
|
||||
let (mut got_info, mut got_tracks) = (false, false);
|
||||
|
||||
loop {
|
||||
if got_info && got_tracks { break; }
|
||||
let (id, size, _) = match ebml::read_element_header(r) { Ok(h) => h, Err(_) => break };
|
||||
|
||||
match id {
|
||||
ebml::INFO => {
|
||||
let end = r.stream_position()? + size;
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
match cid {
|
||||
ebml::TIMESTAMP_SCALE => ts_scale = ebml::read_uint_val(r, cs as usize)?,
|
||||
ebml::DURATION => duration_ms = ebml::read_float_val(r, cs as usize)?,
|
||||
ebml::TITLE => title = ebml::read_string_val(r, cs as usize)?,
|
||||
_ => { r.seek(SeekFrom::Current(cs as i64))?; }
|
||||
}
|
||||
}
|
||||
got_info = true;
|
||||
}
|
||||
ebml::TRACKS => {
|
||||
let end = r.stream_position()? + size;
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
if cid == ebml::TRACK_ENTRY {
|
||||
if let Some(s) = parse_track(r, cs)? { streams.push(s); }
|
||||
} else { r.seek(SeekFrom::Current(cs as i64))?; }
|
||||
}
|
||||
got_tracks = true;
|
||||
}
|
||||
ebml::CLUSTER => break,
|
||||
_ if size != u64::MAX => { r.seek(SeekFrom::Current(size as i64))?; }
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DiscTitle {
|
||||
playlist: title,
|
||||
duration_secs: duration_ms * (ts_scale as f64) / 1_000_000_000.0,
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate::disc::Stream>> {
|
||||
let end = r.stream_position()? + size;
|
||||
let (mut ttype, mut tnum) = (0u64, 0u16);
|
||||
let (mut codec_id, mut lang, mut name) = (String::new(), String::from("und"), String::new());
|
||||
let (mut ph, mut sr, mut ch, mut forced) = (0u32, 0.0f64, 0u8, false);
|
||||
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
match cid {
|
||||
ebml::TRACK_NUMBER => tnum = ebml::read_uint_val(r, cs as usize)? as u16,
|
||||
ebml::TRACK_TYPE => ttype = ebml::read_uint_val(r, cs as usize)?,
|
||||
ebml::CODEC_ID => codec_id = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::LANGUAGE => lang = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::TRACK_NAME => name = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::FLAG_FORCED => forced = ebml::read_uint_val(r, cs as usize)? != 0,
|
||||
ebml::VIDEO => {
|
||||
let ve = r.stream_position()? + cs;
|
||||
while r.stream_position()? < ve {
|
||||
let (vid, vs, _) = ebml::read_element_header(r)?;
|
||||
if vid == ebml::PIXEL_HEIGHT { ph = ebml::read_uint_val(r, vs as usize)? as u32; }
|
||||
else { r.seek(SeekFrom::Current(vs as i64))?; }
|
||||
}
|
||||
}
|
||||
ebml::AUDIO => {
|
||||
let ae = r.stream_position()? + cs;
|
||||
while r.stream_position()? < ae {
|
||||
let (aid, as_, _) = ebml::read_element_header(r)?;
|
||||
match aid {
|
||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
||||
ebml::CHANNELS => ch = ebml::read_uint_val(r, as_ as usize)? as u8,
|
||||
_ => { r.seek(SeekFrom::Current(as_ as i64))?; }
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => { r.seek(SeekFrom::Current(cs as i64))?; }
|
||||
}
|
||||
}
|
||||
|
||||
let codec = match codec_id.as_str() {
|
||||
"V_MPEGH/ISO/HEVC" => Codec::Hevc, "V_MPEG4/ISO/AVC" => Codec::H264,
|
||||
"V_MS/VFW/FOURCC" => Codec::Vc1, "V_MPEG2" => Codec::Mpeg2,
|
||||
"A_AC3" => Codec::Ac3, "A_EAC3" => Codec::Ac3Plus,
|
||||
"A_TRUEHD" => Codec::TrueHd, "A_DTS" => Codec::Dts,
|
||||
"A_PCM/INT/BIG" => Codec::Lpcm, "S_HDMV/PGS" => Codec::Pgs,
|
||||
_ => Codec::Unknown(0),
|
||||
};
|
||||
let res = format!("{}p", ph);
|
||||
let chs: String = match ch { 8 => "7.1", 6 => "5.1", 2 => "stereo", 1 => "mono", _ => "5.1" }.into();
|
||||
let srs: String = if sr >= 96000.0 { "96kHz" } else { "48kHz" }.into();
|
||||
|
||||
Ok(match ttype {
|
||||
1 => Some(crate::disc::Stream::Video(VideoStream {
|
||||
pid: tnum, codec, resolution: res, frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, secondary: false, label: name,
|
||||
})),
|
||||
2 => Some(crate::disc::Stream::Audio(AudioStream {
|
||||
pid: tnum, codec, channels: chs, language: lang, sample_rate: srs,
|
||||
secondary: false, label: name,
|
||||
})),
|
||||
17 => Some(crate::disc::Stream::Subtitle(SubtitleStream {
|
||||
pid: tnum, codec, language: lang, forced,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
// ── BD-TS frame wrapping (read side) ──────────────────────────
|
||||
|
||||
fn block_vint(d: &[u8]) -> (u64, usize) {
|
||||
if d.is_empty() { return (0, 0); }
|
||||
if d[0] & 0x80 != 0 { return ((d[0] & 0x7F) as u64, 1); }
|
||||
if d[0] & 0x40 != 0 && d.len() >= 2 {
|
||||
return ((((d[0] & 0x3F) as u64) << 8) | d[1] as u64, 2);
|
||||
}
|
||||
(0, 1)
|
||||
}
|
||||
|
||||
fn frame_to_ts(out: &mut Vec<u8>, track: u16, pts_ms: i64, data: &[u8]) {
|
||||
let pid = if track == 1 { 0x1011 } else { 0x1100 + (track - 2) as u16 };
|
||||
let stream_id: u8 = if track == 1 { 0xE0 } else { 0xBD };
|
||||
let pts = encode_pts(pts_ms * 90);
|
||||
let hdr = [0x00, 0x00, 0x01, stream_id, 0x00, 0x00, 0x80, 0x80, 0x05];
|
||||
|
||||
let mut pes = Vec::with_capacity(hdr.len() + pts.len() + data.len());
|
||||
pes.extend_from_slice(&hdr);
|
||||
pes.extend_from_slice(&pts);
|
||||
pes.extend_from_slice(data);
|
||||
|
||||
let mut off = 0;
|
||||
let mut pusi = true;
|
||||
while off < pes.len() {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = (pid >> 8) as u8 & 0x1F;
|
||||
if pusi { pkt[5] |= 0x40; pusi = false; }
|
||||
pkt[6] = pid as u8;
|
||||
|
||||
let space = 184;
|
||||
let rem = pes.len() - off;
|
||||
let n = rem.min(space);
|
||||
|
||||
if n < space {
|
||||
let pad = space - n;
|
||||
pkt[7] = 0x30; // AF + payload
|
||||
pkt[8] = pad as u8;
|
||||
if pad > 1 { pkt[9] = 0x00; }
|
||||
for i in 10..(8 + pad).min(192) { pkt[i] = 0xFF; }
|
||||
pkt[8 + pad..8 + pad + n].copy_from_slice(&pes[off..off + n]);
|
||||
} else {
|
||||
pkt[7] = 0x10; // payload only
|
||||
pkt[8..8 + n].copy_from_slice(&pes[off..off + n]);
|
||||
}
|
||||
|
||||
out.extend_from_slice(&pkt);
|
||||
off += n;
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_pts(pts: i64) -> [u8; 5] {
|
||||
let p = pts as u64;
|
||||
[
|
||||
0x21 | ((p >> 29) & 0x0E) as u8,
|
||||
((p >> 22) & 0xFF) as u8,
|
||||
0x01 | ((p >> 14) & 0xFE) as u8,
|
||||
((p >> 7) & 0xFF) as u8,
|
||||
0x01 | ((p << 1) & 0xFE) as u8,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Stream-based I/O pipeline.
|
||||
//!
|
||||
//! All formats are streams. Two URLs, left reads, right writes:
|
||||
//!
|
||||
//! ```text
|
||||
//! freemkv disc:// mkv://Dune.mkv
|
||||
//! freemkv m2ts://Dune.m2ts mkv://Dune.mkv
|
||||
//! freemkv disc:// network://10.0.0.1:9000
|
||||
//! freemkv disc:// stdio://
|
||||
//! freemkv stdio:// mkv://Dune.mkv
|
||||
//! ```
|
||||
//!
|
||||
//! Streams implement `IOStream` for uniform handling:
|
||||
//!
|
||||
//! ```text
|
||||
//! let mut input = open_input("disc://", &opts)?;
|
||||
//! let mut output = open_output("mkv://Dune.mkv", input.info())?;
|
||||
//! io::copy(&mut *input, &mut *output)?;
|
||||
//! output.finish()?;
|
||||
//! ```
|
||||
|
||||
pub mod ebml;
|
||||
pub mod ts;
|
||||
pub mod mkv;
|
||||
pub mod codec;
|
||||
pub mod lookahead;
|
||||
pub mod meta;
|
||||
mod m2ts;
|
||||
mod mkvstream;
|
||||
pub mod network;
|
||||
pub mod disc;
|
||||
pub mod null;
|
||||
pub mod stdio;
|
||||
pub mod iso;
|
||||
pub mod resolve;
|
||||
|
||||
pub use m2ts::M2tsStream;
|
||||
pub use mkvstream::MkvStream;
|
||||
pub use network::NetworkStream;
|
||||
pub use disc::{DiscStream, DiscOptions};
|
||||
pub use null::NullStream;
|
||||
pub use stdio::StdioStream;
|
||||
pub use iso::IsoStream;
|
||||
pub use resolve::{open_input, open_output, parse_url, InputOptions, StreamUrl};
|
||||
|
||||
use std::io::{self, Read, Write, Seek};
|
||||
use crate::disc::DiscTitle;
|
||||
|
||||
/// Common interface for all stream types.
|
||||
///
|
||||
/// A stream can be opened for reading or created for writing.
|
||||
/// Calling the unsupported direction returns an error.
|
||||
pub trait IOStream: Read + Write {
|
||||
/// Get stream metadata.
|
||||
fn info(&self) -> &DiscTitle;
|
||||
|
||||
/// Finalize the stream (flush, write index/cues, close).
|
||||
fn finish(&mut self) -> io::Result<()>;
|
||||
}
|
||||
|
||||
// Combined traits for internal trait objects.
|
||||
pub(crate) trait ReadSeek: Read + Seek {}
|
||||
impl<T: Read + Seek> ReadSeek for T {}
|
||||
|
||||
pub(crate) trait WriteSeek: Write + Seek {}
|
||||
impl<T: Write + Seek> WriteSeek for T {}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! NetworkStream — BD-TS over TCP with embedded metadata.
|
||||
//!
|
||||
//! Write side (sender): connects to a listener, sends FMKV header + BD-TS data.
|
||||
//! Read side (receiver): listens for a connection, reads FMKV header + BD-TS data.
|
||||
//!
|
||||
//! The FMKV metadata header is the same format as M2tsStream uses — so a
|
||||
//! NetworkStream reader can hand off to any output stream (MKV, M2TS, etc.)
|
||||
//! with full metadata (labels, languages, duration).
|
||||
|
||||
use std::io::{self, Read, Write, BufReader, BufWriter};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use super::{IOStream, meta};
|
||||
use crate::disc::DiscTitle;
|
||||
|
||||
/// I/O buffer size for network reads/writes.
|
||||
const NET_BUF_SIZE: usize = 256 * 1024;
|
||||
|
||||
enum Mode {
|
||||
Write {
|
||||
writer: BufWriter<TcpStream>,
|
||||
header_written: bool,
|
||||
},
|
||||
Read {
|
||||
reader: BufReader<TcpStream>,
|
||||
},
|
||||
}
|
||||
|
||||
/// TCP network stream for distributed rip/remux.
|
||||
pub struct NetworkStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl NetworkStream {
|
||||
/// Connect to a remote listener for writing.
|
||||
/// Sends FMKV metadata header on first write.
|
||||
pub fn connect(addr: &str) -> io::Result<Self> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
stream.set_nodelay(true)?;
|
||||
Ok(Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
mode: Mode::Write {
|
||||
writer: BufWriter::with_capacity(NET_BUF_SIZE, stream),
|
||||
header_written: false,
|
||||
},
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set stream metadata (for write side). Returns self for chaining.
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
/// Listen for an incoming connection and read from it.
|
||||
/// Extracts FMKV metadata header from the sender.
|
||||
pub fn listen(addr: &str) -> io::Result<Self> {
|
||||
let listener = TcpListener::bind(addr)?;
|
||||
let (stream, _peer) = listener.accept()?;
|
||||
stream.set_nodelay(true)?;
|
||||
let mut reader = BufReader::with_capacity(NET_BUF_SIZE, stream);
|
||||
|
||||
// Read FMKV metadata header (inline, since TcpStream doesn't impl Seek)
|
||||
let disc_title = meta::read_header_from_stream(&mut reader)?
|
||||
.ok_or_else(|| io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"no FMKV metadata header from sender",
|
||||
))?
|
||||
.to_title();
|
||||
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mode: Mode::Read { reader },
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for NetworkStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if self.finished { return Ok(()); }
|
||||
self.finished = true;
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()?;
|
||||
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for NetworkStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
Mode::Write { ref mut writer, ref mut header_written } => {
|
||||
if !*header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
meta::write_header(&mut *writer, &m)?;
|
||||
}
|
||||
*header_written = true;
|
||||
}
|
||||
writer.write(buf)
|
||||
}
|
||||
Mode::Read { .. } => Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for reading")),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for NetworkStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
Mode::Read { ref mut reader } => reader.read(buf),
|
||||
Mode::Write { .. } => Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for writing")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! NullStream — discards all data. Write-only. For benchmarking.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use super::IOStream;
|
||||
use crate::disc::DiscTitle;
|
||||
|
||||
/// Null stream — accepts writes, discards data. For benchmarking rip speed.
|
||||
pub struct NullStream {
|
||||
disc_title: DiscTitle,
|
||||
bytes_written: u64,
|
||||
}
|
||||
|
||||
impl NullStream {
|
||||
pub fn new() -> Self {
|
||||
Self { disc_title: DiscTitle::empty(), bytes_written: 0 }
|
||||
}
|
||||
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bytes_written(&self) -> u64 { self.bytes_written }
|
||||
}
|
||||
|
||||
impl IOStream for NullStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
fn finish(&mut self) -> io::Result<()> { Ok(()) }
|
||||
}
|
||||
|
||||
impl Write for NullStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.bytes_written += buf.len() as u64;
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> { Ok(()) }
|
||||
}
|
||||
|
||||
impl Read for NullStream {
|
||||
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "null stream is write-only"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Stream URL resolver — parses URL strings into IOStream instances.
|
||||
//!
|
||||
//! Format: `scheme://path`
|
||||
//!
|
||||
//! | Scheme | Input | Output | Path |
|
||||
//! |--------|-------|--------|------|
|
||||
//! | disc:// | Yes | -- | empty (auto-detect) or /dev/sgN |
|
||||
//! | m2ts:// | Yes | Yes | file path (required) |
|
||||
//! | mkv:// | Yes | Yes | file path (required) |
|
||||
//! | network:// | Yes (listen) | Yes (connect) | host:port (required) |
|
||||
//! | stdio:// | Yes (stdin) | Yes (stdout) | empty |
|
||||
//! | iso:// | Yes | -- | file path (required) |
|
||||
//! | null:// | -- | Yes | empty |
|
||||
//!
|
||||
//! Bare paths without a scheme are rejected.
|
||||
|
||||
use std::io::{self, BufReader, BufWriter};
|
||||
use std::path::Path;
|
||||
use super::{IOStream, M2tsStream, MkvStream};
|
||||
use super::network::NetworkStream;
|
||||
use super::null::NullStream;
|
||||
use super::stdio::StdioStream;
|
||||
use super::iso::IsoStream;
|
||||
use super::disc::{DiscStream, DiscOptions};
|
||||
use crate::disc::DiscTitle;
|
||||
|
||||
/// I/O buffer size for file streams.
|
||||
const IO_BUF_SIZE: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// MKV lookahead buffer size.
|
||||
const MKV_LOOKAHEAD: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Parsed stream URL.
|
||||
pub struct StreamUrl {
|
||||
pub scheme: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Parse a URL string into scheme + path.
|
||||
///
|
||||
/// All URLs must use the `scheme://path` format. Bare paths are not supported.
|
||||
///
|
||||
/// ```text
|
||||
/// disc:// → scheme="disc", path=""
|
||||
/// disc:///dev/sg4 → scheme="disc", path="/dev/sg4"
|
||||
/// m2ts:///tmp/Dune.m2ts → scheme="m2ts", path="/tmp/Dune.m2ts"
|
||||
/// mkv://Dune.mkv → scheme="mkv", path="Dune.mkv"
|
||||
/// network://10.0.0.1:9000 → scheme="network", path="10.0.0.1:9000"
|
||||
/// null:// → scheme="null", path=""
|
||||
/// ```
|
||||
pub fn parse_url(url: &str) -> StreamUrl {
|
||||
if let Some(rest) = url.strip_prefix("disc://") {
|
||||
return StreamUrl { scheme: "disc".into(), path: rest.to_string() };
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("m2ts://") {
|
||||
return StreamUrl { scheme: "m2ts".into(), path: rest.to_string() };
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("mkv://") {
|
||||
return StreamUrl { scheme: "mkv".into(), path: rest.to_string() };
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("network://") {
|
||||
return StreamUrl { scheme: "network".into(), path: rest.to_string() };
|
||||
}
|
||||
if url == "null://" || url.starts_with("null://") {
|
||||
return StreamUrl { scheme: "null".into(), path: String::new() };
|
||||
}
|
||||
if url == "stdio://" || url.starts_with("stdio://") {
|
||||
return StreamUrl { scheme: "stdio".into(), path: String::new() };
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("iso://") {
|
||||
return StreamUrl { scheme: "iso".into(), path: rest.to_string() };
|
||||
}
|
||||
|
||||
StreamUrl { scheme: "unknown".into(), path: url.to_string() }
|
||||
}
|
||||
|
||||
/// Validate that a file path is non-empty and has a filename component.
|
||||
fn validate_file_path(path: &str, scheme: &str) -> io::Result<()> {
|
||||
if path.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("{}:// requires a file path (e.g. {}://movie.{})", scheme, scheme, scheme)));
|
||||
}
|
||||
let p = Path::new(path);
|
||||
if p.file_name().is_none() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("{}://{} is not a valid file path — must include a filename", scheme, path)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that a network address has host:port format.
|
||||
fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
if addr.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
"network:// requires host:port (e.g. network://0.0.0.0:9000)"));
|
||||
}
|
||||
if !addr.contains(':') {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("network://{} missing port — use network://{}:PORT", addr, addr)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a stream URL for reading (source).
|
||||
pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream>> {
|
||||
let parsed = parse_url(url);
|
||||
|
||||
match parsed.scheme.as_str() {
|
||||
"disc" => {
|
||||
let disc_opts = DiscOptions {
|
||||
device: if parsed.path.is_empty() { None } else { Some(parsed.path) },
|
||||
keydb_path: opts.keydb_path.clone(),
|
||||
title_index: opts.title_index,
|
||||
};
|
||||
let stream = DiscStream::open(disc_opts)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
"m2ts" => {
|
||||
validate_file_path(&parsed.path, "m2ts")?;
|
||||
let file = std::fs::File::open(&parsed.path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("m2ts://{}: {}", parsed.path, e)))?;
|
||||
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::open(reader)?))
|
||||
}
|
||||
"mkv" => {
|
||||
validate_file_path(&parsed.path, "mkv")?;
|
||||
let file = std::fs::File::open(&parsed.path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("mkv://{}: {}", parsed.path, e)))?;
|
||||
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::open(reader)?))
|
||||
}
|
||||
"network" => {
|
||||
validate_network_addr(&parsed.path)?;
|
||||
Ok(Box::new(NetworkStream::listen(&parsed.path)?))
|
||||
}
|
||||
"stdio" => {
|
||||
Ok(Box::new(StdioStream::input()))
|
||||
}
|
||||
"iso" => {
|
||||
validate_file_path(&parsed.path, "iso")?;
|
||||
let scan_opts = match &opts.keydb_path {
|
||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
Ok(Box::new(IsoStream::open(&parsed.path, opts.title_index, &scan_opts)?))
|
||||
}
|
||||
"null" => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
"null:// is write-only — cannot use as input"))
|
||||
}
|
||||
"unknown" => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", parsed.path)))
|
||||
}
|
||||
_ => Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("unknown scheme: {}://", parsed.scheme))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a stream URL for writing (destination).
|
||||
pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>> {
|
||||
let parsed = parse_url(url);
|
||||
|
||||
match parsed.scheme.as_str() {
|
||||
"disc" => {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"disc:// is read-only — cannot use as output"))
|
||||
}
|
||||
"iso" => {
|
||||
validate_file_path(&parsed.path, "iso")?;
|
||||
Ok(Box::new(IsoStream::create(&parsed.path)?.meta(meta)))
|
||||
}
|
||||
"null" => {
|
||||
Ok(Box::new(NullStream::new().meta(meta)))
|
||||
}
|
||||
"stdio" => {
|
||||
Ok(Box::new(StdioStream::output().meta(meta)))
|
||||
}
|
||||
"m2ts" => {
|
||||
validate_file_path(&parsed.path, "m2ts")?;
|
||||
let file = std::fs::File::create(&parsed.path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("m2ts://{}: {}", parsed.path, e)))?;
|
||||
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::new(writer).meta(meta)))
|
||||
}
|
||||
"mkv" => {
|
||||
validate_file_path(&parsed.path, "mkv")?;
|
||||
let file = std::fs::File::create(&parsed.path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("mkv://{}: {}", parsed.path, e)))?;
|
||||
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(MKV_LOOKAHEAD)))
|
||||
}
|
||||
"network" => {
|
||||
validate_network_addr(&parsed.path)?;
|
||||
Ok(Box::new(NetworkStream::connect(&parsed.path)?.meta(meta)))
|
||||
}
|
||||
"unknown" => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", parsed.path)))
|
||||
}
|
||||
_ => Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("unknown scheme: {}://", parsed.scheme))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for opening an input stream.
|
||||
pub struct InputOptions {
|
||||
pub keydb_path: Option<String>,
|
||||
pub title_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for InputOptions {
|
||||
fn default() -> Self {
|
||||
Self { keydb_path: None, title_index: None }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! StdioStream — raw byte pipe via stdin/stdout. Format-agnostic.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use super::IOStream;
|
||||
use crate::disc::DiscTitle;
|
||||
|
||||
/// Stdio stream — reads from stdin, writes to stdout.
|
||||
///
|
||||
/// No headers, no metadata, no format opinions. Just bytes.
|
||||
/// The format is determined by whatever is on the other end.
|
||||
pub struct StdioStream {
|
||||
disc_title: DiscTitle,
|
||||
reader: Option<io::Stdin>,
|
||||
writer: Option<io::Stdout>,
|
||||
}
|
||||
|
||||
impl StdioStream {
|
||||
/// Create a stdio stream for reading (stdin).
|
||||
pub fn input() -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
reader: Some(io::stdin()),
|
||||
writer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a stdio stream for writing (stdout).
|
||||
pub fn output() -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
reader: None,
|
||||
writer: Some(io::stdout()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set metadata (for output — passed through from input side).
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for StdioStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(ref mut w) = self.writer {
|
||||
w.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for StdioStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self.reader {
|
||||
Some(ref mut r) => r.read(buf),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"stdio:// opened for output — cannot read")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for StdioStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.writer {
|
||||
Some(ref mut w) => w.write(buf),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"stdio:// opened for input — cannot write")),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.writer {
|
||||
Some(ref mut w) => w.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
+526
@@ -0,0 +1,526 @@
|
||||
//! BD Transport Stream demuxer.
|
||||
//!
|
||||
//! Blu-ray uses 192-byte TS packets (not standard 188):
|
||||
//! - 4-byte TP_extra_header (arrival timestamp + copy permission)
|
||||
//! - 188-byte standard MPEG-TS packet
|
||||
//!
|
||||
//! This demuxer extracts PES packets from selected PIDs, with PTS/DTS timestamps.
|
||||
|
||||
/// BD transport stream packet size (4-byte extra header + 188-byte TS).
|
||||
const BD_TS_PACKET_SIZE: usize = 192;
|
||||
|
||||
/// Standard TS packet size.
|
||||
const TS_PACKET_SIZE: usize = 188;
|
||||
|
||||
/// TS sync byte.
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
|
||||
/// Size of head buffer for PTS/stream scanning (1 MB).
|
||||
const SCAN_HEAD_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Size of tail buffer for last-PTS scanning (2 MB).
|
||||
const SCAN_TAIL_SIZE: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// A reassembled PES packet with timestamp info.
|
||||
#[derive(Debug)]
|
||||
pub struct PesPacket {
|
||||
/// MPEG-TS PID this packet belongs to.
|
||||
pub pid: u16,
|
||||
/// Presentation timestamp in 90kHz ticks (if present).
|
||||
pub pts: Option<i64>,
|
||||
/// Decode timestamp in 90kHz ticks (if present).
|
||||
pub dts: Option<i64>,
|
||||
/// Elementary stream data (video frame, audio frame, subtitle segment, etc.).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Per-PID PES reassembly state.
|
||||
struct PesAssembler {
|
||||
pid: u16,
|
||||
buffer: Vec<u8>,
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl PesAssembler {
|
||||
fn new(pid: u16) -> Self {
|
||||
Self {
|
||||
pid,
|
||||
buffer: Vec::with_capacity(256 * 1024),
|
||||
pts: None,
|
||||
dts: None,
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new PES packet. Returns the completed previous packet (if any).
|
||||
fn start(&mut self, pts: Option<i64>, dts: Option<i64>) -> Option<PesPacket> {
|
||||
let completed = if self.active && !self.buffer.is_empty() {
|
||||
Some(PesPacket {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(256 * 1024)),
|
||||
})
|
||||
} else {
|
||||
self.buffer.clear();
|
||||
None
|
||||
};
|
||||
self.pts = pts;
|
||||
self.dts = dts;
|
||||
self.active = true;
|
||||
completed
|
||||
}
|
||||
|
||||
/// Append payload data to the current PES packet.
|
||||
fn push(&mut self, data: &[u8]) {
|
||||
if self.active {
|
||||
self.buffer.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush remaining data as a PES packet.
|
||||
fn flush(&mut self) -> Option<PesPacket> {
|
||||
if self.active && !self.buffer.is_empty() {
|
||||
self.active = false;
|
||||
Some(PesPacket {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::take(&mut self.buffer),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BD Transport Stream demuxer.
|
||||
pub struct TsDemuxer {
|
||||
assemblers: Vec<PesAssembler>,
|
||||
pid_index: [i16; 8192], // PID → index into assemblers, -1 = not tracked
|
||||
remainder: Vec<u8>, // leftover bytes from previous feed() call
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
/// Create a new demuxer tracking the given PIDs.
|
||||
pub fn new(pids: &[u16]) -> Self {
|
||||
let mut pid_index = [-1i16; 8192];
|
||||
let mut assemblers = Vec::with_capacity(pids.len());
|
||||
for (i, &pid) in pids.iter().enumerate() {
|
||||
pid_index[pid as usize] = i as i16;
|
||||
assemblers.push(PesAssembler::new(pid));
|
||||
}
|
||||
Self { assemblers, pid_index, remainder: Vec::new() }
|
||||
}
|
||||
|
||||
/// Feed a chunk of BD transport stream data. Handles non-192-byte-aligned input
|
||||
/// by buffering leftover bytes between calls. Returns completed PES packets.
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
|
||||
let mut completed = Vec::new();
|
||||
|
||||
// Prepend any remainder from previous call
|
||||
let work: &[u8];
|
||||
let mut combined: Vec<u8> = Vec::new();
|
||||
if !self.remainder.is_empty() {
|
||||
combined.reserve(self.remainder.len() + data.len());
|
||||
combined.extend_from_slice(&self.remainder);
|
||||
combined.extend_from_slice(data);
|
||||
self.remainder.clear();
|
||||
work = &combined;
|
||||
} else {
|
||||
work = data;
|
||||
}
|
||||
|
||||
let mut offset = 0;
|
||||
|
||||
while offset + BD_TS_PACKET_SIZE <= work.len() {
|
||||
let packet = &work[offset..offset + BD_TS_PACKET_SIZE];
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
|
||||
// Skip 4-byte TP_extra_header, check sync byte
|
||||
if packet[4] != SYNC_BYTE {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = &packet[4..]; // 188-byte standard TS packet
|
||||
|
||||
// Parse TS header
|
||||
let pid = (((ts[1] & 0x1F) as u16) << 8) | ts[2] as u16;
|
||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||
let adaptation = (ts[3] >> 4) & 0x03;
|
||||
|
||||
// Check if we're tracking this PID
|
||||
let idx = self.pid_index[pid as usize];
|
||||
if idx < 0 {
|
||||
continue;
|
||||
}
|
||||
let asm = &mut self.assemblers[idx as usize];
|
||||
|
||||
// Find payload start (skip adaptation field if present)
|
||||
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
|
||||
// Adaptation field present
|
||||
let af_len = ts[4] as usize;
|
||||
5 + af_len
|
||||
} else {
|
||||
4
|
||||
};
|
||||
|
||||
if payload_start >= TS_PACKET_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
// No payload
|
||||
if adaptation == 0x02 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = &ts[payload_start..];
|
||||
|
||||
if pusi {
|
||||
// New PES packet starts here — parse PES header
|
||||
let (pts, dts, pes_data_start) = parse_pes_header(payload);
|
||||
if let Some(prev) = asm.start(pts, dts) {
|
||||
completed.push(prev);
|
||||
}
|
||||
if pes_data_start < payload.len() {
|
||||
asm.push(&payload[pes_data_start..]);
|
||||
}
|
||||
} else {
|
||||
// Continuation of current PES packet
|
||||
asm.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
// Save leftover bytes for next call
|
||||
if offset < work.len() {
|
||||
self.remainder.extend_from_slice(&work[offset..]);
|
||||
}
|
||||
|
||||
completed
|
||||
}
|
||||
|
||||
/// Flush all assemblers, returning any remaining PES packets.
|
||||
pub fn flush(&mut self) -> Vec<PesPacket> {
|
||||
let mut completed = Vec::new();
|
||||
for asm in &mut self.assemblers {
|
||||
if let Some(pkt) = asm.flush() {
|
||||
completed.push(pkt);
|
||||
}
|
||||
}
|
||||
completed
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PES packet header, extracting PTS and DTS.
|
||||
/// Returns (pts, dts, offset_to_elementary_stream_data).
|
||||
fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
// PES packet: 00 00 01 [stream_id] [length:2] [flags...]
|
||||
if data.len() < 9 || data[0] != 0x00 || data[1] != 0x00 || data[2] != 0x01 {
|
||||
return (None, None, 0);
|
||||
}
|
||||
|
||||
let stream_id = data[3];
|
||||
|
||||
// Some stream IDs don't have the standard PES header extension
|
||||
// (program_stream_map, padding, private_stream_2, ECM, EMM, etc.)
|
||||
if stream_id == 0xBC || stream_id == 0xBE || stream_id == 0xBF
|
||||
|| stream_id == 0xF0 || stream_id == 0xF1 || stream_id == 0xFF
|
||||
{
|
||||
return (None, None, 6);
|
||||
}
|
||||
|
||||
// Standard PES header: [6] = flags1, [7] = flags2, [8] = header_data_length
|
||||
if data.len() < 9 {
|
||||
return (None, None, 6);
|
||||
}
|
||||
|
||||
let pts_dts_flags = (data[7] >> 6) & 0x03;
|
||||
let header_data_len = data[8] as usize;
|
||||
let data_start = 9 + header_data_len;
|
||||
|
||||
let mut pts = None;
|
||||
let mut dts = None;
|
||||
|
||||
if pts_dts_flags >= 2 && data.len() >= 14 {
|
||||
pts = Some(parse_timestamp(&data[9..14]));
|
||||
}
|
||||
if pts_dts_flags == 3 && data.len() >= 19 {
|
||||
dts = Some(parse_timestamp(&data[14..19]));
|
||||
}
|
||||
|
||||
(pts, dts, data_start)
|
||||
}
|
||||
|
||||
/// Parse a 5-byte PTS/DTS timestamp (33 bits in 90kHz).
|
||||
fn parse_timestamp(data: &[u8]) -> i64 {
|
||||
let b0 = data[0] as i64;
|
||||
let b1 = data[1] as i64;
|
||||
let b2 = data[2] as i64;
|
||||
let b3 = data[3] as i64;
|
||||
let b4 = data[4] as i64;
|
||||
|
||||
((b0 >> 1) & 0x07) << 30
|
||||
| b1 << 22
|
||||
| (b2 >> 1) << 15
|
||||
| b3 << 7
|
||||
| b4 >> 1
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Stream scanning (PAT/PMT → stream list)
|
||||
// ============================================================
|
||||
|
||||
/// Scan BD-TS data for streams by parsing PAT and PMT tables.
|
||||
/// Returns None if no valid program is found.
|
||||
pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
use crate::disc::*;
|
||||
|
||||
// Pass 1: find PMT PID from PAT
|
||||
let mut pat_pmt_pid: Option<u16> = None;
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE { offset += 1; continue; }
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
|
||||
if pid == 0 && pusi {
|
||||
let payload_start = offset + 4 + 4;
|
||||
if payload_start + 12 < data.len() {
|
||||
let pointer = data[payload_start] as usize;
|
||||
let pat_start = payload_start + 1 + pointer;
|
||||
if pat_start + 12 < data.len() && data[pat_start] == 0x00 {
|
||||
let section_len = (((data[pat_start + 1] & 0x0F) as usize) << 8) | data[pat_start + 2] as usize;
|
||||
let entries_start = pat_start + 8;
|
||||
let entries_end = pat_start + 3 + section_len - 4;
|
||||
let mut e = entries_start;
|
||||
while e + 4 <= data.len() && e < entries_end {
|
||||
let prog_num = ((data[e] as u16) << 8) | data[e + 1] as u16;
|
||||
let p = (((data[e + 2] & 0x1F) as u16) << 8) | data[e + 3] as u16;
|
||||
if prog_num != 0 {
|
||||
pat_pmt_pid = Some(p);
|
||||
break;
|
||||
}
|
||||
e += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
|
||||
let pmt_pid = pat_pmt_pid?;
|
||||
|
||||
// Pass 2: parse PMT for stream entries
|
||||
let mut streams = Vec::new();
|
||||
offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE { offset += 1; continue; }
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
|
||||
if pid == pmt_pid && pusi {
|
||||
let payload_start = offset + 4 + 4;
|
||||
if payload_start + 1 >= data.len() { offset += BD_TS_PACKET_SIZE; continue; }
|
||||
let pointer = data[payload_start] as usize;
|
||||
let pmt_start = payload_start + 1 + pointer;
|
||||
if pmt_start + 12 >= data.len() { offset += BD_TS_PACKET_SIZE; continue; }
|
||||
if data[pmt_start] != 0x02 { offset += BD_TS_PACKET_SIZE; continue; }
|
||||
|
||||
let section_len = (((data[pmt_start + 1] & 0x0F) as usize) << 8) | data[pmt_start + 2] as usize;
|
||||
let prog_info_len = (((data[pmt_start + 10] & 0x0F) as usize) << 8) | data[pmt_start + 11] as usize;
|
||||
let mut pos = pmt_start + 12 + prog_info_len;
|
||||
let end = pmt_start + 3 + section_len - 4;
|
||||
|
||||
while pos + 5 <= data.len() && pos < end {
|
||||
let stream_type = data[pos];
|
||||
let es_pid = (((data[pos + 1] & 0x1F) as u16) << 8) | data[pos + 2] as u16;
|
||||
let es_info_len = (((data[pos + 3] & 0x0F) as usize) << 8) | data[pos + 4] as usize;
|
||||
|
||||
let stream = match stream_type {
|
||||
0x1B => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid, codec: Codec::H264,
|
||||
resolution: "1080p".into(), frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: String::new(),
|
||||
})),
|
||||
0x24 => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid, codec: Codec::Hevc,
|
||||
resolution: "2160p".into(), frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: String::new(),
|
||||
})),
|
||||
0xEA => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid, codec: Codec::Vc1,
|
||||
resolution: "1080p".into(), frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: String::new(),
|
||||
})),
|
||||
0x02 => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid, codec: Codec::Mpeg2,
|
||||
resolution: "1080i".into(), frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: String::new(),
|
||||
})),
|
||||
0x81 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid, codec: Codec::Ac3,
|
||||
channels: "5.1".into(), language: "und".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
|
||||
})),
|
||||
0x83 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid, codec: Codec::TrueHd,
|
||||
channels: "5.1".into(), language: "und".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
|
||||
})),
|
||||
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid, codec: Codec::Ac3Plus,
|
||||
channels: "5.1".into(), language: "und".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
|
||||
})),
|
||||
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid, codec: Codec::DtsHdMa,
|
||||
channels: "5.1".into(), language: "und".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
|
||||
})),
|
||||
0x82 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid, codec: Codec::Dts,
|
||||
channels: "5.1".into(), language: "und".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false, label: String::new(),
|
||||
})),
|
||||
0x90 => Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: es_pid, codec: Codec::Pgs,
|
||||
language: "und".into(), forced: false,
|
||||
})),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(s) = stream {
|
||||
streams.push(s);
|
||||
}
|
||||
pos += 5 + es_info_len;
|
||||
}
|
||||
break;
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
|
||||
if streams.is_empty() { None } else { Some(streams) }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PTS scanning utilities (for duration detection)
|
||||
// ============================================================
|
||||
|
||||
/// Find the first PTS for a given PID in BD-TS data.
|
||||
pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE { offset += 1; continue; }
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
if pid == target_pid && pusi {
|
||||
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
|
||||
let afc = (ts[3] >> 4) & 0x03;
|
||||
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
|
||||
if payload_start < TS_PACKET_SIZE {
|
||||
let payload = &ts[payload_start..];
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
return Some(parse_timestamp(&payload[9..14]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the last PTS for a given PID in BD-TS data.
|
||||
pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
let mut last_pts = None;
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE { offset += 1; continue; }
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
if pid == target_pid && pusi {
|
||||
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
|
||||
let afc = (ts[3] >> 4) & 0x03;
|
||||
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
|
||||
if payload_start < TS_PACKET_SIZE {
|
||||
let payload = &ts[payload_start..];
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
last_pts = Some(parse_timestamp(&payload[9..14]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
last_pts
|
||||
}
|
||||
|
||||
/// Scan an m2ts file for duration by reading first and last PTS.
|
||||
/// Returns duration in seconds, or None if PTS cannot be found.
|
||||
/// The reader position is restored after scanning.
|
||||
pub fn scan_duration<R: std::io::Read + std::io::Seek>(r: &mut R, video_pid: u16) -> Option<f64> {
|
||||
use std::io::SeekFrom;
|
||||
|
||||
let start_pos = r.stream_position().ok()?;
|
||||
|
||||
// Read first 1MB for first PTS
|
||||
let mut head_buf = vec![0u8; SCAN_HEAD_SIZE];
|
||||
r.seek(SeekFrom::Start(0)).ok()?;
|
||||
let head_n = r.read(&mut head_buf).ok()?;
|
||||
let first_pts = scan_first_pts(&head_buf[..head_n], video_pid)?;
|
||||
|
||||
// Read last 2MB for last PTS (aligned to 192-byte boundary)
|
||||
let file_size = r.seek(SeekFrom::End(0)).ok()?;
|
||||
let tail_size: u64 = SCAN_TAIL_SIZE as u64;
|
||||
let raw_pos = if file_size > tail_size { file_size - tail_size } else { 0 };
|
||||
let seek_pos = (raw_pos / BD_TS_PACKET_SIZE as u64) * BD_TS_PACKET_SIZE as u64;
|
||||
r.seek(SeekFrom::Start(seek_pos)).ok()?;
|
||||
let mut tail_buf = vec![0u8; tail_size as usize];
|
||||
let tail_n = r.read(&mut tail_buf).ok()?;
|
||||
let last_pts = scan_last_pts(&tail_buf[..tail_n], video_pid)?;
|
||||
|
||||
// Restore reader position
|
||||
let _ = r.seek(SeekFrom::Start(start_pos));
|
||||
|
||||
if last_pts > first_pts {
|
||||
Some((last_pts - first_pts) as f64 / 90000.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_timestamp() {
|
||||
// Example: PTS = 0 → encoded as 21 00 01 00 01
|
||||
let data = [0x21, 0x00, 0x01, 0x00, 0x01];
|
||||
assert_eq!(parse_timestamp(&data), 0);
|
||||
|
||||
// Example: PTS = 90000 (1 second at 90kHz)
|
||||
// Manual encoding: 33 bits = 0x00015F90
|
||||
// This is just a sanity check that the parser doesn't crash
|
||||
let data2 = [0x21, 0x00, 0x07, 0xE9, 0x01]; // approximate
|
||||
let pts = parse_timestamp(&data2);
|
||||
assert!(pts >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_demuxer_empty() {
|
||||
let mut demux = TsDemuxer::new(&[0x1011]);
|
||||
let result = demux.feed(&[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
+11
-57
@@ -1,66 +1,20 @@
|
||||
//! Platform-specific implementations of raw disc access commands.
|
||||
//!
|
||||
//! Each chipset family (MT1959, Pioneer) implements the Platform trait.
|
||||
//! accessed via SCSI READ BUFFER with platform-specific mode and buffer ID.
|
||||
//! Platform-specific drive initialization and disc probing.
|
||||
|
||||
pub mod mt1959;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::ScsiTransport;
|
||||
|
||||
/// Platform trait — raw disc access commands implemented per chipset.
|
||||
///
|
||||
/// Command handlers accessed via READ BUFFER:
|
||||
pub trait Platform {
|
||||
///
|
||||
/// Sends the platform-specific READ BUFFER CDB and verifies
|
||||
/// the response signature bytes.
|
||||
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
pub(crate) trait PlatformDriver {
|
||||
/// Unlock drive + upload firmware if needed.
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Performs a primary READ BUFFER for the configuration data,
|
||||
/// followed by a secondary 4-byte status read.
|
||||
fn read_config(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>>;
|
||||
/// Calibrate drive for this disc. Probes disc surface so the drive's
|
||||
/// firmware learns the optimal speed for each region. After probing
|
||||
/// the drive manages per-zone speeds internally — the host just reads
|
||||
/// at max speed.
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// Handlers 2-3: Read hardware register.
|
||||
///
|
||||
/// `index` selects which register offset from the profile to use.
|
||||
/// Returns 16 bytes of register data extracted from a 36-byte response.
|
||||
fn read_register(&mut self, scsi: &mut dyn ScsiTransport, index: u8) -> Result<[u8; 16]>;
|
||||
|
||||
///
|
||||
/// Probes the disc surface via READ BUFFER sub-commands to build
|
||||
/// a 64-entry speed lookup table for optimal read performance.
|
||||
/// Issues SET CD SPEED at maximum after calibration completes.
|
||||
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Periodic command to maintain the raw access session.
|
||||
fn keepalive(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Verifies the response signature and returns 16 bytes of
|
||||
/// feature/status data.
|
||||
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus>;
|
||||
|
||||
///
|
||||
/// Sends a READ BUFFER command with dynamic sub-command, address,
|
||||
/// and length. Used for disc structure reads and feature queries.
|
||||
fn probe(&mut self, scsi: &mut dyn ScsiTransport, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>>;
|
||||
|
||||
///
|
||||
/// Looks up the LBA in the speed table, issues SET CD SPEED,
|
||||
/// then performs a READ(10) with the raw read flag (0x08).
|
||||
fn read_sectors(&mut self, scsi: &mut dyn ScsiTransport, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
|
||||
|
||||
fn timing(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// Check if raw disc access mode is currently enabled.
|
||||
fn is_unlocked(&self) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveStatus {
|
||||
pub unlocked: bool,
|
||||
pub features: [u8; 16],
|
||||
/// True after successful init().
|
||||
fn is_ready(&self) -> bool;
|
||||
}
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
//! MT1959 platform implementation — covers all LG/ASUS MediaTek drives.
|
||||
//!
|
||||
//! Two variants share this code:
|
||||
//! MT1959-A: mode=0x01, buffer_id=0x44 (handlers 0-9)
|
||||
//! MT1959-B: mode=0x02, buffer_id=0x77 (handlers 4-9, 0-3 are no-ops)
|
||||
//!
|
||||
//! The logic is identical between A and B — only the SCSI READ BUFFER
|
||||
//! mode and buffer ID differ. Per-drive data (signature, register offsets)
|
||||
//! comes from the profile.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
use super::{Platform, DriveStatus};
|
||||
|
||||
/// MT1959 driver state.
|
||||
pub struct Mt1959 {
|
||||
profile: DriveProfile,
|
||||
mode: u8,
|
||||
buffer_id: u8,
|
||||
unlocked: bool,
|
||||
speed_table: [u16; 64],
|
||||
calibrated: bool,
|
||||
}
|
||||
|
||||
impl Mt1959 {
|
||||
pub fn new(profile: DriveProfile) -> Self {
|
||||
let mode = profile.unlock_mode;
|
||||
let buffer_id = profile.unlock_buf_id;
|
||||
Mt1959 {
|
||||
profile,
|
||||
mode,
|
||||
buffer_id,
|
||||
unlocked: false,
|
||||
speed_table: [0u16; 64],
|
||||
calibrated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER CDB for this platform's mode and buffer ID.
|
||||
fn read_buffer_cdb(&self, offset: u32, length: u32) -> [u8; 10] {
|
||||
scsi::build_read_buffer(self.mode, self.buffer_id, offset, length)
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER CDB with a sub-command byte in CDB[3].
|
||||
fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
|
||||
[
|
||||
0x3C,
|
||||
self.mode,
|
||||
self.buffer_id,
|
||||
sub_cmd,
|
||||
(address >> 8) as u8,
|
||||
address as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
length,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
///
|
||||
/// 1. Send READ BUFFER(mode, buffer_id, offset=0, length=64)
|
||||
/// 2. Check response[0:4] matches the profile signature
|
||||
/// 3. Check response[12:16] matches the verification bytes (0x4D4D6B76)
|
||||
fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 64]> {
|
||||
let cdb = self.read_buffer_cdb(0, 64);
|
||||
let mut response = [0u8; 64];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
// Check signature at response[0:4]
|
||||
let got_sig: [u8; 4] = response[0..4].try_into().unwrap();
|
||||
if got_sig != self.profile.signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.signature,
|
||||
got: got_sig,
|
||||
});
|
||||
}
|
||||
|
||||
// Check verification bytes at response[12:16]
|
||||
if &response[12..16] != self.profile.verify.as_slice() {
|
||||
return Err(Error::UnlockFailed { detail: format!(
|
||||
"verify mismatch at [12:16]: {:02x}{:02x}{:02x}{:02x}",
|
||||
response[12], response[13], response[14], response[15]
|
||||
) });
|
||||
}
|
||||
|
||||
self.unlocked = true;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Ensure raw disc access is active, re-enabling if needed.
|
||||
fn ensure_unlocked(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked {
|
||||
self.do_unlock(scsi)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-operation validation with retry.
|
||||
///
|
||||
/// Sends a short READ BUFFER probe, retries up to 5 times to confirm
|
||||
/// the drive is still responding to commands.
|
||||
fn validate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
for _attempt in 0..5 {
|
||||
let cdb = self.read_buffer_cdb(0, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError {
|
||||
opcode: 0x3C,
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up optimal read speed for a given LBA from the calibration table.
|
||||
fn lookup_speed(&self, lba: u32) -> u16 {
|
||||
if !self.calibrated {
|
||||
return 0;
|
||||
}
|
||||
let mut best_speed = 0u16;
|
||||
let mut best_diff = u32::MAX;
|
||||
for &entry in &self.speed_table {
|
||||
if entry == 0 {
|
||||
continue;
|
||||
}
|
||||
let entry_lba = entry as u32;
|
||||
let diff = if lba > entry_lba { lba - entry_lba } else { entry_lba - lba };
|
||||
if diff < best_diff {
|
||||
best_diff = diff;
|
||||
best_speed = entry;
|
||||
}
|
||||
}
|
||||
best_speed
|
||||
}
|
||||
|
||||
/// Send SET CD SPEED command.
|
||||
fn set_cd_speed(&self, scsi: &mut dyn ScsiTransport, speed: u16) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(speed);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform for Mt1959 {
|
||||
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
self.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Primary read: 0x760 (1888) bytes of configuration data.
|
||||
/// Secondary read: 4-byte status appended to the result.
|
||||
fn read_config(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
// Primary config read: 0x760 = 1888 bytes
|
||||
let cdb = self.read_buffer_cdb(0, 0x760);
|
||||
let mut buf = vec![0u8; 0x760];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 30_000)?;
|
||||
buf.truncate(result.bytes_transferred);
|
||||
|
||||
// Secondary: 4-byte status read
|
||||
let cdb2 = self.read_buffer_cdb(0, 4);
|
||||
let mut status = [0u8; 4];
|
||||
scsi.execute(&cdb2, DataDirection::FromDevice, &mut status, 5_000)?;
|
||||
|
||||
buf.extend_from_slice(&status);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Handlers 2-3: Read hardware register at the profile-specified offset.
|
||||
///
|
||||
/// Reads 36 bytes via READ BUFFER and extracts bytes [4:20] as the
|
||||
/// 16-byte register value.
|
||||
fn read_register(&mut self, scsi: &mut dyn ScsiTransport, index: u8) -> Result<[u8; 16]> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
self.validate(scsi)?;
|
||||
|
||||
let offset = *self.profile.register_offsets.get(index as usize)
|
||||
.ok_or_else(|| Error::ProfileNotFound {
|
||||
vendor_id: self.profile.vendor_id.clone(),
|
||||
product_revision: self.profile.product_revision.clone(),
|
||||
vendor_specific: format!("register index {} out of range", index),
|
||||
})?;
|
||||
|
||||
let cdb = scsi::build_read_buffer(self.mode, self.buffer_id, offset, 36);
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&response[4..20]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
///
|
||||
/// Scans disc surface addresses via READ BUFFER sub-command 0x14 to
|
||||
/// build a 64-entry speed lookup table. Issues SET CD SPEED(max) when done.
|
||||
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Initial probe: READ BUFFER sub_cmd=0x12
|
||||
let cdb = self.read_buffer_sub(0x12, 0, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000);
|
||||
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Clear speed table
|
||||
self.speed_table = [0u16; 64];
|
||||
|
||||
// Scan disc surface — probe addresses up to 0x10000, 256 at a time
|
||||
let mut table_idx = 0usize;
|
||||
let mut addr: u32 = 0;
|
||||
while addr < 0x10000 && table_idx < 64 {
|
||||
let cdb = self.read_buffer_sub(0x14, addr as u16, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) {
|
||||
Ok(r) if r.bytes_transferred == 4 => {
|
||||
let val = resp[0];
|
||||
if val > 0 {
|
||||
let speed_entry = ((resp[0] as u16) << 8) | (resp[1] as u16);
|
||||
if speed_entry > 0 {
|
||||
self.speed_table[table_idx] = speed_entry;
|
||||
table_idx += 1;
|
||||
}
|
||||
}
|
||||
addr += 256;
|
||||
}
|
||||
_ => {
|
||||
addr += 256;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set max speed after calibration
|
||||
self.set_cd_speed(scsi, 0xFFFF)?;
|
||||
|
||||
self.calibrated = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keepalive(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Sends READ BUFFER with sub-command 0x13, reads 36 bytes.
|
||||
/// Checks signature at [0:4], returns feature data from [4:20].
|
||||
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
self.validate(scsi)?;
|
||||
|
||||
// READ BUFFER with sub_cmd=0x13, 36 bytes response
|
||||
let cdb = self.read_buffer_sub(0x13, 0, 36);
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
// Verify response signature
|
||||
let got_sig = u32::from_be_bytes(response[0..4].try_into().unwrap());
|
||||
let expected_sig = u32::from_le_bytes(self.profile.signature);
|
||||
|
||||
let mut features = [0u8; 16];
|
||||
features.copy_from_slice(&response[4..20]);
|
||||
|
||||
Ok(DriveStatus {
|
||||
unlocked: got_sig == expected_sig,
|
||||
features,
|
||||
})
|
||||
}
|
||||
|
||||
fn probe(&mut self, scsi: &mut dyn ScsiTransport, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
|
||||
let cdb = [
|
||||
0x3C,
|
||||
self.mode,
|
||||
self.buffer_id,
|
||||
sub_cmd,
|
||||
(address >> 16) as u8,
|
||||
(address >> 8) as u8,
|
||||
address as u8,
|
||||
(length >> 16) as u8,
|
||||
(length >> 8) as u8,
|
||||
length as u8,
|
||||
];
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 30_000)?;
|
||||
buf.truncate(result.bytes_transferred);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
///
|
||||
/// Looks up the LBA in the speed table, issues SET CD SPEED if calibrated,
|
||||
/// then performs READ(10) with the raw read flag (0x08).
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
) -> Result<usize> {
|
||||
if !self.unlocked {
|
||||
return Err(Error::NotUnlocked);
|
||||
}
|
||||
|
||||
// Speed optimization from calibration
|
||||
if self.calibrated {
|
||||
let speed = self.lookup_speed(lba);
|
||||
if speed > 0 {
|
||||
let _ = self.set_cd_speed(scsi, speed);
|
||||
}
|
||||
}
|
||||
|
||||
// READ(10) with raw flag 0x08
|
||||
let cdb = scsi::build_read10_raw(lba, count);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 30_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
fn timing(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_unlocked(&self) -> bool {
|
||||
self.unlocked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! MT1959 platform — shared logic for both variants.
|
||||
|
||||
mod variant_a;
|
||||
mod variant_b;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
use super::PlatformDriver;
|
||||
|
||||
// ── Variant constants ──────────────────────────────────────────────────
|
||||
// Every vendor command: 3C [mode] [buffer_id] [sub_cmd] [addr] ...
|
||||
const MODE_A: u8 = 0x01;
|
||||
const MODE_B: u8 = 0x02;
|
||||
const BUFFER_ID_A: u8 = 0x44;
|
||||
const BUFFER_ID_B: u8 = 0x77;
|
||||
|
||||
// ── SCSI opcodes ──────────────────────────────────────────────────────
|
||||
const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
|
||||
// ── Sub-commands (shared A/B) ─────────────────────────────────────────
|
||||
const SUB_CMD_UNLOCK: u8 = 0x00;
|
||||
const SUB_CMD_INIT: u8 = 0x12;
|
||||
const SUB_CMD_PROBE: u8 = 0x14;
|
||||
const UNLOCK_RESPONSE_SIZE: u8 = 64;
|
||||
const VALIDATE_RESPONSE_SIZE: u8 = 4;
|
||||
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
|
||||
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
|
||||
|
||||
// ── Init address (per disc type) ──────────────────────────────────────
|
||||
const INIT_ADDR_BD: u16 = 0x0100;
|
||||
const INIT_ADDR_UHD: u16 = 0x0200;
|
||||
|
||||
// ── Probe scan ranges ─────────────────────────────────────────────────
|
||||
const PROBE_COARSE_END: u16 = 0x5800;
|
||||
const PROBE_FINE_END: u32 = 0x10000;
|
||||
const PROBE_STEP: u16 = 0x0100;
|
||||
const PROBE_RESPONSE_SIZE: u8 = 4;
|
||||
|
||||
// ── Disc type threshold ───────────────────────────────────────────────
|
||||
const UHD_SECTOR_THRESHOLD: u32 = 25_000_000; // ~50 GB
|
||||
const READ_CAPACITY_RESPONSE_SIZE: usize = 8;
|
||||
|
||||
pub struct Mt1959 {
|
||||
pub(crate) profile: DriveProfile,
|
||||
pub(crate) mode: u8,
|
||||
pub(crate) buffer_id: u8,
|
||||
pub(crate) unlocked: bool,
|
||||
probed: bool,
|
||||
}
|
||||
|
||||
impl Mt1959 {
|
||||
pub fn new(profile: DriveProfile, is_variant_b: bool) -> Self {
|
||||
let (mode, buffer_id) = if is_variant_b {
|
||||
(MODE_B, BUFFER_ID_B)
|
||||
} else {
|
||||
(MODE_A, BUFFER_ID_A)
|
||||
};
|
||||
Mt1959 {
|
||||
profile, mode, buffer_id,
|
||||
unlocked: false,
|
||||
probed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCSI helpers (shared by both variants) ─────────────────────────
|
||||
|
||||
pub(crate) fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
|
||||
[
|
||||
SCSI_READ_BUFFER, self.mode, self.buffer_id, sub_cmd,
|
||||
(address >> 8) as u8, address as u8,
|
||||
0x00, 0x00, length, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn read_buffer_probe(
|
||||
&self, scsi: &mut dyn ScsiTransport,
|
||||
sub_cmd: u8, address: u16, buf: &mut [u8], expected: usize,
|
||||
) -> Result<usize> {
|
||||
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
if result.bytes_transferred != expected {
|
||||
return Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
pub(crate) fn set_cd_speed_max(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(0xFFFF);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Unlock (shared) ────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
SUB_CMD_UNLOCK, 0x00, 0x00,
|
||||
0x00, 0x00, UNLOCK_RESPONSE_SIZE, 0x00,
|
||||
];
|
||||
let mut response = vec![0u8; UNLOCK_RESPONSE_SIZE as usize];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
if response.len() >= 4 && response[0..4] != self.profile.signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.signature,
|
||||
got: response[0..4].try_into().unwrap_or([0; 4]),
|
||||
});
|
||||
}
|
||||
|
||||
if response.len() >= FIRMWARE_ACTIVE_OFFSET + 4
|
||||
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
|
||||
self.unlocked = true;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn validate(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
for _attempt in 0..5 {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
SUB_CMD_UNLOCK, 0x00, 0x00,
|
||||
0x00, 0x00, VALIDATE_RESPONSE_SIZE, 0x00,
|
||||
];
|
||||
let mut resp = [0u8; 4];
|
||||
if scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 })
|
||||
}
|
||||
|
||||
// ── Init (unlock + firmware) ───────────────────────────────────────
|
||||
|
||||
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let mut unlocked = false;
|
||||
for _attempt in 0..6 {
|
||||
match self.do_unlock(scsi) {
|
||||
Ok(_) => { unlocked = true; break; }
|
||||
Err(Error::SignatureMismatch { .. }) => {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Err(_) => {
|
||||
let ok = if self.mode == MODE_A {
|
||||
variant_a::load_firmware(self, scsi).is_ok()
|
||||
} else {
|
||||
variant_b::load_firmware(self, scsi).is_ok()
|
||||
};
|
||||
if ok { unlocked = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unlocked {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Probe disc ─────────────────────────────────────────────────────
|
||||
|
||||
/// Probe the disc surface so the drive firmware learns optimal speeds
|
||||
/// per region. Two passes, then SET_CD_SPEED(max). After this the
|
||||
/// drive manages per-zone speeds internally.
|
||||
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
|
||||
// Detect disc type from capacity to select probe mode.
|
||||
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
|
||||
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
|
||||
// Verified from MakeMKV strace: BD and UHD use different init addresses.
|
||||
let cap_cdb = [SCSI_READ_CAPACITY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut cap_buf = [0u8; READ_CAPACITY_RESPONSE_SIZE];
|
||||
let disc_sectors = if scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000).is_ok() {
|
||||
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]) + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let init_addr = if disc_sectors > UHD_SECTOR_THRESHOLD { INIT_ADDR_UHD } else { INIT_ADDR_BD };
|
||||
let mut init_resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
let _ = self.read_buffer_probe(scsi, SUB_CMD_INIT, init_addr, &mut init_resp, PROBE_RESPONSE_SIZE as usize);
|
||||
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Pass 1: coarse scan
|
||||
let mut addr: u16 = 0;
|
||||
while addr < PROBE_COARSE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self.read_buffer_probe(scsi, SUB_CMD_PROBE, addr, &mut resp, PROBE_RESPONSE_SIZE as usize).is_err() {
|
||||
return Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
addr = addr.wrapping_add(PROBE_STEP);
|
||||
}
|
||||
|
||||
// Pass 2: fine scan
|
||||
let mut addr: u32 = 0;
|
||||
while addr < PROBE_FINE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self.read_buffer_probe(scsi, SUB_CMD_PROBE, addr as u16, &mut resp, PROBE_RESPONSE_SIZE as usize).is_err() {
|
||||
break;
|
||||
}
|
||||
addr += PROBE_STEP as u32;
|
||||
}
|
||||
|
||||
// Set max speed — drive manages zones from here
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
self.probed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── PlatformDriver trait ───────────────────────────────────────────────
|
||||
|
||||
impl PlatformDriver for Mt1959 {
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if self.unlocked { return Ok(()); }
|
||||
self.run_init(scsi)
|
||||
}
|
||||
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked { self.run_init(scsi)?; }
|
||||
if self.probed { return Ok(()); }
|
||||
self.run_probe(scsi)
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.unlocked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! MT1959 variant A firmware upload.
|
||||
//!
|
||||
//! WRITE_BUFFER (0x3B) → verify READ_BUFFER (0x45) → unlock × 2
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use super::Mt1959;
|
||||
|
||||
const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
const VERIFY_BUFFER_ID: u8 = 0x45;
|
||||
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Upload firmware via WRITE_BUFFER
|
||||
let len = firmware.len();
|
||||
let cdb = [
|
||||
SCSI_WRITE_BUFFER, 0x06, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(len >> 16) as u8, (len >> 8) as u8, len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware.clone();
|
||||
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Verify firmware loaded (non-fatal — different buffer_id 0x45)
|
||||
let verify_cdb = [super::SCSI_READ_BUFFER, super::MODE_A, VERIFY_BUFFER_ID, 0x00, 0x00, 0x00, 0x00, 0x00, super::VALIDATE_RESPONSE_SIZE, 0x00];
|
||||
let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize];
|
||||
let _ = scsi.execute(&verify_cdb, DataDirection::FromDevice, &mut verify_resp, 5_000);
|
||||
|
||||
// Double unlock after firmware upload
|
||||
mt.do_unlock(scsi)?;
|
||||
mt.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! MT1959 variant B firmware upload.
|
||||
//!
|
||||
//! MODE SELECT (0x55) → read metadata → WRITE_BUFFER → vendor verify (0xF1) → unlock × 5+1
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use super::Mt1959;
|
||||
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
const FIRMWARE_MAX_SIZE: usize = 0x9C0;
|
||||
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Step 1: Upload firmware via MODE SELECT
|
||||
let write_len = FIRMWARE_MAX_SIZE.min(firmware.len());
|
||||
let mode_select_cdb = [
|
||||
SCSI_MODE_SELECT, 0x10, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(write_len >> 16) as u8, (write_len >> 8) as u8, write_len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware[..write_len].to_vec();
|
||||
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000)
|
||||
let read_meta_cdb = [SCSI_READ_BUFFER, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut meta_resp = [0u8; 16];
|
||||
let _ = scsi.execute(&read_meta_cdb, DataDirection::FromDevice, &mut meta_resp, 5_000);
|
||||
|
||||
// Step 3: Write extra firmware data (all zeros)
|
||||
let write_extra_cdb = [SCSI_WRITE_BUFFER, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut data2 = FIRMWARE_EXTRA.to_vec();
|
||||
let _ = scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000);
|
||||
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI)
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&VENDOR_VERIFY, DataDirection::None, &mut dummy, 5_000);
|
||||
|
||||
// Step 5: Unlock retries (up to 5, then final attempt)
|
||||
for _attempt in 0..5 {
|
||||
if mt.do_unlock(scsi).is_ok() {
|
||||
let _ = mt.do_unlock(scsi);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
mt.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
+84
-240
@@ -1,300 +1,144 @@
|
||||
//! Drive profile loading and matching.
|
||||
//!
|
||||
//! Each supported drive has a profile containing the SCSI command
|
||||
//! parameters needed to enable raw disc access mode. Profiles are
|
||||
//! loaded from JSON files so new drives can be added without rebuilding.
|
||||
|
||||
use serde::Deserialize;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Per-drive profile containing SCSI parameters for raw disc access.
|
||||
/// Top-level profiles file — keyed by chipset + variant.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProfilesFile {
|
||||
#[serde(default)]
|
||||
pub mt1959_a: Vec<DriveProfile>,
|
||||
#[serde(default)]
|
||||
pub mt1959_b: Vec<DriveProfile>,
|
||||
#[serde(default)]
|
||||
pub renesas: Vec<DriveProfile>,
|
||||
}
|
||||
|
||||
/// Drive identity — matched against INQUIRY data.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
/// Drive vendor from INQUIRY[8:16] (e.g. "HL-DT-ST")
|
||||
pub struct Identity {
|
||||
#[serde(default)]
|
||||
pub vendor_id: String,
|
||||
|
||||
/// Drive product (devtype) from INQUIRY product field (e.g. "BD-RE")
|
||||
#[serde(default)]
|
||||
pub product_id: String,
|
||||
|
||||
/// Firmware revision from INQUIRY[32:36] (e.g. "1.03")
|
||||
#[serde(default)]
|
||||
pub product_revision: String,
|
||||
|
||||
/// Firmware type from INQUIRY[36:43] (e.g. "NM00000")
|
||||
#[serde(default)]
|
||||
pub vendor_specific: String,
|
||||
|
||||
/// Firmware build date from GET_CONFIG 010C (e.g. "211810241934")
|
||||
#[serde(default)]
|
||||
pub firmware_date: String,
|
||||
}
|
||||
|
||||
/// Chipset manufacturer determining unlock/read command structure.
|
||||
#[serde(default)]
|
||||
pub chipset: Chipset,
|
||||
|
||||
/// READ BUFFER mode byte for unlock CDB (e.g. 0x01 for MT1959-A, 0x02 for MT1959-B).
|
||||
#[serde(default = "default_unlock_mode")]
|
||||
pub unlock_mode: u8,
|
||||
|
||||
/// READ BUFFER buffer ID for unlock CDB (e.g. 0x44 for MT1959-A, 0x77 for MT1959-B).
|
||||
#[serde(default = "default_unlock_buf_id")]
|
||||
pub unlock_buf_id: u8,
|
||||
|
||||
|
||||
/// Drive identifier string from the profile database.
|
||||
#[serde(default)]
|
||||
pub drive_id: String,
|
||||
|
||||
/// Profile version string.
|
||||
#[serde(default)]
|
||||
pub drive_version: String,
|
||||
|
||||
/// Expected response signature bytes [0:4] from the enable command.
|
||||
/// Per-drive profile.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
pub identity: Identity,
|
||||
#[serde(default, deserialize_with = "deserialize_hex4")]
|
||||
pub signature: [u8; 4],
|
||||
|
||||
/// Expected verification bytes [12:16] from the enable response.
|
||||
#[serde(skip, default = "default_verify")]
|
||||
pub verify: [u8; 4],
|
||||
|
||||
/// 10-byte READ BUFFER CDB used to enable raw disc access.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub unlock_cdb: Vec<u8>,
|
||||
|
||||
/// Register read offsets (bytes 3-5 of READ BUFFER CDB).
|
||||
#[serde(default)]
|
||||
pub register_offsets: Vec<u32>,
|
||||
|
||||
/// Drive supports reading DVDs regardless of region code.
|
||||
#[serde(default)]
|
||||
pub dvd_all_regions: bool,
|
||||
|
||||
/// Drive supports raw Blu-ray sector reads.
|
||||
#[serde(default)]
|
||||
pub bd_raw_read: bool,
|
||||
|
||||
/// Drive supports raw Blu-ray metadata reads.
|
||||
#[serde(default)]
|
||||
pub bd_raw_metadata: bool,
|
||||
|
||||
/// Drive supports unrestricted read speed.
|
||||
#[serde(default)]
|
||||
pub unrestricted_speed: bool,
|
||||
#[serde(default, deserialize_with = "deserialize_base64")]
|
||||
pub firmware: Vec<u8>,
|
||||
}
|
||||
|
||||
fn default_verify() -> [u8; 4] {
|
||||
*b"MMkv"
|
||||
}
|
||||
|
||||
fn default_unlock_mode() -> u8 {
|
||||
0x01
|
||||
}
|
||||
|
||||
fn default_unlock_buf_id() -> u8 {
|
||||
0x44
|
||||
}
|
||||
|
||||
/// Drive chipset — determines CDB structure for unlock and raw read commands.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
||||
pub enum Chipset {
|
||||
/// MediaTek MT1959 — LG, ASUS, hp drives.
|
||||
/// CDB: READ_BUFFER with mode and buf_id from profile.
|
||||
#[serde(rename = "mediatek")]
|
||||
MediaTek,
|
||||
/// Renesas RS8xxx/RS9xxx — Pioneer, some HL-DT-ST drives.
|
||||
/// Not yet implemented.
|
||||
#[serde(rename = "renesas")]
|
||||
/// Chipset + variant — determined by which section the profile was found in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Platform {
|
||||
Mt1959A,
|
||||
Mt1959B,
|
||||
Renesas,
|
||||
}
|
||||
|
||||
impl Default for Chipset {
|
||||
fn default() -> Self {
|
||||
Chipset::MediaTek
|
||||
}
|
||||
}
|
||||
|
||||
impl Chipset {
|
||||
/// Human-readable name for this chipset.
|
||||
impl Platform {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Chipset::MediaTek => "MediaTek MT1959",
|
||||
Chipset::Renesas => "Renesas",
|
||||
Platform::Mt1959A => "MediaTek MT1959",
|
||||
Platform::Mt1959B => "MediaTek MT1959",
|
||||
Platform::Renesas => "Renesas",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a hex string like "999ec375" into [u8; 4].
|
||||
/// Result of profile lookup.
|
||||
pub struct ProfileMatch {
|
||||
pub profile: DriveProfile,
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_hex4(s: &str) -> Result<[u8; 4]> {
|
||||
if s.len() != 8 {
|
||||
return Err(Error::ProfileParse { detail: format!("expected 8 hex chars, got {}", s.len()) });
|
||||
return Err(Error::ProfileParse);
|
||||
}
|
||||
let mut out = [0u8; 4];
|
||||
for i in 0..4 {
|
||||
out[i] = u8::from_str_radix(&s[i*2..i*2+2], 16)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("bad hex: {e}") })?;
|
||||
.map_err(|_| Error::ProfileParse)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse a hex string into a byte vector.
|
||||
fn parse_hex(s: &str) -> Result<Vec<u8>> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(Error::ProfileParse { detail: "odd hex length".into() });
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
out.push(u8::from_str_radix(&s[i..i+2], 16)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("bad hex: {e}") })?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Custom serde deserializer for 4-byte hex signature strings.
|
||||
fn deserialize_hex4<'de, D>(deserializer: D) -> std::result::Result<[u8; 4], D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
where D: serde::Deserializer<'de> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if s.is_empty() { return Ok([0; 4]); }
|
||||
parse_hex4(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// Custom serde deserializer for hex-encoded byte vectors.
|
||||
fn deserialize_hex_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
fn deserialize_base64<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
||||
where D: serde::Deserializer<'de> {
|
||||
use base64::Engine;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
parse_hex(&s).map_err(serde::de::Error::custom)
|
||||
if s.is_empty() { return Ok(Vec::new()); }
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&s)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// Load a profile from a parsed JSON value.
|
||||
pub fn load_from_json(json: &serde_json::Value) -> Result<DriveProfile> {
|
||||
let vendor = json["vendor_id"].as_str().unwrap_or("").to_string();
|
||||
let product = json["product_id"].as_str().unwrap_or("").to_string();
|
||||
let revision = json["product_revision"].as_str().unwrap_or("").to_string();
|
||||
let firmware_type = json["vendor_specific"].as_str().unwrap_or("").to_string();
|
||||
let firmware_date = json["firmware_date"].as_str().unwrap_or("").to_string();
|
||||
let chipset_str = json["chipset"].as_str().unwrap_or("unknown");
|
||||
// ── Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
let chipset = match chipset_str {
|
||||
"mediatek" => Chipset::MediaTek,
|
||||
"renesas" => Chipset::Renesas,
|
||||
_ => Chipset::MediaTek,
|
||||
};
|
||||
|
||||
let unlock_mode = json["unlock_mode"].as_u64().map(|v| v as u8).unwrap_or(0x01);
|
||||
let unlock_buf_id = json["unlock_buf_id"].as_u64().map(|v| v as u8).unwrap_or(0x44);
|
||||
|
||||
let sig_str = json["signature"].as_str().unwrap_or("");
|
||||
|
||||
let signature = if sig_str.len() == 8 {
|
||||
parse_hex4(sig_str)?
|
||||
} else {
|
||||
[0; 4]
|
||||
};
|
||||
|
||||
let unlock_cdb = json["unlock_cdb"].as_str()
|
||||
.map(|s| parse_hex(s))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let register_offsets = json["register_cdbs"].as_array()
|
||||
.map(|arr| {
|
||||
arr.iter().filter_map(|v| {
|
||||
let s = v.as_str()?;
|
||||
// CDB format: 3c 01 44 XX XX XX 00 00 24 00
|
||||
// Register offset is bytes 3-5 (chars 6-12 in hex)
|
||||
if s.len() >= 12 {
|
||||
u32::from_str_radix(&s[6..12], 16).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(DriveProfile {
|
||||
vendor_id: vendor,
|
||||
product_id: product,
|
||||
product_revision: revision,
|
||||
vendor_specific: firmware_type,
|
||||
firmware_date,
|
||||
chipset,
|
||||
unlock_mode,
|
||||
unlock_buf_id,
|
||||
drive_id: json["drive_id"].as_str().unwrap_or("").to_string(),
|
||||
drive_version: json["drive_version"].as_str().unwrap_or("").to_string(),
|
||||
signature,
|
||||
verify: *b"MMkv",
|
||||
unlock_cdb,
|
||||
register_offsets,
|
||||
dvd_all_regions: json["capabilities"]["dvd_all_regions"].as_bool().unwrap_or(false),
|
||||
bd_raw_read: json["capabilities"]["bd_raw_read"].as_bool().unwrap_or(false),
|
||||
bd_raw_metadata: json["capabilities"]["bd_raw_metadata"].as_bool().unwrap_or(false),
|
||||
unrestricted_speed: json["capabilities"]["unrestricted_speed"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Bundled profiles — compiled into the binary.
|
||||
/// Override with load_all() to load from a file instead.
|
||||
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
||||
|
||||
/// Load profiles from the bundled database.
|
||||
pub fn load_bundled() -> Result<Vec<DriveProfile>> {
|
||||
pub fn load_bundled() -> Result<ProfilesFile> {
|
||||
load_from_str(BUNDLED_PROFILES)
|
||||
}
|
||||
|
||||
/// Load all profiles from a JSON array file.
|
||||
pub fn load_all(path: &std::path::Path) -> Result<Vec<DriveProfile>> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
load_from_str(&data)
|
||||
}
|
||||
|
||||
/// Parse profiles from a JSON string.
|
||||
fn load_from_str(data: &str) -> Result<Vec<DriveProfile>> {
|
||||
let json: serde_json::Value = serde_json::from_str(data)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("JSON: {e}") })?;
|
||||
|
||||
let arr = json.as_array()
|
||||
.ok_or_else(|| Error::ProfileParse { detail: "expected array".into() })?;
|
||||
|
||||
let mut profiles = Vec::with_capacity(arr.len());
|
||||
for entry in arr {
|
||||
match load_from_json(entry) {
|
||||
Ok(p) => profiles.push(p),
|
||||
Err(_) => continue, // skip malformed entries
|
||||
}
|
||||
}
|
||||
Ok(profiles)
|
||||
fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
serde_json::from_str(data)
|
||||
.map_err(|_| Error::ProfileParse)
|
||||
}
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
///
|
||||
/// Matches by vendor + product + revision + vendor_specific (firmware type).
|
||||
/// All fields trimmed before comparison.
|
||||
pub fn find_by_drive_id<'a>(
|
||||
profiles: &'a [DriveProfile],
|
||||
pub fn find_by_drive_id(
|
||||
profiles: &ProfilesFile,
|
||||
drive_id: &crate::identity::DriveId,
|
||||
) -> Option<&'a DriveProfile> {
|
||||
) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let r = drive_id.product_revision.trim();
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
let date = drive_id.firmware_date.trim();
|
||||
|
||||
// Match all four INQUIRY fields for precise identification
|
||||
profiles.iter().find(|p| {
|
||||
p.vendor_id.trim() == v
|
||||
&& p.product_revision.trim() == r
|
||||
&& p.vendor_specific.trim() == vs
|
||||
&& p.firmware_date.trim() == drive_id.firmware_date.trim()
|
||||
})
|
||||
// Fallback: match without date (for drives where 010C isn't available)
|
||||
.or_else(|| profiles.iter().find(|p| {
|
||||
p.vendor_id.trim() == v
|
||||
&& p.product_revision.trim() == r
|
||||
&& p.vendor_specific.trim() == vs
|
||||
}))
|
||||
for (platform, list) in [
|
||||
(Platform::Mt1959A, &profiles.mt1959_a),
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch { profile: p.clone(), platform });
|
||||
}
|
||||
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
}) {
|
||||
return Some(ProfileMatch { profile: p.clone(), platform });
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -314,9 +158,9 @@ mod tests {
|
||||
fn test_find_known_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
let p = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(p.vendor_id.trim(), "HL-DT-ST");
|
||||
assert_eq!(p.vendor_specific.trim(), "NM00000");
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//! SectorReader — trait for reading 2048-byte disc sectors.
|
||||
//!
|
||||
//! Implemented by DriveSession (SCSI) and IsoFile (file-backed).
|
||||
//! Used by UDF parser, disc scanner, label parsers — anything that
|
||||
//! reads sectors doesn't need to know where they come from.
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Read 2048-byte sectors from a disc or disc image.
|
||||
pub trait SectorReader {
|
||||
/// Read `count` sectors starting at `lba` into `buf`.
|
||||
/// `buf` must be at least `count * 2048` bytes.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
|
||||
}
|
||||
+5
-93
@@ -1,41 +1,14 @@
|
||||
//! Drive speed control — query and set read speeds.
|
||||
//!
|
||||
//! Uses MMC-6 SET CD SPEED (0xBB) command.
|
||||
//! Reference: MMC-6 §6.30
|
||||
//! Drive speed constants.
|
||||
|
||||
/// Disc read speed.
|
||||
/// Common optical drive speeds with KB/s values for SET_CD_SPEED.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum DriveSpeed {
|
||||
/// Blu-ray 1x = 4,500 KB/s
|
||||
BD1x,
|
||||
/// Blu-ray 2x = 9,000 KB/s
|
||||
BD2x,
|
||||
/// Blu-ray 4x = 18,000 KB/s
|
||||
BD4x,
|
||||
/// Blu-ray 6x = 27,000 KB/s
|
||||
BD6x,
|
||||
/// Blu-ray 8x = 36,000 KB/s
|
||||
BD8x,
|
||||
/// Blu-ray 10x = 45,000 KB/s
|
||||
BD10x,
|
||||
/// Blu-ray 12x = 54,000 KB/s
|
||||
BD12x,
|
||||
/// DVD 1x = 1,385 KB/s
|
||||
DVD1x,
|
||||
/// DVD 2x = 2,770 KB/s
|
||||
DVD2x,
|
||||
/// DVD 4x = 5,540 KB/s
|
||||
DVD4x,
|
||||
/// DVD 8x = 11,080 KB/s
|
||||
DVD8x,
|
||||
/// DVD 16x = 22,160 KB/s
|
||||
DVD16x,
|
||||
/// Maximum speed — drive decides
|
||||
BD1x, BD2x, BD4x, BD6x, BD8x, BD10x, BD12x,
|
||||
DVD1x, DVD2x, DVD4x, DVD8x, DVD16x,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl DriveSpeed {
|
||||
/// Convert to KB/s for MMC-6 SET CD SPEED command.
|
||||
pub fn to_kbps(self) -> u16 {
|
||||
match self {
|
||||
DriveSpeed::BD1x => 4_500,
|
||||
@@ -53,71 +26,10 @@ impl DriveSpeed {
|
||||
DriveSpeed::Max => 0xFFFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from KB/s value, rounding to nearest standard speed.
|
||||
pub fn from_kbps(kbps: u16) -> Self {
|
||||
match kbps {
|
||||
0..=2_000 => DriveSpeed::DVD1x,
|
||||
2_001..=4_000 => DriveSpeed::DVD2x,
|
||||
4_001..=6_000 => DriveSpeed::BD1x,
|
||||
6_001..=13_000 => DriveSpeed::BD2x,
|
||||
13_001..=22_000 => DriveSpeed::BD4x,
|
||||
22_001..=31_000 => DriveSpeed::BD6x,
|
||||
31_001..=40_000 => DriveSpeed::BD8x,
|
||||
40_001..=49_000 => DriveSpeed::BD10x,
|
||||
49_001..=u16::MAX => DriveSpeed::BD12x,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
DriveSpeed::BD1x => "BD 1x",
|
||||
DriveSpeed::BD2x => "BD 2x",
|
||||
DriveSpeed::BD4x => "BD 4x",
|
||||
DriveSpeed::BD6x => "BD 6x",
|
||||
DriveSpeed::BD8x => "BD 8x",
|
||||
DriveSpeed::BD10x => "BD 10x",
|
||||
DriveSpeed::BD12x => "BD 12x",
|
||||
DriveSpeed::DVD1x => "DVD 1x",
|
||||
DriveSpeed::DVD2x => "DVD 2x",
|
||||
DriveSpeed::DVD4x => "DVD 4x",
|
||||
DriveSpeed::DVD8x => "DVD 8x",
|
||||
DriveSpeed::DVD16x => "DVD 16x",
|
||||
DriveSpeed::Max => "Max",
|
||||
}
|
||||
}
|
||||
|
||||
/// All standard Blu-ray speeds.
|
||||
pub fn all_bd() -> &'static [DriveSpeed] {
|
||||
&[DriveSpeed::BD1x, DriveSpeed::BD2x, DriveSpeed::BD4x,
|
||||
DriveSpeed::BD6x, DriveSpeed::BD8x, DriveSpeed::BD10x, DriveSpeed::BD12x]
|
||||
}
|
||||
|
||||
/// All standard DVD speeds.
|
||||
pub fn all_dvd() -> &'static [DriveSpeed] {
|
||||
&[DriveSpeed::DVD1x, DriveSpeed::DVD2x, DriveSpeed::DVD4x,
|
||||
DriveSpeed::DVD8x, DriveSpeed::DVD16x]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DriveSpeed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} ({} KB/s)", self.label(), self.to_kbps())
|
||||
write!(f, "{:?} ({} KB/s)", self, self.to_kbps())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build SET CD SPEED CDB — MMC-6 §6.30
|
||||
pub fn set_cd_speed_cdb(read_speed: DriveSpeed) -> [u8; 12] {
|
||||
let kbps = read_speed.to_kbps();
|
||||
[
|
||||
0xBB, // SET CD SPEED opcode
|
||||
0x00, // reserved
|
||||
(kbps >> 8) as u8, // read speed MSB
|
||||
kbps as u8, // read speed LSB
|
||||
0xFF, // write speed MSB (0xFFFF = don't change)
|
||||
0xFF, // write speed LSB
|
||||
0x00, 0x00, 0x00, 0x00, // reserved
|
||||
0x00, 0x00, // reserved
|
||||
]
|
||||
}
|
||||
|
||||
+128
-64
@@ -19,7 +19,7 @@
|
||||
//! BD-ROM Part 3 — Blu-ray filesystem profile
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
|
||||
/// A UDF filesystem parsed from disc.
|
||||
#[derive(Debug)]
|
||||
@@ -72,7 +72,30 @@ impl UdfFs {
|
||||
|
||||
/// Read a file by path, returning its raw bytes.
|
||||
/// Reads sector by sector from disc — no buffering.
|
||||
pub fn read_file(&self, session: &mut DriveSession, path: &str) -> Result<Vec<u8>> {
|
||||
/// Get the absolute starting LBA of a file on disc.
|
||||
/// Used by the rip pipeline to locate m2ts content sectors.
|
||||
pub fn file_start_lba(&self, reader: &mut dyn SectorReader, path: &str) -> Result<u32> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
let entry = current.entries.iter().find(|e| {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
let (data_lba, _) = self.read_icb_extent(reader, entry.meta_lba)?;
|
||||
Ok(self.partition_start + data_lba)
|
||||
}
|
||||
|
||||
pub fn read_file(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<u8>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
|
||||
@@ -80,21 +103,22 @@ impl UdfFs {
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
}).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("directory not found: {}", part),
|
||||
})?;
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
|
||||
// Find the file
|
||||
let filename = parts.last().unwrap();
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
let entry = current.entries.iter().find(|e| {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("file not found: {}", path),
|
||||
})?;
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
|
||||
// Read the file's ICB to get its data extent
|
||||
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
let (data_lba, data_len) = self.read_icb_extent(reader, entry.meta_lba)?;
|
||||
|
||||
// Read file data sector by sector
|
||||
// File DATA is in the physical partition (partition_start + lba),
|
||||
@@ -105,7 +129,7 @@ impl UdfFs {
|
||||
|
||||
for i in 0..sector_count {
|
||||
let offset = (i as usize) * 2048;
|
||||
read_sector(session, abs_start + i, &mut data[offset..offset + 2048])?;
|
||||
read_sector(reader, abs_start + i, &mut data[offset..offset + 2048])?;
|
||||
}
|
||||
|
||||
data.truncate(entry.size as usize);
|
||||
@@ -121,7 +145,7 @@ impl UdfFs {
|
||||
///
|
||||
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
|
||||
/// MKB_RO.inf, ContentHash*, ContentRevocation*
|
||||
pub fn metadata_sector_ranges(&self, session: &mut DriveSession) -> Result<Vec<(u32, u32)>> {
|
||||
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// UDF structure: sector 0 through end of metadata partition
|
||||
@@ -130,7 +154,7 @@ impl UdfFs {
|
||||
ranges.push((0, meta_end));
|
||||
|
||||
// Walk tree, collect ranges for each metadata file
|
||||
self.collect_file_ranges(session, &self.root, &mut ranges)?;
|
||||
self.collect_file_ranges(reader, &self.root, &mut ranges)?;
|
||||
|
||||
// Merge overlapping/adjacent ranges and sort
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
@@ -138,14 +162,14 @@ impl UdfFs {
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
|
||||
fn collect_file_ranges(&self, reader: &mut dyn SectorReader, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
|
||||
for child in &entry.entries {
|
||||
if child.is_dir {
|
||||
// Only skip STREAM — those are the multi-GB video files
|
||||
if child.name.eq_ignore_ascii_case("STREAM") {
|
||||
continue;
|
||||
}
|
||||
self.collect_file_ranges(session, child, ranges)?;
|
||||
self.collect_file_ranges(reader, child, ranges)?;
|
||||
} else {
|
||||
// Include the ICB sector itself (in metadata partition)
|
||||
ranges.push((self.meta_to_abs(child.meta_lba), 1));
|
||||
@@ -155,7 +179,7 @@ impl UdfFs {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
|
||||
if let Ok((data_lba, data_len)) = self.read_icb_extent(reader, child.meta_lba) {
|
||||
let abs_start = self.partition_start + data_lba;
|
||||
let sector_count = (data_len + 2047) / 2048;
|
||||
ranges.push((abs_start, sector_count));
|
||||
@@ -172,45 +196,91 @@ impl UdfFs {
|
||||
|
||||
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
|
||||
/// and return its first allocation extent: (data_lba, data_length).
|
||||
/// The data_lba is metadata-relative.
|
||||
fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
/// The data_lba is partition-relative.
|
||||
fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
let extents = self.read_icb_extents(reader, meta_lba)?;
|
||||
extents.first().copied().ok_or_else(|| Error::DiscRead { sector: 0 })
|
||||
}
|
||||
|
||||
/// Read ALL allocation extents for a file from its ICB.
|
||||
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
|
||||
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents).
|
||||
fn read_icb_extents(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<Vec<(u32, u32)>> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, self.meta_to_abs(meta_lba), &mut icb)?;
|
||||
read_sector(reader, self.meta_to_abs(meta_lba), &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
// Get allocation descriptor offset based on ICB type
|
||||
let ad_offset = match tag {
|
||||
// Get allocation descriptor offset and total length based on ICB type
|
||||
let (ad_offset, l_ad) = match tag {
|
||||
// Extended File Entry (UDF 2.50, used by BD-ROM)
|
||||
// Layout: ... L_EA at [208:212], L_AD at [212:216], alloc descs at 216 + L_EA
|
||||
266 => {
|
||||
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
|
||||
216 + l_ea
|
||||
let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize;
|
||||
(216 + l_ea, l_ad)
|
||||
}
|
||||
// Standard File Entry
|
||||
// Layout: ... L_EA at [168:172], L_AD at [172:176], alloc descs at 176 + L_EA
|
||||
261 => {
|
||||
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
|
||||
176 + l_ea
|
||||
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
|
||||
(176 + l_ea, l_ad)
|
||||
}
|
||||
_ => return Err(Error::DiscError {
|
||||
detail: format!("unexpected ICB tag {} at meta_lba {}", tag, meta_lba),
|
||||
}),
|
||||
_ => return Err(Error::DiscRead { sector: 0 }),
|
||||
};
|
||||
|
||||
if ad_offset + 8 > 2048 {
|
||||
return Err(Error::DiscError { detail: "ICB alloc desc out of range".into() });
|
||||
let mut extents = Vec::new();
|
||||
let num_descriptors = l_ad / 8; // Short Allocation Descriptor = 8 bytes
|
||||
|
||||
for i in 0..num_descriptors {
|
||||
let off = ad_offset + i * 8;
|
||||
if off + 8 > icb.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]);
|
||||
let extent_type = raw_len >> 30;
|
||||
let data_len = raw_len & 0x3FFFFFFF;
|
||||
let data_lba = u32::from_le_bytes([icb[off + 4], icb[off + 5], icb[off + 6], icb[off + 7]]);
|
||||
|
||||
match extent_type {
|
||||
0 => extents.push((data_lba, data_len)), // recorded and allocated
|
||||
1 => {} // allocated but not recorded (sparse) — skip
|
||||
3 => break, // next extent of allocation descriptors — TODO
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Short Allocation Descriptor: extent_length(4) + extent_position(4)
|
||||
// extent_length upper 2 bits = type (0=recorded, 1=allocated not recorded, 3=next extent)
|
||||
let raw_len = u32::from_le_bytes([icb[ad_offset], icb[ad_offset + 1],
|
||||
icb[ad_offset + 2], icb[ad_offset + 3]]);
|
||||
let data_len = raw_len & 0x3FFFFFFF;
|
||||
let data_lba = u32::from_le_bytes([icb[ad_offset + 4], icb[ad_offset + 5],
|
||||
icb[ad_offset + 6], icb[ad_offset + 7]]);
|
||||
Ok(extents)
|
||||
}
|
||||
|
||||
Ok((data_lba, data_len))
|
||||
/// Get all absolute disc sector extents for a file.
|
||||
/// Returns Vec of (absolute_lba, sector_count) covering the entire file.
|
||||
pub fn file_extents(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<(u32, u32)>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
let entry = current.entries.iter().find(|e| {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
|
||||
let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?;
|
||||
let mut disc_extents = Vec::new();
|
||||
for (lba, byte_len) in alloc_extents {
|
||||
let abs_lba = self.partition_start + lba;
|
||||
let sectors = ((byte_len as u64 + 2047) / 2048) as u32;
|
||||
disc_extents.push((abs_lba, sectors));
|
||||
}
|
||||
Ok(disc_extents)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,17 +293,15 @@ impl UdfFs {
|
||||
/// 3. Metadata partition file → metadata content location
|
||||
/// 4. FSD → root directory ICB
|
||||
/// 5. Root directory → file tree
|
||||
pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
// Step 1: Anchor Volume Descriptor Pointer at sector 256
|
||||
// ECMA-167 §10.2 — always at sector 256
|
||||
let mut avdp = [0u8; 2048];
|
||||
read_sector(session, 256, &mut avdp)?;
|
||||
read_sector(reader, 256, &mut avdp)?;
|
||||
|
||||
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
||||
if tag_id != 2 {
|
||||
return Err(Error::DiscError {
|
||||
detail: format!("AVDP: expected tag 2, got {} at sector 256", tag_id),
|
||||
});
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
|
||||
@@ -249,7 +317,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
|
||||
for i in 32..64 {
|
||||
let mut desc = [0u8; 2048];
|
||||
read_sector(session, i, &mut desc)?;
|
||||
read_sector(reader, i, &mut desc)?;
|
||||
|
||||
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
|
||||
match desc_tag {
|
||||
@@ -273,20 +341,18 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
}
|
||||
|
||||
if partition_start == 0 {
|
||||
return Err(Error::DiscError { detail: "UDF: no Partition Descriptor found".into() });
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Step 3: Parse partition maps from LVD to find metadata partition
|
||||
// BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition")
|
||||
// The metadata file is stored at lba=0 of the physical partition
|
||||
let metadata_start = if num_partition_maps >= 2 {
|
||||
let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscError {
|
||||
detail: "UDF: no LVD found".into(),
|
||||
})?;
|
||||
let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscRead { sector: 0 })?;
|
||||
|
||||
// Read LVD to check partition map type
|
||||
let mut lvd = [0u8; 2048];
|
||||
read_sector(session, lvd_sec, &mut lvd)?;
|
||||
read_sector(reader, lvd_sec, &mut lvd)?;
|
||||
|
||||
// Parse partition maps starting at offset 440
|
||||
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
|
||||
@@ -302,7 +368,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
// Read it to find where the metadata content starts
|
||||
let meta_file_lba = partition_start; // lba 0 of partition
|
||||
let mut meta_icb = [0u8; 2048];
|
||||
read_sector(session, meta_file_lba, &mut meta_icb)?;
|
||||
read_sector(reader, meta_file_lba, &mut meta_icb)?;
|
||||
|
||||
let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]);
|
||||
if meta_tag == 266 {
|
||||
@@ -335,13 +401,11 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
// Step 4: Read File Set Descriptor from metadata partition
|
||||
// FSD is at metadata-relative lba 0 (first sector of metadata content)
|
||||
let mut fsd = [0u8; 2048];
|
||||
read_sector(session, metadata_start, &mut fsd)?;
|
||||
read_sector(reader, metadata_start, &mut fsd)?;
|
||||
|
||||
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
|
||||
if fsd_tag != 256 {
|
||||
return Err(Error::DiscError {
|
||||
detail: format!("FSD: expected tag 256, got {} at sector {}", fsd_tag, metadata_start),
|
||||
});
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Root Directory ICB: long_ad at FSD offset 400
|
||||
@@ -349,7 +413,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
let root_lba = u32::from_le_bytes([fsd[404], fsd[405], fsd[406], fsd[407]]);
|
||||
|
||||
// Step 5: Read root directory and build file tree
|
||||
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
|
||||
let root = read_directory(reader, partition_start, metadata_start, root_lba, "", 0)?;
|
||||
|
||||
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
|
||||
|
||||
@@ -368,7 +432,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
/// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir
|
||||
/// and points to its ICB.
|
||||
fn read_directory(
|
||||
session: &mut DriveSession,
|
||||
reader: &mut dyn SectorReader,
|
||||
part_start: u32,
|
||||
meta_start: u32,
|
||||
meta_lba: u32,
|
||||
@@ -377,7 +441,7 @@ fn read_directory(
|
||||
) -> Result<DirEntry> {
|
||||
// Read ICB for this directory
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, meta_start + meta_lba, &mut icb)?;
|
||||
read_sector(reader, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
@@ -413,7 +477,7 @@ fn read_directory(
|
||||
let sector_count = ((ad_len + 2047) / 2048).min(64);
|
||||
let mut dir_data = vec![0u8; sector_count as usize * 2048];
|
||||
for i in 0..sector_count {
|
||||
read_sector(session, dir_abs + i,
|
||||
read_sector(reader, dir_abs + i,
|
||||
&mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048])?;
|
||||
}
|
||||
|
||||
@@ -448,11 +512,11 @@ fn read_directory(
|
||||
|
||||
if !entry_name.is_empty() {
|
||||
// Read the ICB to get file size
|
||||
let file_size = read_file_size(session, meta_start, icb_lba).unwrap_or(0);
|
||||
let file_size = read_file_size(reader, meta_start, icb_lba).unwrap_or(0);
|
||||
|
||||
if is_dir && depth < 3 {
|
||||
// Recurse into subdirectory (max 3 levels: BDMV/PLAYLIST/*.mpls)
|
||||
let subdir = read_directory(session, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
|
||||
let subdir = read_directory(reader, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
|
||||
entries.push(subdir);
|
||||
} else {
|
||||
entries.push(DirEntry {
|
||||
@@ -481,9 +545,9 @@ fn read_directory(
|
||||
}
|
||||
|
||||
/// Read file size (info_length) from an Extended File Entry ICB.
|
||||
fn read_file_size(session: &mut DriveSession, meta_start: u32, meta_lba: u32) -> Result<u64> {
|
||||
fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result<u64> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, meta_start + meta_lba, &mut icb)?;
|
||||
read_sector(reader, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
match tag {
|
||||
@@ -578,7 +642,7 @@ fn parse_dstring(data: &[u8]) -> String {
|
||||
|
||||
/// Read a single 2048-byte sector from the drive.
|
||||
/// Uses standard READ(10) — no unlock required.
|
||||
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
session.read_disc(lba, 1, buf)?;
|
||||
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
reader.read_sectors(lba, 1, buf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Integration tests for the IOStream pipeline.
|
||||
|
||||
use std::io::{Cursor, Read, Write, Seek, SeekFrom};
|
||||
use libfreemkv::*;
|
||||
use libfreemkv::mux::meta::M2tsMeta;
|
||||
|
||||
fn sample_disc_title() -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: "Test Movie".into(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 7200.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: vec![
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011, codec: Codec::Hevc,
|
||||
resolution: "2160p".into(), frame_rate: "23.976".into(),
|
||||
hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: "Main".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100, codec: Codec::TrueHd,
|
||||
channels: "7.1".into(), language: "eng".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false,
|
||||
label: "English Atmos".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1101, codec: Codec::Ac3,
|
||||
channels: "5.1".into(), language: "fra".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false,
|
||||
label: "French".into(),
|
||||
}),
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200, codec: Codec::Pgs,
|
||||
language: "eng".into(), forced: false,
|
||||
}),
|
||||
],
|
||||
extents: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL parsing ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_url_disc() {
|
||||
let u = parse_url("disc://");
|
||||
assert_eq!(u.scheme, "disc");
|
||||
assert_eq!(u.path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_disc_device() {
|
||||
let u = parse_url("disc:///dev/sg4");
|
||||
assert_eq!(u.scheme, "disc");
|
||||
assert_eq!(u.path, "/dev/sg4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_mkv() {
|
||||
let u = parse_url("mkv://Dune.mkv");
|
||||
assert_eq!(u.scheme, "mkv");
|
||||
assert_eq!(u.path, "Dune.mkv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_network() {
|
||||
let u = parse_url("network://10.0.0.1:9000");
|
||||
assert_eq!(u.scheme, "network");
|
||||
assert_eq!(u.path, "10.0.0.1:9000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_bare_path_rejected() {
|
||||
let u = parse_url("Dune.mkv");
|
||||
assert_eq!(u.scheme, "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_null() {
|
||||
let u = parse_url("null://");
|
||||
assert_eq!(u.scheme, "null");
|
||||
assert_eq!(u.path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_m2ts_with_path() {
|
||||
let u = parse_url("m2ts:///tmp/Dune.m2ts");
|
||||
assert_eq!(u.scheme, "m2ts");
|
||||
assert_eq!(u.path, "/tmp/Dune.m2ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_m2ts_relative() {
|
||||
let u = parse_url("m2ts://Dune.m2ts");
|
||||
assert_eq!(u.scheme, "m2ts");
|
||||
assert_eq!(u.path, "Dune.m2ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_bare_path_errors() {
|
||||
let result = libfreemkv::open_input("Dune.mkv", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("not a valid stream URL"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_bare_path_errors() {
|
||||
let dt = sample_disc_title();
|
||||
let result = libfreemkv::open_output("Dune.mkv", &dt);
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("not a valid stream URL"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_m2ts_empty_path_errors() {
|
||||
let result = libfreemkv::open_input("m2ts://", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("requires a file path"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_null_input_errors() {
|
||||
let result = libfreemkv::open_input("null://", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("write-only"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_disc_errors() {
|
||||
let dt = sample_disc_title();
|
||||
let result = libfreemkv::open_output("disc://", &dt);
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("read-only"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_network_no_port_errors() {
|
||||
let result = libfreemkv::open_input("network://10.0.0.1", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("missing port"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_stdio() {
|
||||
let u = parse_url("stdio://");
|
||||
assert_eq!(u.scheme, "stdio");
|
||||
assert_eq!(u.path, "");
|
||||
}
|
||||
|
||||
// ── M2TS metadata roundtrip ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_meta_roundtrip() {
|
||||
let dt = sample_disc_title();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
let restored = meta.to_title();
|
||||
|
||||
assert_eq!(restored.playlist, dt.playlist);
|
||||
assert_eq!(restored.duration_secs, dt.duration_secs);
|
||||
assert_eq!(restored.streams.len(), dt.streams.len());
|
||||
|
||||
// Check video
|
||||
if let Stream::Video(v) = &restored.streams[0] {
|
||||
assert_eq!(v.codec, Codec::Hevc);
|
||||
assert_eq!(v.resolution, "2160p");
|
||||
assert_eq!(v.label, "Main");
|
||||
} else { panic!("expected video"); }
|
||||
|
||||
// Check audio
|
||||
if let Stream::Audio(a) = &restored.streams[1] {
|
||||
assert_eq!(a.codec, Codec::TrueHd);
|
||||
assert_eq!(a.language, "eng");
|
||||
assert_eq!(a.label, "English Atmos");
|
||||
} else { panic!("expected audio"); }
|
||||
|
||||
// Check subtitle
|
||||
if let Stream::Subtitle(s) = &restored.streams[3] {
|
||||
assert_eq!(s.language, "eng");
|
||||
assert!(!s.forced);
|
||||
} else { panic!("expected subtitle"); }
|
||||
}
|
||||
|
||||
// ── M2TS header write + read ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_header_write_read() {
|
||||
let dt = sample_disc_title();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
|
||||
// Write header to buffer
|
||||
let mut buf = Vec::new();
|
||||
libfreemkv::mux::meta::write_header(&mut buf, &meta).unwrap();
|
||||
|
||||
// Verify magic
|
||||
assert_eq!(&buf[..4], b"FMKV");
|
||||
|
||||
// Verify 192-byte alignment
|
||||
assert_eq!(buf.len() % 192, 0);
|
||||
|
||||
// Read it back
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let read_back = libfreemkv::mux::meta::read_header(&mut cursor).unwrap().unwrap();
|
||||
assert_eq!(read_back.title, "Test Movie");
|
||||
assert_eq!(read_back.duration, 7200.0);
|
||||
assert_eq!(read_back.streams.len(), 4);
|
||||
|
||||
// Cursor should be at end of header (192-aligned)
|
||||
assert_eq!(cursor.position() as usize, buf.len());
|
||||
}
|
||||
|
||||
// ── M2tsStream write + read ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_stream_write_read() {
|
||||
let dt = sample_disc_title();
|
||||
|
||||
// Build fake BD-TS packets
|
||||
let mut ts_data = Vec::new();
|
||||
for i in 0..10u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = 0x10;
|
||||
pkt[6] = 0x11;
|
||||
pkt[7] = 0x10;
|
||||
pkt[8] = i;
|
||||
ts_data.extend_from_slice(&pkt);
|
||||
}
|
||||
|
||||
// Write through M2tsStream to a Cursor
|
||||
let output = Cursor::new(Vec::new());
|
||||
let mut stream = M2tsStream::new(output).meta(&dt);
|
||||
stream.write_all(&ts_data).unwrap();
|
||||
stream.finish().unwrap();
|
||||
|
||||
// M2tsStream consumed the cursor — we need the inner data.
|
||||
// For this test, write to a shared buffer instead.
|
||||
// Use a second pass: write header + data manually to verify read side.
|
||||
let mut encoded = Vec::new();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
libfreemkv::mux::meta::write_header(&mut encoded, &meta).unwrap();
|
||||
encoded.extend_from_slice(&ts_data);
|
||||
|
||||
// Read back
|
||||
let cursor = Cursor::new(encoded);
|
||||
let mut stream = M2tsStream::open(cursor).unwrap();
|
||||
let info = stream.info();
|
||||
assert_eq!(info.streams.len(), 4);
|
||||
assert_eq!(info.duration_secs, 7200.0);
|
||||
|
||||
// Read BD-TS data
|
||||
let mut read_buf = vec![0u8; 192 * 10];
|
||||
let mut total = 0;
|
||||
loop {
|
||||
match stream.read(&mut read_buf[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(total, 192 * 10);
|
||||
for i in 0..10u8 {
|
||||
assert_eq!(read_buf[i as usize * 192 + 4], 0x47);
|
||||
assert_eq!(read_buf[i as usize * 192 + 8], i);
|
||||
}
|
||||
}
|
||||
|
||||
// ── M2tsStream passthrough identity ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_passthrough_preserves_data() {
|
||||
let dt = sample_disc_title();
|
||||
|
||||
// Create original BD-TS data
|
||||
let mut original = Vec::new();
|
||||
for i in 0..100u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = (i % 3) << 4;
|
||||
pkt[6] = i;
|
||||
for j in 8..192 { pkt[j] = i.wrapping_add(j as u8); }
|
||||
original.extend_from_slice(&pkt);
|
||||
}
|
||||
|
||||
// Build encoded: header + original data
|
||||
let mut encoded = Vec::new();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
libfreemkv::mux::meta::write_header(&mut encoded, &meta).unwrap();
|
||||
encoded.extend_from_slice(&original);
|
||||
|
||||
// Read back through M2tsStream
|
||||
let cursor = Cursor::new(encoded);
|
||||
let mut stream = M2tsStream::open(cursor).unwrap();
|
||||
let mut decoded = vec![0u8; original.len()];
|
||||
let mut total = 0;
|
||||
loop {
|
||||
match stream.read(&mut decoded[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// BD-TS data must be byte-identical
|
||||
assert_eq!(total, original.len());
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
// ── IOStream trait ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_implements_iostream() {
|
||||
let dt = sample_disc_title();
|
||||
let output = Cursor::new(Vec::new());
|
||||
let stream = M2tsStream::new(output).meta(&dt);
|
||||
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(stream);
|
||||
let meta = boxed.info();
|
||||
assert_eq!(meta.streams.len(), 4);
|
||||
|
||||
let pkt = [0u8; 192];
|
||||
boxed.write_all(&pkt).unwrap();
|
||||
boxed.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m2ts_read_returns_error_on_write_stream() {
|
||||
let output = Cursor::new(Vec::new());
|
||||
let stream = M2tsStream::new(output);
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(stream);
|
||||
let mut buf = [0u8; 10];
|
||||
assert!(boxed.read(&mut buf).is_err());
|
||||
}
|
||||
|
||||
// ── DiscTitle::empty ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn disc_title_empty() {
|
||||
let dt = DiscTitle::empty();
|
||||
assert_eq!(dt.streams.len(), 0);
|
||||
assert_eq!(dt.duration_secs, 0.0);
|
||||
assert!(dt.playlist.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user