mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e771a1867 | ||
|
|
8999db68ea | ||
|
|
2d67b11a2c | ||
|
|
995525d3ff | ||
|
|
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 | ||
|
|
ca732d57e7 | ||
|
|
f357e0fe26 | ||
|
|
976aeb4848 | ||
|
|
2bea69c7d9 | ||
|
|
0bb815ca22 | ||
|
|
aa188751e4 | ||
|
|
ba44f7d928 | ||
|
|
8a95787426 | ||
|
|
aebe6a256b | ||
|
|
791b832f25 | ||
|
|
580ce2277d | ||
|
|
8a556c5c63 | ||
|
|
520a3f912c | ||
|
|
6f0eea48fa | ||
|
|
b01010f2d1 | ||
|
|
fd443e3a2f | ||
|
|
8d85a9d778 | ||
|
|
237528b385 | ||
|
|
492a2966c0 | ||
|
|
5fa6b064da | ||
|
|
df652c5735 | ||
|
|
d0c5c7fb97 | ||
|
|
6ab355245f | ||
|
|
272215c551 | ||
|
|
721308cb8e | ||
|
|
cc58ab1754 | ||
|
|
193b571967 | ||
|
|
bac2e39ee5 | ||
|
|
9825db82aa | ||
|
|
985d00f0a4 | ||
|
|
dbc5789406 | ||
|
|
eb3b84216a | ||
|
|
a07a7aebf2 | ||
|
|
37ee573014 | ||
|
|
b34b7dd2e6 | ||
|
|
dd0f49fa3d | ||
|
|
7d3284cf28 | ||
|
|
117a7823d6 | ||
|
|
7db4606038 | ||
|
|
3f083f57de | ||
|
|
326edc4e83 | ||
|
|
1e90497760 |
@@ -9,13 +9,20 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo test
|
||||
|
||||
check-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo check
|
||||
|
||||
check-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo check
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo test
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Update README version
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
|
||||
|
||||
- name: Update version in README
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
FILE="README.md"
|
||||
|
||||
# Update cargo dependency version (e.g. "0.2" -> "0.3")
|
||||
MAJOR_MINOR="${VERSION#v}"
|
||||
MAJOR_MINOR="${MAJOR_MINOR%.*}"
|
||||
sed -i "s|libfreemkv = \"[0-9]*\.[0-9]*\"|libfreemkv = \"${MAJOR_MINOR}\"|" "$FILE"
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet || git commit -m "Update to libfreemkv ${VERSION}"
|
||||
git push
|
||||
@@ -3,3 +3,4 @@ Cargo.lock
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.cargo/
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.2 (2026-04-11)
|
||||
|
||||
### Windows support
|
||||
|
||||
- **SPTI backend** (`scsi/windows.rs`) — SCSI_PASS_THROUGH_DIRECT via DeviceIoControl
|
||||
- **Windows drive discovery** (`drive/windows.rs`) — scans CdRom0-15 + drive letters
|
||||
- **Platform file separation** — `drive/unix.rs` and `drive/windows.rs`, no inline cfg branches
|
||||
- **CI** — `cargo check` on windows-latest, actions/checkout@v5
|
||||
|
||||
### Test suite
|
||||
|
||||
- **177 tests** (was 64) — MPLS, CLPI, H.264, HEVC, AC3, VC1, DTS, TrueHd, PGS, EBML, UDF, disc scanning, streams
|
||||
- **FEATURES.md** created
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Stable download URLs** — `/latest/download/freemkv-x86_64-unknown-linux-musl.tar.gz` works forever
|
||||
|
||||
## 0.7.1 (2026-04-11)
|
||||
|
||||
### SectorReader trait
|
||||
|
||||
- **`SectorReader` trait** — decouples disc scanning from SCSI. UDF, MPLS, CLPI, labels, and AACS resolution now work with any sector source.
|
||||
- **`Disc::scan_image()`** — scan ISO images or any SectorReader. Full title/stream/label/AACS pipeline, no drive required.
|
||||
- **`resolve_encryption()`** — single function handles AACS 1.0, 2.0, or none. Uses whatever path works (KEYDB VUK, handshake, media key, device key).
|
||||
|
||||
### Stream types
|
||||
|
||||
- **7 stream types** — Disc, ISO, MKV, M2TS, Network, Stdio, Null
|
||||
- **`IsoStream`** — read/write Blu-ray ISO images. Uses `Disc::scan_image()` for full UDF parsing (not heuristic scanning).
|
||||
- **`StdioStream`** — stdin/stdout pipe, format-agnostic
|
||||
- **Strict URL format** — all URLs require `scheme://path`. Bare paths rejected with clear error messages.
|
||||
- **Validation** — empty paths, missing ports, read-only/write-only direction errors
|
||||
|
||||
### IOStream trait
|
||||
|
||||
- `IOStream` trait for all stream types (Read + Write + info + finish)
|
||||
- `open_input()` / `open_output()` resolve URL strings to stream instances
|
||||
|
||||
## 0.7.0 (2026-04-11)
|
||||
|
||||
### Stream I/O architecture
|
||||
|
||||
- **5 stream types** — Disc, MKV, M2TS, Network, Null
|
||||
- **`IOStream` trait** — common interface for all streams
|
||||
- **URL resolver** — `open_input()` / `open_output()` with scheme://path format
|
||||
- **FMKV metadata header** — JSON metadata embedded in M2TS and network streams
|
||||
- **Bidirectional MKV** — MkvStream reads and writes Matroska containers
|
||||
- **Network streaming** — TCP with metadata header, TCP_NODELAY
|
||||
- **BD-TS demuxer** — PAT/PMT scanning, PTS duration detection
|
||||
- **EBML reader** — parse existing MKV files for read-side MkvStream
|
||||
|
||||
## 0.6.0 (2026-04-10)
|
||||
|
||||
### API improvements
|
||||
|
||||
- **`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
|
||||
|
||||
- **Detect-then-parse architecture**: each BD-J authoring format has its own parser module with `detect()` and `parse()` functions. Drop in a new parser with one line in the registry.
|
||||
- **5 format parsers**: Paramount (`playlists.xml`), Criterion (`streamproperties.xml`), Pixelogic (`bluray_project.bin`), Warner CTRM (`menu_base.prop` / `language_streams.txt`), shared label vocabulary (`vocab.rs`)
|
||||
- **Raw disc data principle**: label data passes through as-is from disc. Only BD-standard codec identifiers (MLP, AC3, DTS) are mapped to display names. Unknown authoring tool codes (csp, eda, cf) pass through raw.
|
||||
- **`variant` field**: replaces `region` — language dialect codes from authoring tools, not BD spec regions
|
||||
- Removed: old `jar` module (superseded by labels)
|
||||
- Removed: dead label apply functions from disc.rs
|
||||
|
||||
### Drive
|
||||
|
||||
- **`DriveSession::eject()`**: sends PREVENT ALLOW MEDIUM REMOVAL then START STOP UNIT. Works reliably after raw mode unlock.
|
||||
- **`DiscRegion` enum**: Free, BluRay(A/B/C), Dvd(1-8). UHD always region-free.
|
||||
|
||||
### Capture
|
||||
|
||||
- **Fixed sector range collection**: captures ALL files on disc (only skips STREAM/ video files and >50MB). Previously skipped BACKUP/, DUPLICATE/, and files >10MB which missed JAR content.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
- Labels module: 4 disc file parsers for stream labels
|
||||
- Simplified labels API
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Initial public release
|
||||
- SCSI transport (Linux SG_IO, macOS IOKit)
|
||||
- UDF 2.50 filesystem reader
|
||||
- MPLS/CLPI parsers with full STN support
|
||||
- Drive identification + profile matching
|
||||
- 206 bundled drive profiles
|
||||
- AACS 1.0 decryption (VUK + unit keys)
|
||||
+11
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.2.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
@@ -12,17 +12,21 @@ 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"
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
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"
|
||||
|
||||
[[bin]]
|
||||
name = "freemkv-info"
|
||||
path = "src/bin/freemkv_info.rs"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "freemkv-test"
|
||||
path = "src/bin/freemkv_test.rs"
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# libfreemkv — Feature List
|
||||
|
||||
## v0.7.1 (current)
|
||||
|
||||
### Done
|
||||
- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject
|
||||
- [x] 206 bundled drive profiles (MediaTek MT1959 A + B variants)
|
||||
- [x] SCSI transport: Linux SG_IO, macOS IOKit, Windows SPTI
|
||||
- [x] UDF 2.50 filesystem parser (metadata partitions, Blu-ray profile)
|
||||
- [x] MPLS playlist parser (play items, STN table, secondary streams)
|
||||
- [x] CLPI clip info parser (EP map, sector extents)
|
||||
- [x] AACS 1.0 decryption (4 VUK paths: KEYDB, media key, processing key, device key)
|
||||
- [x] AACS 2.0 SCSI handshake (P-256/SHA-256 ECDH, bus decryption, read data key)
|
||||
- [x] KEYDB.cfg download, verify, save (raw TCP, zero HTTP deps)
|
||||
- [x] Content reading with adaptive batch sizing, error recovery, 12+ MB/s
|
||||
- [x] Stream labels: 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- [x] MKV muxer: 8 codec parsers (H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1, LPCM)
|
||||
- [x] SectorReader trait: decouples disc scanning from SCSI
|
||||
- [x] 7 stream types: Disc, ISO, MKV, M2TS, Network, Stdio, Null
|
||||
- [x] IOStream trait with URL-based resolver (scheme://path)
|
||||
- [x] FMKV metadata header for M2TS and network streams
|
||||
- [x] Numeric error codes only (no English text in library)
|
||||
- [x] Event system for progress callbacks
|
||||
|
||||
### Planned
|
||||
- [ ] Windows testing on real hardware
|
||||
- [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C)
|
||||
- [ ] DVD CSS decryption
|
||||
- [ ] TranscodeStream (ffmpeg integration)
|
||||
- [ ] ISO write with BD-compliant UDF structure
|
||||
@@ -1,147 +1,124 @@
|
||||
[](https://crates.io/crates/libfreemkv)
|
||||
[](https://docs.rs/libfreemkv)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
|
||||
# libfreemkv
|
||||
|
||||
Rust library for raw sector access on optical drives. Identifies drives using standard SCSI commands, matches them against 206 bundled profiles, and unlocks raw read mode. No external files, no configuration.
|
||||
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/)**
|
||||
|
||||
Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
## Install
|
||||
|
||||
### As a library
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.2"
|
||||
libfreemkv = "0.6"
|
||||
```
|
||||
|
||||
### Prebuilt binaries
|
||||
|
||||
Download from [GitHub Releases](https://github.com/freemkv/libfreemkv/releases):
|
||||
|
||||
| Platform | Architecture | Download |
|
||||
|----------|-------------|----------|
|
||||
| Linux | x86_64 | [freemkv-info](https://github.com/freemkv/libfreemkv/releases/latest) |
|
||||
| Linux | aarch64 | [freemkv-info](https://github.com/freemkv/libfreemkv/releases/latest) |
|
||||
|
||||
## Usage
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use libfreemkv::DriveSession;
|
||||
use libfreemkv::{DriveSession, Disc, ScanOptions};
|
||||
use std::path::Path;
|
||||
|
||||
// Open drive — profiles are bundled, auto-identified
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
session.wait_ready()?; // wait for disc
|
||||
session.init()?; // unlock + firmware upload
|
||||
session.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
session.unlock()?; // activate raw read mode
|
||||
session.calibrate()?; // optimize read speed
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let n = session.read_sectors(0, 1, &mut buf)?;
|
||||
```
|
||||
for title in &disc.titles {
|
||||
println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
}
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **INQUIRY** (SPC-4 §6.4) — reads vendor_id, product_id, product_revision, vendor_specific
|
||||
2. **GET CONFIGURATION 010C** (MMC-6 §5.3.10) — reads firmware_date
|
||||
3. **Profile match** — five fields uniquely identify the drive against 206 bundled profiles
|
||||
4. **READ BUFFER** — single command with per-drive mode/buffer_id activates raw read mode
|
||||
5. **Signature verify** — drive responds with 4-byte signature + "MMkv" confirmation
|
||||
|
||||
No fingerprints. No encrypted lookups. All identification uses standard SCSI fields.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
// Open and auto-identify
|
||||
let mut session = DriveSession::open(device_path)?;
|
||||
|
||||
// Drive identity
|
||||
session.drive_id.vendor_id // "HL-DT-ST"
|
||||
session.drive_id.product_id // "BD-RE BU40N"
|
||||
session.drive_id.product_revision // "1.03"
|
||||
session.drive_id.vendor_specific // "NM00000"
|
||||
session.drive_id.firmware_date // "211810241934"
|
||||
|
||||
// Profile data
|
||||
session.profile.chipset // Chipset::MediaTek
|
||||
session.profile.unlock_mode // 0x01
|
||||
session.profile.unlock_buf_id // 0x44
|
||||
session.profile.signature // [0x99, 0x9e, 0xc3, 0x75]
|
||||
|
||||
// Operations
|
||||
session.unlock()?; // activate raw mode
|
||||
session.calibrate()?; // speed optimization
|
||||
session.read_sectors(lba, count, &mut buf)?;
|
||||
session.status()?; // feature flags
|
||||
session.read_config()?; // drive configuration
|
||||
session.read_register(index)?; // hardware registers
|
||||
```
|
||||
|
||||
## Chipset Support
|
||||
|
||||
| Chipset | Status | Drives | Brands |
|
||||
|---------|--------|--------|--------|
|
||||
| MediaTek MT1959 | Supported | 206 | LG, ASUS, HP |
|
||||
| Renesas | Planned | -- | Pioneer |
|
||||
|
||||
Each profile stores per-drive `unlock_mode` and `unlock_buf_id` — the exact CDB bytes for that drive's unlock command. A single `Mt1959` implementation handles all MediaTek variants.
|
||||
|
||||
## Drive Profile
|
||||
|
||||
Each bundled profile (compiled into the binary):
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor_id": "HL-DT-ST",
|
||||
"product_id": "BD-RE BU40N ",
|
||||
"product_revision": "1.03",
|
||||
"vendor_specific": "NM00000",
|
||||
"firmware_date": "211810241934",
|
||||
"chipset": "mediatek",
|
||||
"unlock_mode": 1,
|
||||
"unlock_buf_id": 68,
|
||||
"signature": "999ec375",
|
||||
"register_offsets": ["10e291", "11ab1c"]
|
||||
// Read content (decrypted transparently if AACS keys available)
|
||||
let mut reader = disc.open_title(&mut session, 0)?;
|
||||
while let Some(unit) = reader.read_unit()? {
|
||||
// 6144 bytes of content per aligned unit
|
||||
}
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
## What It Does
|
||||
|
||||
Structured errors for programmatic handling — no user-facing English text in the library.
|
||||
- **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
|
||||
|
||||
| Code | Error | Meaning |
|
||||
|------|-------|---------|
|
||||
| E1000 | DeviceNotFound | Device path doesn't exist |
|
||||
| E1001 | DevicePermission | No access (try sudo or cdrom group) |
|
||||
| E2000 | UnsupportedDrive | No matching profile |
|
||||
| E2001 | ProfileNotFound | Profile lookup failed |
|
||||
| E3000 | UnlockFailed | Unlock command rejected |
|
||||
| E3001 | SignatureMismatch | Response signature wrong |
|
||||
| E3002 | NotUnlocked | Operation requires unlock first |
|
||||
| E4000 | ScsiError | SCSI command failed |
|
||||
| E5000 | IoError | System I/O error |
|
||||
### 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
|
||||
|
||||
```
|
||||
DriveSession
|
||||
├── ScsiTransport SG_IO (Linux) / IOKit (macOS, planned)
|
||||
├── DriveProfile per-drive parameters (bundled JSON, compiled in)
|
||||
├── DriveId INQUIRY + GET_CONFIG 010C fields
|
||||
└── Platform
|
||||
├── Mt1959 MediaTek MT1959 (206 drives)
|
||||
└── (Renesas) Pioneer (planned)
|
||||
```text
|
||||
DriveSession — open any drive, identify, init (optional), read sectors
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||
└── 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
|
||||
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||
├── AACS — key resolution + content decryption
|
||||
└── KEYDB — download + verify + save
|
||||
```
|
||||
|
||||
## Platform
|
||||
See [docs/](docs/) for detailed technical documentation on each module.
|
||||
|
||||
Linux only today (SG_IO ioctl). The `ScsiTransport` trait abstracts the platform — macOS IOKit and Windows SPTI backends are planned.
|
||||
## Error Codes
|
||||
|
||||
All errors are structured with numeric codes. No user-facing English text — applications format their own messages.
|
||||
|
||||
| Range | Category |
|
||||
|-------|----------|
|
||||
| E1xxx | Device errors (not found, permission) |
|
||||
| E2xxx | Profile errors (unsupported drive) |
|
||||
| E3xxx | Unlock errors (failed, signature) |
|
||||
| E4xxx | SCSI errors (command failed, timeout) |
|
||||
| E5xxx | I/O errors |
|
||||
| E6xxx | Disc format errors |
|
||||
| E7xxx | AACS errors |
|
||||
| E8xxx | KEYDB update errors |
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Status | Backend |
|
||||
|----------|--------|---------|
|
||||
| Linux | Supported | SG_IO ioctl |
|
||||
| macOS | Supported | IOKit SCSITask |
|
||||
| Windows | Supported | SPTI |
|
||||
|
||||
## Contributing
|
||||
|
||||
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to submit 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
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fn main() {
|
||||
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,32 @@
|
||||
# libfreemkv Documentation
|
||||
|
||||
Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv), the open source optical drive library.
|
||||
|
||||
## Start Here
|
||||
|
||||
**[Disc to Rip: End-to-End Flow](disc-to-rip.md)** — How the library goes from a disc in the drive to decrypted content. Read this first.
|
||||
|
||||
## Reference
|
||||
|
||||
| Document | What it covers |
|
||||
|----------|---------------|
|
||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||
| [Drive Access](drive-access.md) | DriveSession, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||
| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation |
|
||||
|
||||
## Reading Order
|
||||
|
||||
If you want to understand the whole library:
|
||||
|
||||
1. **[Disc to Rip](disc-to-rip.md)** — the big picture
|
||||
2. **[Architecture](architecture.md)** — how modules fit together
|
||||
3. **[Drive Access](drive-access.md)** — how we talk to hardware
|
||||
4. **[UDF](udf.md)** → **[MPLS](mpls.md)** → **[CLPI](clpi.md)** — how disc content is structured
|
||||
5. **[AACS](aacs.md)** — how encryption works and how we break it
|
||||
|
||||
## API Documentation
|
||||
|
||||
Generated API docs are on [docs.rs/libfreemkv](https://docs.rs/libfreemkv).
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
# AACS Encryption Support
|
||||
|
||||
## Overview
|
||||
|
||||
AACS (Advanced Access Content System) is the encryption layer used by Blu-ray and UHD 4K discs to protect content. libfreemkv implements AACS decryption to enable transparent disc access.
|
||||
|
||||
There are two major versions:
|
||||
|
||||
- **AACS 1.0** -- Used by standard Blu-ray discs. Relies on a custom 160-bit elliptic curve for bus authentication and AES-128 for content encryption. Processing keys and device keys can derive the media key from the disc's Media Key Block (MKB).
|
||||
|
||||
- **AACS 2.0** -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus encryption layer (read_data_key) on top of the standard content encryption. Uses P-256/SHA-256 for its native handshake, though drives accept AACS 1.0 host certificates for backward compatibility.
|
||||
|
||||
Both versions use AES-128-CBC for content decryption with a fixed initialization vector. The fundamental key hierarchy is the same: a Volume Unique Key (VUK) decrypts per-title unit keys, which in turn decrypt the content stream.
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
AACS support is split across two modules:
|
||||
|
||||
### `aacs.rs` -- Keys and Decryption
|
||||
|
||||
Handles everything related to key resolution and content decryption:
|
||||
|
||||
- KEYDB.cfg parsing (device keys, processing keys, host certificates, per-disc entries)
|
||||
- Disc hash computation (SHA-1 of `Unit_Key_RO.inf`)
|
||||
- VUK resolution chain (4 paths, described below)
|
||||
- MKB record parsing and media key derivation
|
||||
- Subset-difference tree traversal (AACS-G3 key derivation)
|
||||
- Unit_Key_RO.inf parsing and unit key decryption
|
||||
- Content Certificate parsing (AACS version detection)
|
||||
- Aligned unit decryption (AES-128-CBC)
|
||||
- Bus decryption (AACS 2.0 read_data_key layer)
|
||||
|
||||
### `aacs_handshake.rs` -- SCSI Authentication
|
||||
|
||||
Handles the drive-level SCSI authentication protocol:
|
||||
|
||||
- ECDH key agreement on the AACS 160-bit curve
|
||||
- ECDSA signing and verification
|
||||
- Bus key derivation
|
||||
- AGID management (allocate/invalidate)
|
||||
- Volume ID retrieval (encrypted with bus key, verified by AES-CMAC)
|
||||
- Read Data Key retrieval (for AACS 2.0 bus decryption)
|
||||
- AACS LA public key certificate verification
|
||||
|
||||
|
||||
## Key Resolution Chain
|
||||
|
||||
When a disc is scanned, `resolve_keys()` attempts four paths in priority order. The first path that succeeds is used.
|
||||
|
||||
### Path 1: KEYDB VUK Lookup (fastest)
|
||||
|
||||
```
|
||||
Unit_Key_RO.inf --> SHA-1 --> disc_hash --> KEYDB lookup --> VUK
|
||||
```
|
||||
|
||||
The disc hash is computed as the SHA-1 digest of the raw `Unit_Key_RO.inf` file from the disc's `/AACS/` directory. This hash is used as the lookup key in `KEYDB.cfg`. If a matching entry contains a VUK (`V` field), it is used directly.
|
||||
|
||||
This is the fast path and resolves the vast majority of discs in a well-maintained KEYDB.
|
||||
|
||||
### Path 2: KEYDB Media Key + Volume ID
|
||||
|
||||
```
|
||||
KEYDB media_key + Volume ID (from SCSI handshake) --> VUK derivation
|
||||
```
|
||||
|
||||
If the disc hash is not in the KEYDB but a KEYDB entry has a matching Volume ID (`I` field) and a media key (`M` field), the VUK is derived:
|
||||
|
||||
```
|
||||
VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
||||
```
|
||||
|
||||
Requires a successful SCSI handshake to obtain the Volume ID.
|
||||
|
||||
### Path 3: MKB + Processing Keys
|
||||
|
||||
```
|
||||
MKB (from disc) + processing_keys (from KEYDB) --> media_key --> VUK
|
||||
```
|
||||
|
||||
Processing keys are pre-computed keys that work against specific MKB versions. For each processing key, the library:
|
||||
|
||||
1. Parses the MKB to extract the Verify Media Key Record (`mk_dv`), subset-difference index, and conditional values (cvalues).
|
||||
2. Tries each processing key against each UV/cvalue pair: `mk = AES-DEC(pk, cvalue) XOR cvalue`.
|
||||
3. Validates the derived media key: `AES-ECB(mk, mk_dv)` must produce 12 leading zero bytes.
|
||||
4. Derives VUK from the validated media key and Volume ID.
|
||||
|
||||
### Path 4: MKB + Device Keys (Subset-Difference Tree)
|
||||
|
||||
```
|
||||
MKB + device_keys --> subset-difference tree traversal --> processing_key --> media_key --> VUK
|
||||
```
|
||||
|
||||
The most complex path. Each device key has an associated node number, UV value, and mask parameters that position it in the AACS subset-difference tree. The library:
|
||||
|
||||
1. Finds the subset-difference entry in the MKB that applies to the device key's node.
|
||||
2. Traverses the tree using AACS-G3 key derivation: `aesg3(key, inc) = AES-DEC(key, seed) XOR seed`, where `seed[15]` is incremented by `inc`. Each tree node produces a left child (inc=0), a processing key (inc=1), and a right child (inc=2).
|
||||
3. At each level, selects left or right based on the UV bit at the current position.
|
||||
4. The resulting processing key is validated against the MKB cvalue to derive the media key.
|
||||
5. VUK is derived from the media key and Volume ID.
|
||||
|
||||
|
||||
## Content Decryption
|
||||
|
||||
### Aligned Units
|
||||
|
||||
AACS encrypts content in aligned units of 6144 bytes (3 sectors of 2048 bytes each). The encryption flag is signaled by the copy_permission_indicator bits in byte 0 of the unit (`unit[0] & 0xC0 != 0`).
|
||||
|
||||
### Per-Unit Key Derivation
|
||||
|
||||
Each aligned unit has its own decryption key derived from the CPS unit key:
|
||||
|
||||
1. **Derive**: AES-128-ECB encrypt the first 16 bytes of the unit (plaintext TP_extra_header) with the unit key.
|
||||
2. **XOR**: XOR the encrypted result with the original 16 bytes to produce the per-unit decryption key.
|
||||
3. **Decrypt**: AES-128-CBC decrypt bytes 16 through 6143 using the per-unit key and the fixed AACS IV.
|
||||
4. **Clear flag**: Clear the encryption indicator bits (`unit[0] &= !0xC0`).
|
||||
|
||||
### Fixed IV
|
||||
|
||||
All AES-CBC operations in AACS use the same fixed initialization vector, defined in the AACS specification.
|
||||
|
||||
### Verification
|
||||
|
||||
After decryption, the library verifies correctness by checking for MPEG-TS sync bytes (0x47) at the expected 192-byte packet boundaries within the unit. Blu-ray transport stream packets are 192 bytes: 4-byte TP_extra_header followed by a 188-byte TS packet.
|
||||
|
||||
|
||||
## Bus Encryption
|
||||
|
||||
### AACS 1.0
|
||||
|
||||
Standard Blu-ray discs do not use bus encryption. Content is read directly from the disc and decrypted using the unit key.
|
||||
|
||||
### AACS 2.0
|
||||
|
||||
UHD 4K discs add a per-sector bus encryption layer. The drive encrypts data as it is read from the disc, and the host must decrypt it before applying AACS content decryption.
|
||||
|
||||
Bus encryption uses a **read_data_key** obtained during the SCSI handshake. For each 2048-byte sector within an aligned unit, bytes 16 through 2047 are AES-128-CBC encrypted with the read_data_key and the fixed AACS IV. The first 16 bytes of each sector remain plaintext.
|
||||
|
||||
The full decryption pipeline for AACS 2.0:
|
||||
|
||||
1. **Bus decrypt**: For each sector, AES-128-CBC decrypt bytes 16..2047 with the read_data_key.
|
||||
2. **Content decrypt**: Standard per-unit key derivation and AES-128-CBC decryption as described above.
|
||||
|
||||
|
||||
## SCSI Handshake
|
||||
|
||||
The AACS SCSI authentication handshake establishes a shared bus key between host and drive, then uses it to securely transfer the Volume ID and read data keys.
|
||||
|
||||
### Protocol Flow
|
||||
|
||||
1. **Invalidate AGIDs**: Send REPORT KEY with format 0x3F for AGIDs 0-3 to clear stale sessions.
|
||||
2. **Allocate AGID**: REPORT KEY format 0x00 returns a fresh Authentication Grant ID.
|
||||
3. **Send host credentials**: SEND KEY format 0x01 transmits the host nonce (20 random bytes) and host certificate (92 bytes).
|
||||
4. **Receive drive credentials**: REPORT KEY format 0x01 returns the drive nonce and drive certificate.
|
||||
5. **Receive drive key**: REPORT KEY format 0x02 returns the drive's ephemeral EC key point and ECDSA signature over `host_nonce || drive_key_point`.
|
||||
6. **Verify drive key**: The signature is verified against the drive's public key (extracted from its certificate). AACS 1.0 certificates are verified against the AACS LA public key.
|
||||
7. **Send host key**: The host generates an ephemeral key pair, signs `drive_nonce || host_key_point` with the host private key, and sends via SEND KEY format 0x02.
|
||||
8. **Compute bus key**: ECDH shared secret = `host_private_key * drive_key_point`. The bus key is the low 128 bits of the shared point's x-coordinate.
|
||||
|
||||
### Post-Authentication Reads
|
||||
|
||||
- **Volume ID**: REPORT DISC STRUCTURE format 0x80. Returns 16-byte VID encrypted with the bus key, plus an AES-CMAC MAC for integrity verification.
|
||||
- **Read Data Keys**: REPORT DISC STRUCTURE format 0x84. Returns the read_data_key and write_data_key, each AES-ECB encrypted with the bus key.
|
||||
|
||||
### Elliptic Curve
|
||||
|
||||
AACS 1.0 uses a custom 160-bit Weierstrass curve (`y^2 = x^3 + ax + b mod p`) with 20-byte field elements. The library implements full EC arithmetic: point addition, doubling, scalar multiplication, modular inverse, ECDSA sign/verify, and ECDH key agreement.
|
||||
|
||||
|
||||
## AACS 2.0 Status
|
||||
|
||||
AACS 2.0 discs are detected via the Content Certificate file (`Content000.cer` or `Content001.cer`). A certificate type byte of 0x01 indicates AACS 2.0.
|
||||
|
||||
AACS 2.0 drives are identified by their drive certificate type (0x11). These drives natively use P-256/SHA-256, but accept AACS 1.0 host certificates for backward compatibility.
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- AACS 2.0 detection: **implemented** (Content Certificate parsing, drive cert type check)
|
||||
- AACS 1.0 handshake with AACS 2.0 drives: **implemented** (backward compatibility mode)
|
||||
- Full P-256 AACS 2.0 handshake: **not yet implemented** (prepared but rarely needed since drives accept AACS 1.0 host certs)
|
||||
- Bus decryption with read_data_key: **implemented**
|
||||
- Content decryption: **implemented** (same as AACS 1.0)
|
||||
|
||||
In practice, AACS 2.0 UHD discs work through the backward-compatible AACS 1.0 handshake path, with the addition of read_data_key bus decryption.
|
||||
|
||||
|
||||
## API Usage
|
||||
|
||||
AACS decryption is transparent to the application. The `Disc::scan()` method handles everything automatically:
|
||||
|
||||
```rust
|
||||
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();
|
||||
|
||||
// Check encryption state
|
||||
if disc.encrypted {
|
||||
if let Some(ref aacs) = disc.aacs {
|
||||
println!("AACS {}.0", aacs.version);
|
||||
println!("Key source: {}", aacs.key_source.name());
|
||||
println!("Disc hash: {}", aacs.disc_hash);
|
||||
if let Some(mkb_ver) = aacs.mkb_version {
|
||||
println!("MKB version: {}", mkb_ver);
|
||||
}
|
||||
} else {
|
||||
println!("Encrypted but keys not available");
|
||||
}
|
||||
}
|
||||
|
||||
// Read content -- decryption is automatic
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
The application never touches keys, never calls decryption functions, and never manages handshakes. All of that is internal to `Disc::scan()` and `ContentReader::read_unit()`.
|
||||
|
||||
### KEYDB Location
|
||||
|
||||
`ScanOptions` controls where the KEYDB is loaded from. If no explicit path is set, the library checks:
|
||||
|
||||
1. `~/.config/aacs/KEYDB.cfg`
|
||||
2. `/etc/aacs/KEYDB.cfg`
|
||||
|
||||
To specify an explicit path:
|
||||
|
||||
```rust
|
||||
let opts = ScanOptions::with_keydb("/path/to/KEYDB.cfg");
|
||||
let disc = Disc::scan(&mut session, &opts).unwrap();
|
||||
```
|
||||
|
||||
### AacsState
|
||||
|
||||
After a successful scan, `disc.aacs` contains an `AacsState` with:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `version` | `u8` | AACS version (1 or 2) |
|
||||
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
||||
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
||||
| `disc_hash` | `String` | SHA-1 of Unit_Key_RO.inf (hex with 0x prefix) |
|
||||
| `key_source` | `KeySource` | How keys were resolved |
|
||||
| `vuk` | `[u8; 16]` | Volume Unique Key |
|
||||
| `unit_keys` | `Vec<(u32, [u8; 16])>` | Decrypted unit keys (CPS unit number, key) |
|
||||
| `read_data_key` | `Option<[u8; 16]>` | AACS 2.0 bus decryption key |
|
||||
| `volume_id` | `[u8; 16]` | Volume ID from SCSI handshake |
|
||||
|
||||
### KeySource
|
||||
|
||||
| Variant | Description |
|
||||
|---------|-------------|
|
||||
| `KeyDb` | VUK found directly in KEYDB by disc hash |
|
||||
| `KeyDbDerived` | Media key + Volume ID from KEYDB, VUK derived |
|
||||
| `ProcessingKey` | MKB + processing keys from KEYDB |
|
||||
| `DeviceKey` | MKB + device keys, subset-difference tree traversal |
|
||||
|
||||
|
||||
## KEYDB.cfg Format Reference
|
||||
|
||||
The KEYDB.cfg file contains all cryptographic material needed for AACS decryption. Lines starting with `;` or `#` are comments.
|
||||
|
||||
### Device Keys
|
||||
|
||||
```
|
||||
| DK | DEVICE_KEY 0x<key> | DEVICE_NODE 0x<node> | KEY_UV 0x<uv> | KEY_U_MASK_SHIFT 0x<shift>
|
||||
```
|
||||
|
||||
- `key`: 16-byte AES device key (hex)
|
||||
- `node`: Device node number in the subset-difference tree (hex)
|
||||
- `uv`: UV value for tree positioning (hex)
|
||||
- `shift`: U mask shift value (hex)
|
||||
|
||||
### Processing Keys
|
||||
|
||||
```
|
||||
| PK | 0x<key>
|
||||
```
|
||||
|
||||
- `key`: 16-byte pre-computed processing key (hex)
|
||||
|
||||
### Host Certificate
|
||||
|
||||
```
|
||||
| HC | HOST_PRIV_KEY 0x<privkey> | HOST_CERT 0x<cert>
|
||||
```
|
||||
|
||||
- `privkey`: 20-byte ECDSA private key (hex)
|
||||
- `cert`: 92-byte AACS host certificate (hex)
|
||||
|
||||
The host certificate is used for SCSI authentication. It contains the host's public key and is signed by the AACS Licensing Administrator.
|
||||
|
||||
### Disc Entries
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
|
||||
```
|
||||
|
||||
- `disc_hash`: 20-byte SHA-1 of Unit_Key_RO.inf (hex)
|
||||
- `title`: Human-readable disc title
|
||||
- `D`: Date tag, followed by release/rip date
|
||||
- `M`: Media key tag, followed by 16-byte media key (hex)
|
||||
- `I`: Disc ID tag, followed by 16-byte Volume ID (hex)
|
||||
- `V`: VUK tag, followed by 16-byte Volume Unique Key (hex)
|
||||
- `U`: Unit keys tag, followed by space-separated `<unit_num>-0x<key>` pairs
|
||||
|
||||
All fields after the title are optional. A minimal entry needs only the disc hash and VUK:
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | V | 0x<vuk>
|
||||
```
|
||||
|
||||
Inline comments are supported with `;`:
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | V | 0x<vuk> ; MKBv77
|
||||
```
|
||||
@@ -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,180 @@
|
||||
# libfreemkv Architecture
|
||||
|
||||
Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD.
|
||||
Rust library with no external dependencies at runtime -- profiles are bundled,
|
||||
AACS keys are derived internally, and all SCSI communication is handled in-process.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** AGPL-3.0-only
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
|
||||
format handling live in the library. CLI binaries are thin wrappers that call
|
||||
`DriveSession::open()` and `Disc::scan()`.
|
||||
|
||||
2. **No external files.** 206 drive profiles are compiled into the binary via
|
||||
`include_str!`. No configuration directory, no runtime file lookups for drive
|
||||
support.
|
||||
|
||||
3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are
|
||||
available. Callers read cleartext sectors without knowing whether the disc
|
||||
was encrypted.
|
||||
|
||||
4. **Structured errors, no English.** Every error has a numeric code (E1000-E7000).
|
||||
The library never formats user-facing messages -- applications do that.
|
||||
|
||||
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
||||
policy level. If a profile exists, the library uses it.
|
||||
|
||||
---
|
||||
|
||||
## Module Map
|
||||
|
||||
```
|
||||
libfreemkv (lib.rs)
|
||||
│
|
||||
├── Drive Access
|
||||
│ ├── drive DriveSession — open, identify, unlock, read
|
||||
│ ├── scsi ScsiTransport trait + SG_IO implementation
|
||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, hp)
|
||||
│ ├── profile DriveProfile loading, matching, bundled JSON
|
||||
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
|
||||
│ └── speed DriveSpeed enum, SET CD SPEED CDB builder
|
||||
│
|
||||
├── Disc Scanning
|
||||
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
|
||||
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
|
||||
│ ├── mpls MPLS playlist parser — clips, streams, STN table
|
||||
│ ├── clpi CLPI clip info parser — EP map, sector extents
|
||||
│ └── jar BD-J JAR label extraction (audio/subtitle names)
|
||||
│
|
||||
├── Encryption
|
||||
│ ├── aacs KEYDB parsing, VUK lookup, MKB processing, unit decryption
|
||||
│ └── aacs_handshake ECDH bus authentication, Volume ID, Read Data Key
|
||||
│
|
||||
└── error Error enum with numeric codes E1000-E7000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drive Access Flow
|
||||
|
||||
```
|
||||
DriveSession::open("/dev/sr0")
|
||||
│
|
||||
├─ scsi::open() Open /dev/sr0 via SG_IO
|
||||
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
||||
├─ profile::find_by_drive_id() Match against 206 bundled profiles
|
||||
├─ Platform::new() Instantiate chipset driver (Mt1959)
|
||||
└─ Platform::unlock() Activate raw disc access mode
|
||||
```
|
||||
|
||||
After open, the session provides:
|
||||
- `read_sectors(lba, count, buf)` -- raw sector reads (through platform driver)
|
||||
- `read_disc(lba, count, buf)` -- standard READ(10) for filesystem data
|
||||
- `scsi_execute(cdb, dir, buf, timeout)` -- arbitrary SCSI commands
|
||||
- `status()`, `calibrate()`, `read_config()`, `read_register()`
|
||||
|
||||
---
|
||||
|
||||
## Disc Scanning Flow
|
||||
|
||||
```
|
||||
Disc::scan(&mut session, &ScanOptions)
|
||||
│
|
||||
├─ READ CAPACITY Get disc size in sectors
|
||||
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
|
||||
├─ For each BDMV/PLAYLIST/*.mpls:
|
||||
│ ├─ mpls::parse() Extract play items, STN streams
|
||||
│ └─ For each clip:
|
||||
│ └─ clpi::parse() EP map → sector extents for the clip's time range
|
||||
├─ Detect AACS Check for /AACS directory on disc
|
||||
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
|
||||
```
|
||||
|
||||
The result is a `Disc` with:
|
||||
- `titles: Vec<Title>` -- sorted by duration, each with streams and sector extents
|
||||
- `aacs: Option<AacsState>` -- decryption keys if available
|
||||
- `encrypted: bool` -- whether the disc uses AACS
|
||||
|
||||
---
|
||||
|
||||
## AACS Decryption
|
||||
|
||||
Four key resolution paths, tried in order:
|
||||
|
||||
| Path | Method | Speed |
|
||||
|------|--------|-------|
|
||||
| 1 | VUK lookup by disc hash in KEYDB.cfg | Instant |
|
||||
| 2 | Media Key + Volume ID from KEYDB → derive VUK | Fast |
|
||||
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
|
||||
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
|
||||
|
||||
The AACS handshake (`aacs_handshake`) performs ECDH key agreement over the
|
||||
AACS 1.0 160-bit elliptic curve to obtain:
|
||||
- **Volume ID** -- needed for VUK derivation (paths 2-4)
|
||||
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
|
||||
|
||||
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
|
||||
`ContentReader` handles this transparently.
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
All errors carry a numeric code for programmatic handling. No user-facing text
|
||||
is baked into the library.
|
||||
|
||||
| Range | Category | Examples |
|
||||
|-------|----------|----------|
|
||||
| E1xxx | Device errors | `DeviceNotFound`, `DevicePermission` |
|
||||
| E2xxx | Profile errors | `UnsupportedDrive`, `ProfileNotFound`, `ProfileParse` |
|
||||
| E3xxx | Unlock errors | `UnlockFailed`, `SignatureMismatch`, `NotUnlocked`, `NotCalibrated` |
|
||||
| E4xxx | SCSI errors | `ScsiError`, `ScsiTimeout` |
|
||||
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
|
||||
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
|
||||
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Transport | Status |
|
||||
|----------|-----------|--------|
|
||||
| Linux | SG_IO ioctl on `/dev/sr*` | Implemented |
|
||||
| macOS | IOKit SCSI passthrough | Planned |
|
||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Planned |
|
||||
|
||||
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
|
||||
implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
||||
|
||||
---
|
||||
|
||||
## Chipset Support
|
||||
|
||||
| Chipset | Drives | Status |
|
||||
|---------|--------|--------|
|
||||
| MediaTek MT1959 | LG, ASUS, hp | Implemented (206 profiles) |
|
||||
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
|
||||
|
||||
The `Platform` trait abstracts chipset-specific commands. Each chipset implements
|
||||
10 handlers (unlock, config, register, calibrate, keepalive, status, probe,
|
||||
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
|
||||
chipset-specific mode and buffer ID bytes.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Linux builds produce a static library and two binaries (`freemkv-info`,
|
||||
`freemkv-test`). The `libc` dependency is Linux-only. On non-Linux platforms,
|
||||
the library compiles but `scsi::open()` returns a platform-not-supported error
|
||||
until the IOKit/SPTI backends are implemented.
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
# CLPI Clip Information Format
|
||||
|
||||
## What is CLPI?
|
||||
|
||||
CLPI (Clip Information) files describe the structure of individual M2TS transport streams on a Blu-ray disc. Each `.clpi` file in `BDMV/CLIPINF/` corresponds to one `.m2ts` file in `BDMV/STREAM/` with the same numeric name (e.g. `00001.clpi` describes `00001.m2ts`).
|
||||
|
||||
The most important data in a CLPI file is the **EP (Entry Point) map**, which provides timestamp-to-sector mapping. This is essential for seeking and for extracting the specific sector ranges that correspond to a playlist's in/out time window.
|
||||
|
||||
## File Structure
|
||||
|
||||
CLPI files use big-endian byte order. The header:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 Magic: "HDMV"
|
||||
4 4 Version: "0200" (BD) or "0300" (UHD BD)
|
||||
8 4 SequenceInfo start offset
|
||||
12 4 ProgramInfo start offset
|
||||
16 4 CPI (Characteristic Point Information) start offset
|
||||
20 4 ClipMark start offset
|
||||
24 4 ExtensionData start offset
|
||||
```
|
||||
|
||||
### ClipInfo Section (offset 40)
|
||||
|
||||
Contains basic clip metadata. The source packet count (total number of 192-byte source packets in the M2TS file) is at offset 56:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
40 4 ClipInfo length
|
||||
44 2 Reserved
|
||||
46 1 Stream type
|
||||
47 1 Application type
|
||||
48 4 Reserved
|
||||
52 4 TS recording rate
|
||||
56 4 Source packet count
|
||||
```
|
||||
|
||||
The source packet count multiplied by 192 gives the total byte size of the M2TS file.
|
||||
|
||||
## EP Map
|
||||
|
||||
The EP map lives inside the CPI section and provides random access into the transport stream. It maps PTS timestamps to Source Packet Numbers (SPN), enabling precise seeking without scanning the stream.
|
||||
|
||||
### CPI Section Layout
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 CPI length
|
||||
4 2 Reserved / CPI type
|
||||
6 ... EP map
|
||||
```
|
||||
|
||||
### EP Map Header
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Reserved
|
||||
1 1 Number of stream PID entries
|
||||
2 ... Stream PID entry headers (one per stream)
|
||||
```
|
||||
|
||||
Each stream PID entry header (14 bytes):
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 Stream PID
|
||||
2 2 Reserved + EP stream type
|
||||
4 2 Number of coarse entries
|
||||
6 4 Number of fine entries (note: 32-bit, can be large)
|
||||
10 4 EP map start offset (relative to EP map start)
|
||||
```
|
||||
|
||||
libfreemkv parses only the first stream (primary video), which is sufficient for sector-level seeking.
|
||||
|
||||
### Two-Level Index
|
||||
|
||||
The EP map uses a two-level structure to compress what would otherwise be a very large lookup table:
|
||||
|
||||
- **Coarse entries**: low-resolution index covering large time/sector ranges
|
||||
- **Fine entries**: high-resolution entries within each coarse range
|
||||
|
||||
Each coarse entry points to a range of fine entries via `ref_to_fine_id`.
|
||||
|
||||
### Coarse Entries (8 bytes each)
|
||||
|
||||
Located immediately after the fine table start offset (4 bytes) in the per-stream EP map:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31:14] ref_to_fine_id (18 bits) -- index of first fine entry in this range
|
||||
[13:0] pts_coarse (14 bits) -- upper bits of PTS
|
||||
```
|
||||
|
||||
Second dword:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31:0] spn_coarse (32 bits) -- upper bits of SPN
|
||||
```
|
||||
|
||||
### Fine Entries (4 bytes each)
|
||||
|
||||
Located at the fine table start offset within the per-stream EP map:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31] is_angle_change_point (1 bit)
|
||||
[30:28] I_end_position_offset (3 bits)
|
||||
[27:17] pts_fine (11 bits) -- lower bits of PTS
|
||||
[16:0] spn_fine (17 bits) -- lower bits of SPN
|
||||
```
|
||||
|
||||
## Reconstructing Full PTS and SPN
|
||||
|
||||
The full timestamp and packet number are assembled by combining the coarse and fine components:
|
||||
|
||||
### Full PTS
|
||||
|
||||
```
|
||||
full_pts = (pts_coarse << 19) + (pts_fine << 8)
|
||||
```
|
||||
|
||||
The coarse component provides the upper bits, shifted left by 19. The fine component provides mid-range bits, shifted left by 8. The resulting PTS is in 45kHz ticks, matching the MPLS timestamp format.
|
||||
|
||||
### Full SPN
|
||||
|
||||
```
|
||||
full_spn = (spn_coarse & 0xFFFE0000) + spn_fine
|
||||
```
|
||||
|
||||
The coarse SPN provides the upper 15 bits (masked to clear the lower 17). The fine SPN provides the lower 17 bits. The result is a Source Packet Number -- each source packet is 192 bytes in the M2TS file.
|
||||
|
||||
### Resolved EP Map
|
||||
|
||||
The `resolved_ep_map()` method iterates through all coarse entries and their associated fine entries to produce a flat list of `(PTS, SPN)` pairs. For each coarse entry at index `ci`:
|
||||
|
||||
- Fine entries range from `coarse[ci].ref_to_fine_id` to `coarse[ci+1].ref_to_fine_id` (or end of fine table for the last coarse entry).
|
||||
- Each fine entry is combined with its parent coarse entry to produce one full `(PTS, SPN)` pair.
|
||||
|
||||
## Deriving Sector Extents for Ripping
|
||||
|
||||
Given a playlist's in_time and out_time (from MPLS), the CLPI EP map provides the sector ranges to read from disc. The `get_extents()` method does this in three steps:
|
||||
|
||||
### Step 1: PTS to SPN
|
||||
|
||||
Binary search the resolved EP map for the in_time and out_time:
|
||||
|
||||
- **start_spn**: the SPN at or before in_time (seek backward to the nearest I-frame)
|
||||
- **end_spn**: the SPN at or after out_time (include the full GOP)
|
||||
|
||||
### Step 2: SPN to Byte Offset
|
||||
|
||||
Each Source Packet is 192 bytes (188 bytes MPEG-TS payload + 4 bytes M2TS header):
|
||||
|
||||
```
|
||||
byte_offset = spn * 192
|
||||
```
|
||||
|
||||
### Step 3: Byte Offset to Sector
|
||||
|
||||
M2TS files are stored contiguously on disc. Sectors are 2048 bytes:
|
||||
|
||||
```
|
||||
start_sector = start_byte / 2048
|
||||
end_sector = (end_byte + 2047) / 2048
|
||||
sector_count = end_sector - start_sector
|
||||
```
|
||||
|
||||
The resulting `Extent` contains `start_lba` (relative to the M2TS file's starting sector on disc) and `sector_count`. The caller adds the file's absolute starting LBA from UDF to get disc-absolute sector numbers.
|
||||
|
||||
### Alignment Note
|
||||
|
||||
The 192-byte source packet size and 2048-byte sector size share no common factor beyond 1. A single sector contains roughly 10.67 source packets. The conversion rounds start down and end up to ensure complete coverage.
|
||||
|
||||
## Putting It Together
|
||||
|
||||
The full ripping pipeline chains three parsers:
|
||||
|
||||
1. **MPLS** provides clip IDs and in/out timestamps.
|
||||
2. **CLPI** converts those timestamps to SPN ranges, then to sector extents.
|
||||
3. **UDF** provides the file's starting LBA on disc for absolute sector addressing.
|
||||
|
||||
The `Disc::scan()` method in `src/disc.rs` orchestrates this: for each play item in each playlist, it loads the corresponding CLPI, calls `get_extents()` with the play item's in/out times, and collects the resulting sector ranges into the title's extent list.
|
||||
|
||||
## References
|
||||
|
||||
- BD-ROM Part 3, Section 5.5: Clip Information file format
|
||||
- https://github.com/lw/BluRay/wiki/CLPI
|
||||
@@ -0,0 +1,114 @@
|
||||
# Disc to Rip: End-to-End Flow
|
||||
|
||||
How libfreemkv goes from a disc in the drive to decrypted content ready for backup.
|
||||
This is the starting point for understanding the library.
|
||||
|
||||
## The Pipeline
|
||||
|
||||
```
|
||||
Insert disc
|
||||
│
|
||||
▼
|
||||
1. Open drive (drive.rs)
|
||||
│ INQUIRY → identify drive
|
||||
│ Match bundled profile → chipset, unlock parameters
|
||||
│
|
||||
▼
|
||||
2. AACS handshake (aacs_handshake.rs) — optional, separate transport
|
||||
│ Allocate AGID
|
||||
│ Exchange certificates + nonces (ECDH)
|
||||
│ Derive bus key
|
||||
│ Read Volume ID + read_data_key
|
||||
│ (fails gracefully if drive doesn't support AACS for this disc)
|
||||
│
|
||||
▼
|
||||
3. Unlock drive (drive.rs → platform/mt1959.rs)
|
||||
│ Vendor-specific command activates raw read mode
|
||||
│ Required — drive firmware blocks all reads without it
|
||||
│
|
||||
▼
|
||||
4. Read UDF filesystem (udf.rs)
|
||||
│ Sector 256: AVDP → find Volume Descriptor Sequence
|
||||
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
|
||||
│ Metadata partition → File Set Descriptor → Root directory
|
||||
│ Walk directory tree: BDMV/, AACS/, CERTIFICATE/
|
||||
│ → docs/udf.md
|
||||
│
|
||||
▼
|
||||
5. Read AACS files from disc (aacs.rs)
|
||||
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
|
||||
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
|
||||
│ MKB via SCSI → for key derivation fallback
|
||||
│
|
||||
▼
|
||||
6. Resolve AACS keys (aacs.rs → resolve_keys)
|
||||
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||||
│ Path 2: KEYDB media key + Volume ID → VUK
|
||||
│ Path 3: MKB + processing keys → media key → VUK
|
||||
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
||||
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
||||
│ → docs/aacs.md
|
||||
│
|
||||
▼
|
||||
7. Parse playlists (mpls.rs)
|
||||
│ BDMV/PLAYLIST/*.mpls → titles with play items
|
||||
│ Each play item: clip ID, in/out timestamps
|
||||
│ STN table: video, audio, subtitle streams with codec + language
|
||||
│ → docs/mpls.md
|
||||
│
|
||||
▼
|
||||
8. Parse clip info (clpi.rs)
|
||||
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
|
||||
│ Coarse + fine entries → full PTS and SPN
|
||||
│ SPN → byte offset → sector extents for reading
|
||||
│ → docs/clpi.md
|
||||
│
|
||||
▼
|
||||
9. Parse BD-J labels (jar.rs) — optional
|
||||
│ BDMV/JAR/*.jar → Java class constant pool strings
|
||||
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
|
||||
│
|
||||
▼
|
||||
10. Read + decrypt content (disc.rs → ContentReader)
|
||||
│ For each aligned unit (6144 bytes = 3 sectors):
|
||||
│ Read 3 sectors from disc
|
||||
│ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC)
|
||||
│ If encrypted: unit decrypt (per-unit key derivation + AES-CBC)
|
||||
│ Output decrypted content
|
||||
│
|
||||
▼
|
||||
Decrypted m2ts stream → ready for muxing/backup
|
||||
```
|
||||
|
||||
## API Summary
|
||||
|
||||
```rust
|
||||
// Steps 1 + 3 (open + unlock)
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
|
||||
// Steps 2 + 4-9 (AACS + scan)
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?;
|
||||
|
||||
// Step 10 (read + decrypt)
|
||||
let mut reader = disc.open_title(&mut session, 0)?;
|
||||
while let Some(unit) = reader.read_unit()? {
|
||||
output.write_all(&unit)?;
|
||||
}
|
||||
```
|
||||
|
||||
Three lines. Everything else is internal.
|
||||
|
||||
## Module Reference
|
||||
|
||||
| Module | Doc | Purpose |
|
||||
|--------|-----|---------|
|
||||
| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read |
|
||||
| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport |
|
||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
|
||||
| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt |
|
||||
| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication |
|
||||
| disc.rs | -- | High-level scan + read API |
|
||||
| jar.rs | -- | BD-J audio track labels |
|
||||
| error.rs | -- | Error codes (E1xxx-E7xxx) |
|
||||
@@ -0,0 +1,253 @@
|
||||
# Drive Access and Unlock
|
||||
|
||||
Technical reference for how libfreemkv opens, identifies, unlocks, and reads
|
||||
optical drives.
|
||||
|
||||
---
|
||||
|
||||
## DriveSession
|
||||
|
||||
`DriveSession` is the primary API. It owns the SCSI transport, the matched
|
||||
drive profile, and the chipset-specific platform driver.
|
||||
|
||||
### Opening a Drive
|
||||
|
||||
```rust
|
||||
// Full open: identify → match profile → unlock
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
|
||||
// No-unlock open: identify → match profile only
|
||||
let mut session = DriveSession::open_no_unlock(Path::new("/dev/sr0"))?;
|
||||
|
||||
// Explicit profile (skip auto-detection)
|
||||
let mut session = DriveSession::open_with_profile(Path::new("/dev/sr0"), profile)?;
|
||||
```
|
||||
|
||||
**`open()`** performs the full sequence: open device, send INQUIRY, match
|
||||
profile, instantiate platform driver, and unlock. Unlock failures are silently
|
||||
ignored (unencrypted discs do not need it). After `open()`, both raw sector
|
||||
reads and standard READ(10) work immediately.
|
||||
|
||||
**`open_no_unlock()`** skips the unlock step. This is required when AACS bus
|
||||
authentication must happen before unlock. The handshake uses standard SCSI
|
||||
commands that work without raw mode. After authentication completes, the caller
|
||||
can invoke `session.unlock()` manually.
|
||||
|
||||
**`open_with_profile()`** bypasses profile auto-detection. Useful for testing
|
||||
or when a custom profile is loaded from an external source.
|
||||
|
||||
### Session Operations
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `unlock()` | Activate raw disc access mode via platform driver |
|
||||
| `is_unlocked()` | Check if raw mode is active |
|
||||
| `calibrate()` | Build speed lookup table for the current disc |
|
||||
| `read_sectors(lba, count, buf)` | Raw sector read (requires unlock + calibrate) |
|
||||
| `read_disc(lba, count, buf)` | Standard READ(10) with 5s timeout |
|
||||
| `status()` | Query drive status and feature flags |
|
||||
| `read_config()` | Read drive configuration block (1888 bytes) |
|
||||
| `read_register(index)` | Read 16-byte hardware register |
|
||||
| `probe(sub_cmd, addr, len)` | Generic READ BUFFER with caller parameters |
|
||||
| `scsi_execute(cdb, dir, buf, timeout)` | Send an arbitrary SCSI CDB |
|
||||
|
||||
---
|
||||
|
||||
## SCSI Transport
|
||||
|
||||
### Trait
|
||||
|
||||
```rust
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
```
|
||||
|
||||
All drive communication goes through this trait. The library never opens file
|
||||
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
||||
|
||||
### Linux: SG_IO
|
||||
|
||||
The `SgIoTransport` implementation:
|
||||
|
||||
1. Opens the device path with `O_RDWR | O_NONBLOCK`.
|
||||
2. Constructs an `sg_io_hdr` struct with the CDB, data buffer, and timeout.
|
||||
3. Calls `ioctl(fd, SG_IO, &hdr)`.
|
||||
4. Returns `ScsiResult` with status, bytes transferred, and sense data.
|
||||
|
||||
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
|
||||
sense buffer and returns `Error::ScsiError`.
|
||||
|
||||
### CDB Builders
|
||||
|
||||
The `scsi` module provides platform-agnostic CDB constructors:
|
||||
|
||||
| Function | CDB | Use |
|
||||
|----------|-----|-----|
|
||||
| `inquiry()` | INQUIRY (0x12) | Drive identification |
|
||||
| `get_config_010c()` | GET CONFIGURATION (0x46) | Feature 010C firmware date |
|
||||
| `build_read_buffer()` | READ BUFFER (0x3C) | All platform commands |
|
||||
| `build_set_cd_speed()` | SET CD SPEED (0xBB) | Speed control |
|
||||
| `build_read10_raw()` | READ(10) (0x28) with flag 0x08 | Raw sector reads |
|
||||
|
||||
---
|
||||
|
||||
## Drive Identification
|
||||
|
||||
`DriveId::from_drive()` sends two standard SCSI commands and extracts identity
|
||||
fields:
|
||||
|
||||
| Field | Source | SCSI Reference |
|
||||
|-------|--------|----------------|
|
||||
| `vendor_id` | INQUIRY bytes [8:16] | SPC-4 section 6.4.2 |
|
||||
| `product_id` | INQUIRY bytes [16:32] | SPC-4 section 6.4.2 |
|
||||
| `product_revision` | INQUIRY bytes [32:36] | SPC-4 section 6.4.2 |
|
||||
| `vendor_specific` | INQUIRY bytes [36:43] | SPC-4 section 6.4.2 |
|
||||
| `firmware_date` | GET CONFIGURATION Feature 010C | MMC-6 section 5.3.10 |
|
||||
|
||||
The match key is `"VENDOR|PRODUCT|REVISION|VENDOR_SPECIFIC"`. Profile matching
|
||||
tries all four fields first, then falls back to matching without the firmware
|
||||
date for drives where Feature 010C is unavailable.
|
||||
|
||||
---
|
||||
|
||||
## Drive Profiles
|
||||
|
||||
Profiles are JSON objects compiled into the binary (`profiles.json`,
|
||||
206 entries). Each profile contains:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `vendor_id`, `product_revision`, `vendor_specific`, `firmware_date` | Matching fields |
|
||||
| `chipset` | `"mediatek"` or `"renesas"` |
|
||||
| `unlock_mode`, `unlock_buf_id` | READ BUFFER CDB parameters |
|
||||
| `signature` | Expected 4-byte response signature |
|
||||
| `unlock_cdb` | Pre-built unlock CDB (hex-encoded) |
|
||||
| `register_offsets` | Offsets for hardware register reads |
|
||||
| `capabilities` | Feature flags: `bd_raw_read`, `dvd_all_regions`, etc. |
|
||||
|
||||
Loading:
|
||||
|
||||
```rust
|
||||
// Bundled (compiled-in) -- no file I/O
|
||||
let profiles = profile::load_bundled()?;
|
||||
|
||||
// External file
|
||||
let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chipsets
|
||||
|
||||
### MediaTek MT1959
|
||||
|
||||
Covers all LG, ASUS, and hp optical drives. Two sub-variants share identical
|
||||
logic with different SCSI parameters:
|
||||
|
||||
| Variant | READ BUFFER mode | Buffer ID |
|
||||
|---------|------------------|-----------|
|
||||
| MT1959-A | 0x01 | 0x44 |
|
||||
| MT1959-B | 0x02 | 0x77 |
|
||||
|
||||
The Platform trait maps to 10 command handlers:
|
||||
|
||||
| Handler | Function | Description |
|
||||
|---------|----------|-------------|
|
||||
| 0 | `unlock()` | Send READ BUFFER, verify signature + verification bytes |
|
||||
| 1 | `read_config()` | Read 1888-byte configuration block + 4-byte status |
|
||||
| 2-3 | `read_register()` | Read hardware registers at profile-specified offsets |
|
||||
| 4 | `calibrate()` | Probe disc surface, build 64-entry speed table |
|
||||
| 5 | `keepalive()` | Periodic session maintenance |
|
||||
| 6 | `status()` | Query current mode and feature flags |
|
||||
| 7 | `probe()` | Generic READ BUFFER with dynamic parameters |
|
||||
| 8 | `read_sectors()` | Speed lookup + SET CD SPEED + READ(10) with flag 0x08 |
|
||||
| 9 | `timing()` | Timing calibration |
|
||||
|
||||
### Renesas (Planned)
|
||||
|
||||
RS8xxx/RS9xxx chipsets used in Pioneer and some HL-DT-ST drives.
|
||||
Currently returns `Error::UnsupportedDrive` when a Renesas profile is matched.
|
||||
|
||||
---
|
||||
|
||||
## Why Unlock Is Needed
|
||||
|
||||
Optical drive firmware restricts what applications can read from disc. Without
|
||||
unlock:
|
||||
|
||||
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
|
||||
playlists, and CLPI clip info are readable without unlock. The `read_disc()`
|
||||
method uses standard READ(10) and works on any drive.
|
||||
|
||||
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
|
||||
SCSI errors (sense key 0x05, illegal request) when an application attempts to
|
||||
read sectors containing encrypted m2ts content without prior AACS
|
||||
authentication via the bus key.
|
||||
|
||||
- **The kernel sr driver blocks block-device reads.** On Linux, the kernel's
|
||||
SCSI CD-ROM driver (`sr`) refuses to expose encrypted disc content through
|
||||
`/dev/sr0` as a block device. Even if you open the block device directly,
|
||||
reads to encrypted regions fail.
|
||||
|
||||
- **Raw mode bypasses firmware restrictions.** After unlock, the drive accepts
|
||||
READ(10) with the raw read flag (CDB byte 1 = 0x08) for all sectors,
|
||||
regardless of encryption status. This is how raw sector ripping works.
|
||||
|
||||
### open() vs open_no_unlock()
|
||||
|
||||
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
|
||||
These must execute before unlock because:
|
||||
|
||||
1. The AACS handshake establishes a bus key via ECDH.
|
||||
2. The bus key encrypts the Volume ID and Read Data Key responses.
|
||||
3. The Volume ID is needed to derive the Volume Unique Key (VUK).
|
||||
4. The VUK is needed to decrypt unit keys from `Unit_Key_RO.inf`.
|
||||
|
||||
If `open()` unlocks first, some drives reject the subsequent AACS commands.
|
||||
The correct sequence for encrypted discs is:
|
||||
|
||||
```rust
|
||||
// 1. Open without unlock
|
||||
let mut session = DriveSession::open_no_unlock(device)?;
|
||||
|
||||
// 2. AACS handshake (uses standard SCSI, no unlock needed)
|
||||
let auth = aacs_handshake::aacs_authenticate(&mut session, &key, &cert)?;
|
||||
let vid = aacs_handshake::read_volume_id(&mut session, &mut auth)?;
|
||||
|
||||
// 3. Now unlock for raw reads
|
||||
session.unlock()?;
|
||||
session.calibrate()?;
|
||||
|
||||
// 4. Read and decrypt content
|
||||
session.read_sectors(lba, count, &mut buf)?;
|
||||
```
|
||||
|
||||
In practice, `Disc::scan()` handles this internally. The default `open()` call
|
||||
unlocks immediately and is correct for most use cases -- the scan re-opens a
|
||||
second session with `open_no_unlock()` for the AACS handshake when needed.
|
||||
|
||||
---
|
||||
|
||||
## Speed Control
|
||||
|
||||
After `calibrate()`, the platform driver maintains a 64-entry speed lookup table
|
||||
built by probing the disc surface. On each `read_sectors()` call, the driver:
|
||||
|
||||
1. Looks up the optimal speed for the target LBA in the table.
|
||||
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
|
||||
3. Performs the READ(10).
|
||||
|
||||
Available speeds:
|
||||
|
||||
| Format | Speeds |
|
||||
|--------|--------|
|
||||
| Blu-ray | 1x (4,500 KB/s) through 12x (54,000 KB/s) |
|
||||
| DVD | 1x (1,385 KB/s) through 16x (22,160 KB/s) |
|
||||
| Max | 0xFFFF (drive decides) |
|
||||
@@ -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)
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# MPLS Playlist Format
|
||||
|
||||
## What is MPLS?
|
||||
|
||||
MPLS (Movie PlayList) files define playback titles on a Blu-ray disc. Each `.mpls` file in `BDMV/PLAYLIST/` describes one title -- a sequence of clips with precise in/out timestamps and stream information. The main movie, bonus features, trailers, and menus each have their own MPLS file.
|
||||
|
||||
A disc may contain dozens of MPLS files. Most are short (menus, logos, transitions). The main movie is typically the longest playlist. libfreemkv filters out playlists shorter than 30 seconds.
|
||||
|
||||
## File Structure
|
||||
|
||||
MPLS files use big-endian byte order throughout. The file has three main sections:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 Magic: "MPLS"
|
||||
4 4 Version: "0200" (BD) or "0300" (UHD BD)
|
||||
8 4 PlayList start offset (absolute from file start)
|
||||
12 4 PlayListMark start offset
|
||||
16 4 ExtensionData start offset
|
||||
```
|
||||
|
||||
### PlayList Section
|
||||
|
||||
Located at the PlayList start offset. Contains all play items and sub-path entries:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 PlayList length
|
||||
4 2 Reserved
|
||||
6 2 Number of PlayItems
|
||||
8 2 Number of SubPaths
|
||||
10 ... PlayItem entries (variable length)
|
||||
```
|
||||
|
||||
### Play Items
|
||||
|
||||
Each play item references one clip and specifies what portion to play:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 PlayItem length (bytes after this field)
|
||||
2 5 Clip ID (ASCII, e.g. "00001")
|
||||
7 4 Codec ID ("M2TS")
|
||||
11 1 Connection condition (lower 4 bits)
|
||||
12 1 Ref to STC_id
|
||||
14 4 IN_time (45kHz PTS ticks)
|
||||
18 4 OUT_time (45kHz PTS ticks)
|
||||
22 8 UO_mask_table
|
||||
30 1 Misc flags
|
||||
31 1 still_mode
|
||||
32 ... STN_table (first play item only is parsed)
|
||||
```
|
||||
|
||||
**Connection condition** values:
|
||||
- `1` = seamless connection (no gap between clips)
|
||||
- `5`, `6` = non-seamless connection
|
||||
|
||||
**Timestamps** use 45kHz PTS (Presentation Time Stamp) ticks, the same timebase as MPEG transport streams. Duration of a play item = `OUT_time - IN_time`. To convert to seconds: divide by 45000.
|
||||
|
||||
A playlist's total duration is the sum of all play item durations.
|
||||
|
||||
### Clip ID Mapping
|
||||
|
||||
The Clip ID (e.g. "00001") maps to:
|
||||
- `BDMV/STREAM/00001.m2ts` -- the transport stream
|
||||
- `BDMV/CLIPINF/00001.clpi` -- the clip info (EP map, stream details)
|
||||
|
||||
## STN Table
|
||||
|
||||
The Stream Number Table describes all elementary streams available in the clip. It is parsed from the first play item (which defines the title's stream layout).
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 STN table length
|
||||
2 2 Reserved
|
||||
4 1 Number of primary video streams
|
||||
5 1 Number of primary audio streams
|
||||
6 1 Number of PG (subtitle) streams
|
||||
7 1 Number of IG (interactive graphics) streams
|
||||
8 ... Stream entries
|
||||
```
|
||||
|
||||
### Stream Entries
|
||||
|
||||
Each stream entry has two parts: a stream reference and a stream attributes block.
|
||||
|
||||
**Stream reference:**
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Entry length
|
||||
1 1 Stream type (1=PlayItem, 2=SubPath, 3=InMux)
|
||||
2 2 PID (MPEG-TS packet ID, big-endian)
|
||||
```
|
||||
|
||||
**Stream attributes** (immediately follows the reference):
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Attributes length
|
||||
1 1 Coding type
|
||||
2+ ... Type-specific fields
|
||||
```
|
||||
|
||||
The layout of type-specific fields depends on the stream category:
|
||||
|
||||
**Video streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 1 | Format (upper 4 bits) + frame rate (lower 4 bits) |
|
||||
|
||||
**Audio streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 1 | Format (upper 4 bits) + sample rate (lower 4 bits) |
|
||||
| 3 | 3 | Language code (ISO 639-2, e.g. "eng") |
|
||||
|
||||
**PG subtitle and IG streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 3 | Language code |
|
||||
|
||||
## Coding Types
|
||||
|
||||
The coding type byte identifies the codec:
|
||||
|
||||
| Value | Codec | Category |
|
||||
|-------|-------|----------|
|
||||
| `0x02` | MPEG-2 | Video |
|
||||
| `0x1B` | H.264 / AVC | Video |
|
||||
| `0x24` | HEVC / H.265 | Video |
|
||||
| `0xEA` | VC-1 | Video |
|
||||
| `0x80` | LPCM | Audio |
|
||||
| `0x81` | AC-3 (Dolby Digital) | Audio |
|
||||
| `0x82` | DTS | Audio |
|
||||
| `0x83` | TrueHD (Dolby TrueHD) | Audio |
|
||||
| `0x84` | AC-3 Plus (E-AC-3) | Audio |
|
||||
| `0x85` | DTS-HD HR | Audio |
|
||||
| `0x86` | DTS-HD MA | Audio |
|
||||
| `0xA1` | AC-3 Plus (secondary) | Audio |
|
||||
| `0xA2` | DTS-HD HR (secondary) | Audio |
|
||||
| `0x90` | PGS (Presentation Graphics) | Subtitle |
|
||||
| `0x91` | PGS (Interactive Graphics) | Subtitle |
|
||||
|
||||
## Video Format Codes
|
||||
|
||||
| Value | Resolution |
|
||||
|-------|-----------|
|
||||
| 1 | 480i |
|
||||
| 2 | 576i |
|
||||
| 3 | 480p |
|
||||
| 4 | 1080i |
|
||||
| 5 | 720p |
|
||||
| 6 | 1080p |
|
||||
| 7 | 576p |
|
||||
| 8 | 2160p |
|
||||
|
||||
## Video Frame Rate Codes
|
||||
|
||||
| Value | Frame Rate |
|
||||
|-------|-----------|
|
||||
| 1 | 23.976 fps |
|
||||
| 2 | 24 fps |
|
||||
| 3 | 25 fps |
|
||||
| 4 | 29.97 fps |
|
||||
| 6 | 50 fps |
|
||||
| 7 | 59.94 fps |
|
||||
|
||||
## Audio Format Codes
|
||||
|
||||
| Value | Channels |
|
||||
|-------|----------|
|
||||
| 1 | Mono |
|
||||
| 3 | Stereo |
|
||||
| 6 | 5.1 surround |
|
||||
| 12 | 7.1 surround |
|
||||
|
||||
## Audio Sample Rate Codes
|
||||
|
||||
| Value | Rate |
|
||||
|-------|------|
|
||||
| 1 | 48 kHz |
|
||||
| 4 | 96 kHz |
|
||||
| 5 | 192 kHz |
|
||||
| 12 | 48/192 kHz (combo) |
|
||||
| 14 | 48/96 kHz (combo) |
|
||||
|
||||
## How Playlists Map to Titles
|
||||
|
||||
libfreemkv's `Disc::scan()` reads every MPLS file from the disc and builds a `Title` for each:
|
||||
|
||||
1. Read all `.mpls` files from `BDMV/PLAYLIST/` via UDF.
|
||||
2. Parse each with `mpls::parse()`.
|
||||
3. Calculate duration by summing `(OUT_time - IN_time)` across all play items.
|
||||
4. Discard playlists shorter than 30 seconds.
|
||||
5. For each play item, load the corresponding CLPI file to get EP map data and compute sector extents (see [clpi.md](clpi.md)).
|
||||
6. Extract stream info from the STN table of the first play item.
|
||||
7. Sort titles by duration, longest first.
|
||||
|
||||
The resulting `Title` struct contains everything needed to rip: streams, duration, byte size, and the sector extents to read from disc.
|
||||
|
||||
## References
|
||||
|
||||
- BD-ROM Part 3, Section 5.3: PlayList file format
|
||||
- https://github.com/lw/BluRay/wiki/MPLS
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# UDF 2.50 Filesystem Parser
|
||||
|
||||
## What is UDF?
|
||||
|
||||
UDF (Universal Disc Format) is the filesystem standard for optical media. It is defined by ECMA-167 with extensions from the OSTA (Optical Storage Technology Association). BD-ROM discs use UDF revision 2.50, which introduces the **metadata partition** -- a critical feature that separates file metadata from file content on disc.
|
||||
|
||||
## Why UDF 2.50 for Blu-ray?
|
||||
|
||||
Older UDF revisions (1.02, 1.50) scatter ICBs (file metadata) and file data across the same partition. On a high-capacity Blu-ray disc (25-100 GB), this creates excessive seeking when the drive needs to read a directory listing or locate a file. UDF 2.50 solves this by placing all metadata into a contiguous region near the beginning of the disc. The drive reads metadata from one compact area and streams file data from another -- no interleaved seeks.
|
||||
|
||||
BD-ROM Part 3 of the Blu-ray specification mandates UDF 2.50.
|
||||
|
||||
## Metadata Partitions
|
||||
|
||||
A UDF 2.50 BD-ROM has two logical partitions:
|
||||
|
||||
- **Partition 0 (Type 1)** -- the physical partition. Contains actual file data (m2ts streams, playlist files, etc.). Mapped directly to disc sectors starting at the Partition Descriptor's `partitionStartingLocation`.
|
||||
|
||||
- **Partition 1 (Type 2)** -- the metadata partition. Contains all ICBs (Inode-like structures), directory data, and the File Set Descriptor. The metadata partition is itself stored as a file within the physical partition. Its location is found by reading an Extended File Entry at LBA 0 of the physical partition.
|
||||
|
||||
The key rule: **ICBs and directory data live in the metadata partition. File content lives in the physical partition.** When an ICB's allocation descriptor gives an LBA, the partition it refers to depends on what the LBA describes -- metadata-relative for directory entries, physical-partition-relative for file data extents.
|
||||
|
||||
## Pointer Chain
|
||||
|
||||
Reading a UDF 2.50 filesystem follows a fixed chain of pointers. Each step reads one or two 2048-byte sectors:
|
||||
|
||||
```
|
||||
Sector 256: AVDP (Anchor Volume Descriptor Pointer, tag 2)
|
||||
|
|
||||
v
|
||||
Sectors 32-63: VDS (Volume Descriptor Sequence)
|
||||
|-- Partition Descriptor (tag 5) --> partition_start (physical sector)
|
||||
|-- Logical Volume Descriptor (tag 6) --> partition maps, FSD location
|
||||
|
|
||||
v
|
||||
Partition Maps in LVD (offset 440):
|
||||
|-- Map 0: Type 1 (physical partition)
|
||||
|-- Map 1: Type 2 (metadata partition, identified by "*UDF Metadata Partition")
|
||||
|
|
||||
v
|
||||
Metadata file ICB at partition_start + 0 (Extended File Entry, tag 266)
|
||||
|-- Allocation descriptor --> metadata content location
|
||||
|
|
||||
v
|
||||
metadata_start = partition_start + allocation_position
|
||||
|
|
||||
v
|
||||
FSD at metadata_start + 0 (File Set Descriptor, tag 256)
|
||||
|-- Root Directory ICB: long_ad at offset 400 --> root_lba (metadata-relative)
|
||||
|
|
||||
v
|
||||
Root Directory ICB at metadata_start + root_lba (Extended File Entry, tag 266)
|
||||
|-- Allocation descriptor --> directory data location (metadata-relative)
|
||||
|
|
||||
v
|
||||
Directory data: File Identifier Descriptors (tag 257)
|
||||
|-- Each FID names a file/subdirectory and points to its ICB
|
||||
|-- Recurse into subdirectories to build the full file tree
|
||||
```
|
||||
|
||||
### AVDP (Sector 256)
|
||||
|
||||
The Anchor Volume Descriptor Pointer is always at sector 256 (ECMA-167 section 10.2). It points to the Main Volume Descriptor Sequence. Tag identifier = 2.
|
||||
|
||||
### VDS (Sectors 32+)
|
||||
|
||||
The Volume Descriptor Sequence contains:
|
||||
|
||||
- **Partition Descriptor (tag 5)**: byte offset 188 holds `partitionStartingLocation` -- the absolute sector where the physical partition begins.
|
||||
- **Logical Volume Descriptor (tag 6)**: byte offset 268 holds the number of partition maps. The partition maps themselves start at offset 440. For BD-ROM, map 0 is Type 1 (physical) and map 1 is Type 2 (metadata).
|
||||
- **Terminating Descriptor (tag 8)**: signals the end of the VDS.
|
||||
|
||||
### Metadata File
|
||||
|
||||
When two partition maps exist and the second is Type 2, the metadata partition content is located by reading the Extended File Entry at the first sector of the physical partition (partition_start + 0). This ICB's allocation descriptor gives the offset and length of the metadata content within the physical partition.
|
||||
|
||||
### File Set Descriptor
|
||||
|
||||
The FSD (tag 256) sits at metadata-relative LBA 0 (the first sector of the metadata content). It contains a long allocation descriptor at offset 400 pointing to the root directory ICB. The LBA in this long_ad is at bytes 404-407.
|
||||
|
||||
### Directory Traversal
|
||||
|
||||
Each directory is an ICB (Extended File Entry, tag 266, or File Entry, tag 261) whose allocation extent points to directory data. The directory data is a sequence of File Identifier Descriptors (FIDs, tag 257):
|
||||
|
||||
| FID Field | Offset | Size | Description |
|
||||
|-----------|--------|------|-------------|
|
||||
| Tag | 0 | 2 | Always 257 |
|
||||
| File characteristics | 18 | 1 | Bit 1 = directory, bit 3 = parent |
|
||||
| L_FI (name length) | 19 | 1 | Length of filename |
|
||||
| ICB (long_ad) | 20 | 16 | Points to the entry's ICB |
|
||||
| L_IU | 36 | 2 | Implementation use length |
|
||||
| Filename | 38 + L_IU | L_FI | UDF-encoded filename |
|
||||
|
||||
FIDs are 4-byte aligned. The parser advances by `(38 + L_IU + L_FI + 3) & !3` bytes per entry.
|
||||
|
||||
### ICB Layout
|
||||
|
||||
Both File Entry (tag 261) and Extended File Entry (tag 266) share the same info_length field:
|
||||
|
||||
| Field | Tag 261 Offset | Tag 266 Offset |
|
||||
|-------|---------------|---------------|
|
||||
| info_length (u64) | 56 | 56 |
|
||||
| L_EA (u32) | 168 | 208 |
|
||||
| L_AD (u32) | 172 | 212 |
|
||||
| Allocation descriptors | 176 + L_EA | 216 + L_EA |
|
||||
|
||||
Allocation descriptors use the Short Allocation Descriptor format: 4 bytes extent length (upper 2 bits = type), 4 bytes extent position (LBA).
|
||||
|
||||
## How read_filesystem() Works
|
||||
|
||||
The `read_filesystem()` function in `src/udf.rs` follows the pointer chain above:
|
||||
|
||||
1. Reads sector 256, validates AVDP (tag 2).
|
||||
2. Scans sectors 32-63 for the Partition Descriptor and Logical Volume Descriptor.
|
||||
3. If two partition maps exist and the second is Type 2, reads the metadata file ICB at partition_start to find metadata_start.
|
||||
4. Reads the FSD at metadata_start, extracts the root directory ICB LBA.
|
||||
5. Calls `read_directory()` recursively (max depth 3) to build the full file tree.
|
||||
|
||||
Each directory read involves two sector reads: one for the ICB, then one or more for the directory data. File sizes are read from info_length in each file's ICB.
|
||||
|
||||
`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start).
|
||||
|
||||
### UDF Filename Encoding
|
||||
|
||||
UDF filenames use a compression ID as the first byte:
|
||||
- `8` = 8-bit characters (ASCII)
|
||||
- `16` = 16-bit big-endian Unicode (UTF-16BE)
|
||||
|
||||
The parser handles both encodings. All path lookups are case-insensitive.
|
||||
|
||||
## BD-ROM Directory Structure
|
||||
|
||||
A typical Blu-ray disc has this directory layout:
|
||||
|
||||
```
|
||||
/
|
||||
+-- BDMV/
|
||||
| +-- index.bdmv Disc index (title list, first play)
|
||||
| +-- MovieObject.bdmv Movie objects (navigation commands)
|
||||
| +-- PLAYLIST/
|
||||
| | +-- 00000.mpls Main movie playlist
|
||||
| | +-- 00001.mpls Director's commentary
|
||||
| | +-- ...
|
||||
| +-- CLIPINF/
|
||||
| | +-- 00001.clpi Clip info for 00001.m2ts
|
||||
| | +-- 00002.clpi
|
||||
| | +-- ...
|
||||
| +-- STREAM/
|
||||
| | +-- 00001.m2ts Transport stream (video/audio/subtitle data)
|
||||
| | +-- 00002.m2ts
|
||||
| | +-- ...
|
||||
| +-- BACKUP/ Duplicate of index, MovieObject, playlists, clip info
|
||||
|
|
||||
+-- AACS/ AACS encryption data (encrypted discs only)
|
||||
| +-- Unit_Key_RO.inf Unit key file (encrypted)
|
||||
| +-- MKB_RW.inf Media Key Block
|
||||
| +-- Content000.cer Content certificate
|
||||
| +-- DUPLICATE/ Backup copies
|
||||
|
|
||||
+-- CERTIFICATE/ BD+ certificate data (some discs)
|
||||
```
|
||||
|
||||
The parser reads from `BDMV/PLAYLIST/` and `BDMV/CLIPINF/` to discover titles and their sector layouts. The `BDMV/STREAM/` directory contains the actual transport streams but is not parsed by the UDF layer -- stream data is read by LBA directly using extents computed from CLPI EP maps.
|
||||
|
||||
## References
|
||||
|
||||
- ECMA-167: Volume and File Structure of Write-Once and Rewritable Media
|
||||
- UDF 2.50 (OSTA): Universal Disk Format Specification
|
||||
- BD-ROM Part 3: Blu-ray Disc Read-Only Format, File System Specifications
|
||||
+2067
-3093
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1484
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
//! freemkv-info — Drive identification and compatibility checker.
|
||||
//!
|
||||
//! Sends standard SCSI INQUIRY and GET CONFIGURATION commands to an optical drive,
|
||||
//! displays drive identity and compatibility status, and optionally outputs raw
|
||||
//! response data for profile contribution.
|
||||
//!
|
||||
//! Usage:
|
||||
//! freemkv-info /dev/sr0
|
||||
//! freemkv-info /dev/sr0 --raw
|
||||
//! freemkv-info /dev/sr0 --json
|
||||
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
eprintln!("freemkv-info — Drive identification and compatibility checker");
|
||||
eprintln!();
|
||||
eprintln!("Usage: freemkv-info <device> [options]");
|
||||
eprintln!();
|
||||
eprintln!(" <device> Optical drive device (e.g. /dev/sr0)");
|
||||
eprintln!(" --raw Output raw SCSI response hex (for profile contribution)");
|
||||
eprintln!(" --json Output machine-readable JSON");
|
||||
eprintln!(" --profiles Path to profiles directory (default: ./profiles)");
|
||||
eprintln!();
|
||||
eprintln!("Examples:");
|
||||
eprintln!(" freemkv-info /dev/sr0");
|
||||
eprintln!(" freemkv-info /dev/sr0 --raw > my_drive.txt");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let device = Path::new(&args[1]);
|
||||
let raw_mode = args.iter().any(|a| a == "--raw");
|
||||
let json_mode = args.iter().any(|a| a == "--json");
|
||||
let profiles_dir = args.iter()
|
||||
.position(|a| a == "--profiles")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("profiles");
|
||||
|
||||
// Open SCSI transport
|
||||
let mut transport = match libfreemkv::scsi::open(device) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("Error: Cannot open {}: {}", device.display(), e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// INQUIRY
|
||||
let inquiry = match libfreemkv::scsi::inquiry(transport.as_mut()) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("Error: INQUIRY failed: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// GET CONFIGURATION feature 0x010C
|
||||
let gc_010c = libfreemkv::scsi::get_config_010c(transport.as_mut()).ok();
|
||||
|
||||
if json_mode {
|
||||
print_json(&inquiry, &gc_010c);
|
||||
} else if raw_mode {
|
||||
print_raw(&inquiry, &gc_010c);
|
||||
} else {
|
||||
print_human(&inquiry, &gc_010c, profiles_dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_human(
|
||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
||||
gc_010c: &Option<Vec<u8>>,
|
||||
profiles_dir: &str,
|
||||
) {
|
||||
println!("freemkv-info v{}", env!("CARGO_PKG_VERSION"));
|
||||
println!();
|
||||
println!("Drive: {} {} {}", inquiry.vendor_id, inquiry.model, inquiry.firmware);
|
||||
println!("INQUIRY: additional_length=0x{:02X} ({})",
|
||||
inquiry.raw.get(4).unwrap_or(&0),
|
||||
inquiry.raw.get(4).unwrap_or(&0));
|
||||
|
||||
if let Some(gc) = gc_010c {
|
||||
let data_hex: String = gc.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
println!("Feature 0x010C: {}", data_hex);
|
||||
} else {
|
||||
println!("Feature 0x010C: not available");
|
||||
}
|
||||
|
||||
// Try to match profile
|
||||
if let Ok(profiles) = libfreemkv::profile::load_all(Path::new(profiles_dir)) {
|
||||
let matched = profiles.iter().find(|p| {
|
||||
p.drive_id.contains(&inquiry.vendor_id)
|
||||
&& p.drive_id.contains(&inquiry.model)
|
||||
});
|
||||
|
||||
println!();
|
||||
match matched {
|
||||
Some(p) => {
|
||||
println!("Profile: FOUND ({})", p.chipset.name());
|
||||
println!("Raw Read: Supported");
|
||||
}
|
||||
None => {
|
||||
println!("Profile: NOT FOUND");
|
||||
println!("Raw Read: Unknown — run with --raw and submit a profile request");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!();
|
||||
println!("Profile: No profiles directory found at '{}'", profiles_dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_raw(
|
||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
||||
gc_010c: &Option<Vec<u8>>,
|
||||
) {
|
||||
println!("# freemkv-info raw output");
|
||||
println!("# Submit this file to https://github.com/freemkv/libfreemkv/issues");
|
||||
println!();
|
||||
println!("vendor: {}", inquiry.vendor_id);
|
||||
println!("model: {}", inquiry.model);
|
||||
println!("firmware: {}", inquiry.firmware);
|
||||
println!();
|
||||
|
||||
// Full INQUIRY hex
|
||||
println!("inquiry_hex: {}", hex_encode(&inquiry.raw));
|
||||
println!("inquiry_length: {}", inquiry.raw.len());
|
||||
|
||||
// GET CONFIG 0x010C
|
||||
if let Some(gc) = gc_010c {
|
||||
println!("get_config_010c_hex: {}", hex_encode(gc));
|
||||
println!("get_config_010c_length: {}", gc.len());
|
||||
} else {
|
||||
println!("get_config_010c_hex: ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(
|
||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
||||
gc_010c: &Option<Vec<u8>>,
|
||||
) {
|
||||
let json = serde_json::json!({
|
||||
"vendor": inquiry.vendor_id,
|
||||
"model": inquiry.model,
|
||||
"firmware": inquiry.firmware,
|
||||
"inquiry_hex": hex_encode(&inquiry.raw),
|
||||
"inquiry_length": inquiry.raw.len(),
|
||||
"get_config_010c_hex": gc_010c.as_ref().map(|g| hex_encode(g)),
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&json).unwrap());
|
||||
}
|
||||
|
||||
fn hex_encode(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//! freemkv-test — Quick verification that raw disc access works.
|
||||
//!
|
||||
//! Enables raw read mode, calibrates speed, reads a few test sectors.
|
||||
//! Use this to verify your drive and profile are working correctly.
|
||||
//!
|
||||
//! Usage:
|
||||
//! freemkv-test /dev/sr0
|
||||
//! freemkv-test /dev/sr0 --profiles ./profiles
|
||||
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
eprintln!("freemkv-test — Verify raw disc access works");
|
||||
eprintln!();
|
||||
eprintln!("Usage: freemkv-test <device> [--profiles <dir>]");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let device = Path::new(&args[1]);
|
||||
|
||||
println!("freemkv-test v{}", env!("CARGO_PKG_VERSION"));
|
||||
println!();
|
||||
|
||||
// Open drive session (uses bundled profiles)
|
||||
print!("Opening {}... ", device.display());
|
||||
let mut session = match libfreemkv::DriveSession::open(device) {
|
||||
Ok(s) => { println!("OK"); s }
|
||||
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
|
||||
};
|
||||
|
||||
println!(" Drive ID: {}", session.profile.drive_id);
|
||||
println!(" Chipset: {}", session.profile.chipset.name());
|
||||
println!();
|
||||
|
||||
// Enable raw read mode
|
||||
print!("Unlocking drive... ");
|
||||
match session.unlock() {
|
||||
Ok(()) => println!("OK"),
|
||||
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
|
||||
}
|
||||
|
||||
// Check status
|
||||
print!("Checking status... ");
|
||||
match session.status() {
|
||||
Ok(status) => {
|
||||
if status.unlocked {
|
||||
println!("OK (active)");
|
||||
} else {
|
||||
println!("WARNING: drive reported as locked");
|
||||
}
|
||||
}
|
||||
Err(e) => println!("SKIP ({})", e),
|
||||
}
|
||||
|
||||
// Calibrate speed
|
||||
print!("Calibrating speed... ");
|
||||
match session.calibrate() {
|
||||
Ok(()) => println!("OK"),
|
||||
Err(e) => println!("SKIP ({})", e),
|
||||
}
|
||||
|
||||
// Read test sectors
|
||||
let test_lbas: &[u32] = &[0, 100, 1000, 10000];
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let mut pass = 0;
|
||||
let mut fail = 0;
|
||||
|
||||
for &lba in test_lbas {
|
||||
print!("Reading sector {}... ", lba);
|
||||
match session.read_sectors(lba, 1, &mut buf) {
|
||||
Ok(n) if n == 2048 => {
|
||||
let nonzero = buf.iter().filter(|&&b| b != 0).count();
|
||||
println!("OK ({} bytes, {} non-zero)", n, nonzero);
|
||||
pass += 1;
|
||||
}
|
||||
Ok(n) => {
|
||||
println!("PARTIAL ({} bytes)", n);
|
||||
fail += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("FAILED: {}", e);
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
if fail == 0 {
|
||||
println!("All {} checks passed. Drive is fully functional.", pass);
|
||||
} else {
|
||||
println!("{} passed, {} failed.", pass, fail);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
//! CLPI clip info parser — maps clips to sector ranges on disc.
|
||||
//!
|
||||
//! Each .clpi file in BDMV/CLIPINF/ describes one M2TS clip.
|
||||
//! The EP (Entry Point) map provides timestamp → SPN mapping.
|
||||
//! SPN × 192 = byte offset in the m2ts file.
|
||||
//!
|
||||
//! Reference: https://github.com/lw/BluRay/wiki/CLPI
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::disc::Extent;
|
||||
|
||||
/// Parsed CLPI clip info.
|
||||
#[derive(Debug)]
|
||||
pub struct ClipInfo {
|
||||
pub version: String,
|
||||
/// Total source packets in the m2ts (each 192 bytes)
|
||||
pub source_packet_count: u32,
|
||||
/// Coarse EP entries for the primary video stream
|
||||
pub ep_coarse: Vec<EpCoarse>,
|
||||
/// Fine EP entries for the primary video stream
|
||||
pub ep_fine: Vec<EpFine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpCoarse {
|
||||
pub ref_to_fine_id: u32,
|
||||
pub pts_coarse: u32,
|
||||
pub spn_coarse: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpFine {
|
||||
pub pts_fine: u32,
|
||||
pub spn_fine: u32,
|
||||
}
|
||||
|
||||
impl ClipInfo {
|
||||
/// Reconstruct full PTS from coarse + fine entry.
|
||||
pub fn full_pts(coarse: &EpCoarse, fine: &EpFine) -> u32 {
|
||||
(coarse.pts_coarse << 19) + (fine.pts_fine << 8)
|
||||
}
|
||||
|
||||
/// Reconstruct full SPN from coarse + fine entry.
|
||||
pub fn full_spn(coarse: &EpCoarse, fine: &EpFine) -> u32 {
|
||||
(coarse.spn_coarse & 0xFFFE0000) + fine.spn_fine
|
||||
}
|
||||
|
||||
/// Get all EP entries as (PTS, SPN) pairs, fully resolved.
|
||||
pub fn resolved_ep_map(&self) -> Vec<(u32, u32)> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for (ci, coarse) in self.ep_coarse.iter().enumerate() {
|
||||
let fine_start = coarse.ref_to_fine_id as usize;
|
||||
let fine_end = if ci + 1 < self.ep_coarse.len() {
|
||||
self.ep_coarse[ci + 1].ref_to_fine_id as usize
|
||||
} else {
|
||||
self.ep_fine.len()
|
||||
};
|
||||
|
||||
for fi in fine_start..fine_end.min(self.ep_fine.len()) {
|
||||
let fine = &self.ep_fine[fi];
|
||||
let pts = Self::full_pts(coarse, fine);
|
||||
let spn = Self::full_spn(coarse, fine);
|
||||
entries.push((pts, spn));
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// Get sector extents for a given in/out time range.
|
||||
///
|
||||
/// Converts PTS timestamps to SPN ranges, then SPN to LBA
|
||||
/// using the file's starting LBA on disc.
|
||||
pub fn get_extents(&self, in_time: u32, out_time: u32) -> Vec<Extent> {
|
||||
let ep_map = self.resolved_ep_map();
|
||||
if ep_map.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Find SPN at or before in_time
|
||||
let start_spn = match ep_map.binary_search_by_key(&in_time, |(pts, _)| *pts) {
|
||||
Ok(i) => ep_map[i].1,
|
||||
Err(0) => ep_map[0].1,
|
||||
Err(i) => ep_map[i - 1].1,
|
||||
};
|
||||
|
||||
// Find SPN at or after out_time
|
||||
let end_spn = match ep_map.binary_search_by_key(&out_time, |(pts, _)| *pts) {
|
||||
Ok(i) => ep_map[i].1,
|
||||
Err(i) if i < ep_map.len() => ep_map[i].1,
|
||||
_ => ep_map.last().unwrap().1 + 1,
|
||||
};
|
||||
|
||||
if end_spn <= start_spn {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// SPN → byte offset: spn × 192
|
||||
// Byte offset → sectors: offset / 2048
|
||||
// Note: the caller needs to add the file's starting LBA from UDF
|
||||
let start_byte = start_spn as u64 * 192;
|
||||
let end_byte = end_spn as u64 * 192;
|
||||
let start_sector = (start_byte / 2048) as u32;
|
||||
let end_sector = ((end_byte + 2047) / 2048) as u32;
|
||||
|
||||
vec![Extent {
|
||||
start_lba: start_sector, // relative to m2ts file start
|
||||
sector_count: end_sector - start_sector,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CLPI file from raw bytes.
|
||||
pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
if data.len() < 40 {
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
|
||||
if &data[0..4] != b"HDMV" {
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
|
||||
// Header offsets
|
||||
let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
let _prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
let cpi_start = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
|
||||
|
||||
// ClipInfo section at offset 40
|
||||
// source_packet_count at offset 40 + 4(len) + 2(reserved) + 1(stream_type) + 1(app_type) + 4(reserved) + 4(ts_rate)
|
||||
let source_packet_count = if data.len() > 56 {
|
||||
u32::from_be_bytes([data[56], data[57], data[58], data[59]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Parse CPI / EP Map
|
||||
let (ep_coarse, ep_fine) = if cpi_start > 0 && cpi_start + 8 < data.len() {
|
||||
parse_cpi(&data[cpi_start..])?
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
Ok(ClipInfo {
|
||||
version,
|
||||
source_packet_count,
|
||||
ep_coarse,
|
||||
ep_fine,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the CPI section containing the EP map.
|
||||
fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
if data.len() < 8 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
let cpi_length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if cpi_length < 4 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// CPI type at bits 44-47 (byte 5, lower 4 bits)
|
||||
// Skip to EP map: offset 4 (after length) + 2 (reserved/type)
|
||||
let ep_map = &data[6..];
|
||||
if ep_map.len() < 4 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// EP map header
|
||||
// [0] reserved
|
||||
// [1] number of stream PID entries
|
||||
let num_streams = ep_map[1] as usize;
|
||||
if num_streams == 0 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Stream PID entry headers start at offset 2
|
||||
// Each: 2(PID) + 2(reserved+type) + 2(num_coarse) + 4(num_fine) + 4(ep_map_start) = 14 bytes
|
||||
// We only care about the first stream (primary video)
|
||||
if ep_map.len() < 16 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
|
||||
// stream_PID: 16 bits → ep_map[2..4]
|
||||
// reserved: 10 bits ┐
|
||||
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
|
||||
// num_EP_coarse: 16 bits │ (10+4+16+18+32 = 80)
|
||||
// num_EP_fine: 18 bits │
|
||||
// EP_map_start_address: 32 bits ┘
|
||||
if ep_map.len() < 16 { return Ok((Vec::new(), Vec::new())); }
|
||||
let _stream_pid = u16::from_be_bytes([ep_map[2], ep_map[3]]);
|
||||
|
||||
// Read 10 bytes (80 bits) from ep_map[4..14] for bit extraction
|
||||
// Use two u64s since we need 80 bits
|
||||
let hi = u64::from_be_bytes([ep_map[4], ep_map[5], ep_map[6], ep_map[7],
|
||||
ep_map[8], ep_map[9], ep_map[10], ep_map[11]]);
|
||||
let lo_bytes = [ep_map[12], ep_map[13]];
|
||||
|
||||
// Bit 0-9: reserved (10)
|
||||
// Bit 10-13: EP_stream_type (4)
|
||||
// Bit 14-29: num_coarse (16)
|
||||
// Bit 30-47: num_fine (18)
|
||||
// Bit 48-79: EP_map_start (32) — bits 48-63 in hi, bits 64-79 in lo
|
||||
let num_coarse = ((hi >> 34) & 0xFFFF) as usize;
|
||||
let num_fine = ((hi >> 16) & 0x3FFFF) as usize;
|
||||
let ep_map_offset = (((hi & 0xFFFF) as u32) << 16)
|
||||
| (u16::from_be_bytes(lo_bytes) as u32);
|
||||
let ep_map_offset = ep_map_offset as usize;
|
||||
|
||||
// EP map for this stream starts at ep_map_offset relative to ep_map start
|
||||
if ep_map_offset + 4 > ep_map.len() {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
let stream_ep = &ep_map[ep_map_offset..];
|
||||
if stream_ep.len() < 4 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Fine table start address (relative to this stream EP map)
|
||||
let fine_start = u32::from_be_bytes([stream_ep[0], stream_ep[1], stream_ep[2], stream_ep[3]]) as usize;
|
||||
|
||||
// Coarse entries start at offset 4, 8 bytes each
|
||||
let coarse_data = &stream_ep[4..];
|
||||
let mut ep_coarse = Vec::with_capacity(num_coarse);
|
||||
for i in 0..num_coarse {
|
||||
let off = i * 8;
|
||||
if off + 8 > coarse_data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let dword0 = u32::from_be_bytes([coarse_data[off], coarse_data[off + 1],
|
||||
coarse_data[off + 2], coarse_data[off + 3]]);
|
||||
let ref_to_fine_id = dword0 >> 14;
|
||||
let pts_coarse = dword0 & 0x3FFF;
|
||||
let spn_coarse = u32::from_be_bytes([coarse_data[off + 4], coarse_data[off + 5],
|
||||
coarse_data[off + 6], coarse_data[off + 7]]);
|
||||
|
||||
ep_coarse.push(EpCoarse {
|
||||
ref_to_fine_id,
|
||||
pts_coarse,
|
||||
spn_coarse,
|
||||
});
|
||||
}
|
||||
|
||||
// Fine entries at fine_start, 4 bytes each
|
||||
let mut ep_fine = Vec::with_capacity(num_fine);
|
||||
if fine_start < stream_ep.len() {
|
||||
let fine_data = &stream_ep[fine_start..];
|
||||
for i in 0..num_fine {
|
||||
let off = i * 4;
|
||||
if off + 4 > fine_data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let dword = u32::from_be_bytes([fine_data[off], fine_data[off + 1],
|
||||
fine_data[off + 2], fine_data[off + 3]]);
|
||||
// Bits: is_angle(1) + i_end_offset(3) + pts_fine(11) + spn_fine(17)
|
||||
let pts_fine = (dword >> 17) & 0x7FF;
|
||||
let spn_fine = dword & 0x1FFFF;
|
||||
|
||||
ep_fine.push(EpFine { pts_fine, spn_fine });
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ep_coarse, ep_fine))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal CLPI binary.
|
||||
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
|
||||
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
|
||||
// We need at least 60 bytes for the header area.
|
||||
// Offsets:
|
||||
// 0..4: "HDMV"
|
||||
// 4..8: "0200"
|
||||
// 8..12: seq_info_start (unused, set to 0)
|
||||
// 12..16: prog_info_start (unused, set to 0)
|
||||
// 16..20: cpi_start
|
||||
// 20..40: reserved/padding
|
||||
// 40..56: ClipInfo section area (length + stuff before source_packet_count)
|
||||
// 56..60: source_packet_count
|
||||
|
||||
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
|
||||
|
||||
let mut buf = vec![0u8; 60];
|
||||
// Magic + version
|
||||
buf[0..4].copy_from_slice(b"HDMV");
|
||||
buf[4..8].copy_from_slice(b"0200");
|
||||
// seq_info_start = 0
|
||||
// prog_info_start = 0
|
||||
// cpi_start
|
||||
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
|
||||
// source_packet_count at offset 56
|
||||
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
|
||||
|
||||
if let Some(cpi) = cpi_data {
|
||||
buf.extend_from_slice(cpi);
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Build a CPI section with one stream's EP map.
|
||||
/// coarse_entries: Vec<(ref_to_fine_id, pts_coarse, spn_coarse)>
|
||||
/// fine_entries: Vec<(pts_fine, spn_fine)>
|
||||
fn build_cpi(
|
||||
stream_pid: u16,
|
||||
coarse_entries: &[(u32, u32, u32)],
|
||||
fine_entries: &[(u32, u32)],
|
||||
) -> Vec<u8> {
|
||||
// CPI section layout:
|
||||
// [0..4] cpi_length (u32 BE)
|
||||
// [4..6] reserved/type (2 bytes)
|
||||
// [6..] EP map
|
||||
//
|
||||
// EP map layout (relative to byte 6 of CPI):
|
||||
// [0] reserved
|
||||
// [1] num_streams (1)
|
||||
// [2..4] stream_PID (u16 BE)
|
||||
// [4..14] 80 bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32)
|
||||
// [14..] (next stream entry, if any)
|
||||
//
|
||||
// Stream EP map (at EP_map_start relative to EP map start):
|
||||
// [0..4] fine_start (relative to stream EP map start)
|
||||
// [4..] coarse entries, 8 bytes each
|
||||
// [fine_start..] fine entries, 4 bytes each
|
||||
|
||||
let num_coarse = coarse_entries.len() as u32;
|
||||
let num_fine = fine_entries.len() as u32;
|
||||
|
||||
// EP_map_start: offset from ep_map start where the stream EP data begins.
|
||||
// ep_map has: reserved(1) + num_streams(1) + stream_header(12) = 14 bytes
|
||||
// So EP_map_start = 14 (first stream data right after the header)
|
||||
let ep_map_start: u32 = 14;
|
||||
|
||||
// Build the 80-bit stream PID entry (10 bytes: ep_map[4..14])
|
||||
// Bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32)
|
||||
// Total: 80 bits = 10 bytes
|
||||
//
|
||||
// Pack into a u128 for convenience then extract 10 bytes
|
||||
let ep_stream_type: u32 = 1; // video
|
||||
let packed: u128 = ((0u128) << 70) // reserved: 10 bits
|
||||
| ((ep_stream_type as u128) << 66) // EP_stream_type: 4 bits
|
||||
| ((num_coarse as u128) << 50) // num_coarse: 16 bits
|
||||
| ((num_fine as u128) << 32) // num_fine: 18 bits
|
||||
| (ep_map_start as u128); // EP_map_start: 32 bits
|
||||
let packed_bytes = packed.to_be_bytes(); // 16 bytes, we want the last 10
|
||||
let stream_header_bits = &packed_bytes[6..16];
|
||||
|
||||
// Build stream EP data
|
||||
// fine_start = 4 (header) + num_coarse * 8
|
||||
let fine_start: u32 = 4 + num_coarse * 8;
|
||||
let mut stream_ep = Vec::new();
|
||||
stream_ep.extend_from_slice(&fine_start.to_be_bytes());
|
||||
|
||||
// Coarse entries: 8 bytes each
|
||||
// dword0 = (ref_to_fine_id << 14) | (pts_coarse & 0x3FFF)
|
||||
// dword1 = spn_coarse
|
||||
for &(ref_id, pts_c, spn_c) in coarse_entries {
|
||||
let dword0 = (ref_id << 14) | (pts_c & 0x3FFF);
|
||||
stream_ep.extend_from_slice(&dword0.to_be_bytes());
|
||||
stream_ep.extend_from_slice(&spn_c.to_be_bytes());
|
||||
}
|
||||
|
||||
// Fine entries: 4 bytes each
|
||||
// dword = (is_angle(1) + i_end_offset(3) + pts_fine(11) + spn_fine(17))
|
||||
for &(pts_f, spn_f) in fine_entries {
|
||||
let dword: u32 = ((pts_f & 0x7FF) << 17) | (spn_f & 0x1FFFF);
|
||||
stream_ep.extend_from_slice(&dword.to_be_bytes());
|
||||
}
|
||||
|
||||
// Assemble EP map
|
||||
let mut ep_map = Vec::new();
|
||||
ep_map.push(0); // reserved
|
||||
ep_map.push(1); // num_streams = 1
|
||||
ep_map.extend_from_slice(&stream_pid.to_be_bytes());
|
||||
ep_map.extend_from_slice(stream_header_bits);
|
||||
ep_map.extend_from_slice(&stream_ep);
|
||||
|
||||
// Assemble CPI section
|
||||
let mut cpi = Vec::new();
|
||||
let cpi_length = (2 + ep_map.len()) as u32; // reserved/type(2) + ep_map
|
||||
cpi.extend_from_slice(&cpi_length.to_be_bytes());
|
||||
cpi.extend_from_slice(&[0u8; 2]); // reserved/type
|
||||
cpi.extend_from_slice(&ep_map);
|
||||
|
||||
cpi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_clpi() {
|
||||
let cpi = build_cpi(
|
||||
0x1011,
|
||||
&[(0, 100, 0x00020000)], // 1 coarse
|
||||
&[(50, 1024)], // 1 fine
|
||||
);
|
||||
let data = build_clpi(500_000, Some(&cpi));
|
||||
|
||||
let clip = parse(&data).expect("should parse valid CLPI");
|
||||
assert_eq!(clip.version, "0200");
|
||||
assert_eq!(clip.source_packet_count, 500_000);
|
||||
assert_eq!(clip.ep_coarse.len(), 1);
|
||||
assert_eq!(clip.ep_fine.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ep_map() {
|
||||
let cpi = build_cpi(
|
||||
0x1011,
|
||||
&[
|
||||
(0, 100, 0x00020000), // coarse 0: fine starts at 0, pts_coarse=100, spn_coarse=0x20000
|
||||
(2, 200, 0x00040000), // coarse 1: fine starts at 2, pts_coarse=200, spn_coarse=0x40000
|
||||
],
|
||||
&[
|
||||
(50, 1024), // fine 0
|
||||
(100, 2048), // fine 1
|
||||
(25, 512), // fine 2
|
||||
(75, 1536), // fine 3
|
||||
],
|
||||
);
|
||||
let data = build_clpi(1_000_000, Some(&cpi));
|
||||
|
||||
let clip = parse(&data).expect("should parse EP map");
|
||||
assert_eq!(clip.ep_coarse.len(), 2);
|
||||
assert_eq!(clip.ep_fine.len(), 4);
|
||||
|
||||
// Verify coarse entries
|
||||
assert_eq!(clip.ep_coarse[0].ref_to_fine_id, 0);
|
||||
assert_eq!(clip.ep_coarse[0].pts_coarse, 100);
|
||||
assert_eq!(clip.ep_coarse[0].spn_coarse, 0x00020000);
|
||||
assert_eq!(clip.ep_coarse[1].ref_to_fine_id, 2);
|
||||
assert_eq!(clip.ep_coarse[1].pts_coarse, 200);
|
||||
assert_eq!(clip.ep_coarse[1].spn_coarse, 0x00040000);
|
||||
|
||||
// Verify fine entries
|
||||
assert_eq!(clip.ep_fine[0].pts_fine, 50);
|
||||
assert_eq!(clip.ep_fine[0].spn_fine, 1024);
|
||||
assert_eq!(clip.ep_fine[1].pts_fine, 100);
|
||||
assert_eq!(clip.ep_fine[1].spn_fine, 2048);
|
||||
assert_eq!(clip.ep_fine[2].pts_fine, 25);
|
||||
assert_eq!(clip.ep_fine[2].spn_fine, 512);
|
||||
assert_eq!(clip.ep_fine[3].pts_fine, 75);
|
||||
assert_eq!(clip.ep_fine[3].spn_fine, 1536);
|
||||
|
||||
// Verify resolved EP map assigns fine entries to coarse correctly
|
||||
let resolved = clip.resolved_ep_map();
|
||||
assert_eq!(resolved.len(), 4);
|
||||
// First two fines belong to coarse 0, last two to coarse 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_pts_calculation() {
|
||||
let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 100, spn_coarse: 0 };
|
||||
let fine = EpFine { pts_fine: 50, spn_fine: 0 };
|
||||
// full_pts = (100 << 19) + (50 << 8) = 52_428_800 + 12_800 = 52_441_600
|
||||
let pts = ClipInfo::full_pts(&coarse, &fine);
|
||||
assert_eq!(pts, (100 << 19) + (50 << 8));
|
||||
assert_eq!(pts, 52_441_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_spn_calculation() {
|
||||
let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FE0000 };
|
||||
let fine = EpFine { pts_fine: 0, spn_fine: 0x1234 };
|
||||
// full_spn = (0x00FE0000 & 0xFFFE0000) + 0x1234 = 0x00FE0000 + 0x1234 = 0x00FE1234
|
||||
let spn = ClipInfo::full_spn(&coarse, &fine);
|
||||
assert_eq!(spn, 0x00FE0000 + 0x1234);
|
||||
assert_eq!(spn, 0x00FE1234);
|
||||
|
||||
// Test that the low bit of spn_coarse is masked out
|
||||
let coarse2 = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FF0000 };
|
||||
let spn2 = ClipInfo::full_spn(&coarse2, &fine);
|
||||
// 0x00FF0000 & 0xFFFE0000 = 0x00FE0000, so low 17 bits of coarse are zeroed
|
||||
assert_eq!(spn2, 0x00FE0000 + 0x1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[0] = b'X';
|
||||
data[1] = b'X';
|
||||
data[2] = b'X';
|
||||
data[3] = b'X';
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_ep_map() {
|
||||
// cpi_start = 0 means no CPI section
|
||||
let data = build_clpi(100_000, None);
|
||||
let clip = parse(&data).expect("should parse with no EP map");
|
||||
assert_eq!(clip.source_packet_count, 100_000);
|
||||
assert!(clip.ep_coarse.is_empty());
|
||||
assert!(clip.ep_fine.is_empty());
|
||||
|
||||
// Also test: CPI section present but with zero streams
|
||||
let mut cpi = Vec::new();
|
||||
let cpi_length: u32 = 6; // reserved/type(2) + ep_map(reserved(1) + num_streams=0(1) + 2 padding)
|
||||
cpi.extend_from_slice(&cpi_length.to_be_bytes());
|
||||
cpi.extend_from_slice(&[0u8; 2]); // reserved/type
|
||||
cpi.push(0); // reserved
|
||||
cpi.push(0); // num_streams = 0
|
||||
cpi.extend_from_slice(&[0u8; 4]); // padding
|
||||
|
||||
let data2 = build_clpi(100_000, Some(&cpi));
|
||||
let clip2 = parse(&data2).expect("should parse with zero-stream EP map");
|
||||
assert!(clip2.ep_coarse.is_empty());
|
||||
assert!(clip2.ep_fine.is_empty());
|
||||
}
|
||||
}
|
||||
+1235
File diff suppressed because it is too large
Load Diff
-131
@@ -1,131 +0,0 @@
|
||||
//! High-level drive session — the main API for consumers.
|
||||
//!
|
||||
//! Opens a drive, identifies it via standard SCSI commands,
|
||||
//! matches it against the profile database, and provides
|
||||
//! raw disc access methods.
|
||||
|
||||
use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::identity::DriveId;
|
||||
use crate::profile::{self, DriveProfile, Chipset};
|
||||
use crate::platform::{Platform, DriveStatus};
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
|
||||
/// A complete drive session.
|
||||
///
|
||||
/// Handles: identify → match profile → create platform → execute commands.
|
||||
pub struct DriveSession {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
platform: Box<dyn Platform>,
|
||||
pub profile: DriveProfile,
|
||||
pub drive_id: DriveId,
|
||||
}
|
||||
|
||||
impl DriveSession {
|
||||
/// Open a drive, identify it, and find the matching profile.
|
||||
/// Uses the bundled profile database — no external files needed.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let profiles = profile::load_bundled()?;
|
||||
|
||||
// Identify drive via standard SCSI commands
|
||||
// SPC-4 §6.4 (INQUIRY) + MMC-6 §5.3.10 (Feature 010Ch)
|
||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
||||
|
||||
// Match drive to a profile by INQUIRY fields
|
||||
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: Box<dyn Platform> = match profile.chipset {
|
||||
Chipset::MediaTek => {
|
||||
Box::new(Mt1959::new(profile.clone()))
|
||||
}
|
||||
Chipset::Renesas => {
|
||||
return Err(Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
product_revision: "Renesas not yet implemented".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open with an explicit profile (skip 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: Box<dyn Platform> = match profile.chipset {
|
||||
Chipset::MediaTek => {
|
||||
Box::new(Mt1959::new(profile.clone()))
|
||||
}
|
||||
Chipset::Renesas => {
|
||||
return Err(Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
product_revision: "Renesas not yet implemented".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Activate raw disc access mode.
|
||||
pub fn unlock(&mut self) -> Result<()> {
|
||||
self.platform.unlock(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// Check if raw disc access mode is enabled.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.platform.is_unlocked()
|
||||
}
|
||||
|
||||
/// Read drive status and feature flags.
|
||||
pub fn status(&mut self) -> Result<DriveStatus> {
|
||||
self.platform.status(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Generic 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Drive session — open, identify, and read from optical drives.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::identity::DriveId;
|
||||
use crate::profile::{self, DriveProfile};
|
||||
use crate::platform::PlatformDriver;
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
|
||||
pub struct DriveSession {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
driver: Option<Box<dyn PlatformDriver>>,
|
||||
pub profile: Option<DriveProfile>,
|
||||
pub platform: Option<profile::Platform>,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl DriveSession {
|
||||
pub fn open(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 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,
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
for _ in 0..60 {
|
||||
let mut buf = [0u8; 0];
|
||||
if self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5000
|
||||
).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
Err(Error::DeviceNotFound {
|
||||
path: format!("{}: drive not ready after 30s", self.device_path),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
match self.driver {
|
||||
Some(ref d) => d.is_ready(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
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<()> {
|
||||
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);
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn scsi_execute(
|
||||
&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)
|
||||
}
|
||||
}
|
||||
|
||||
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)> {
|
||||
#[cfg(unix)]
|
||||
{ unix::find_drives() }
|
||||
#[cfg(windows)]
|
||||
{ windows::find_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>)> {
|
||||
#[cfg(unix)]
|
||||
{ unix::resolve_device(path) }
|
||||
#[cfg(windows)]
|
||||
{ windows::resolve_device(path) }
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Unix (Linux/macOS) drive discovery and device resolution.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::DriveId;
|
||||
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/sg{}", i);
|
||||
if !std::path::Path::new(&path).exists() { continue; }
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drives
|
||||
}
|
||||
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
if path.contains("/sg") {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
return Ok((path.to_string(), None));
|
||||
}
|
||||
if path.contains("/sr") {
|
||||
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
|
||||
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
|
||||
drop(sr_transport);
|
||||
for (sg_path, sg_id) in find_drives() {
|
||||
if sg_id.vendor_id == sr_id.vendor_id
|
||||
&& sg_id.product_id == sr_id.product_id
|
||||
&& sg_id.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning = format!(
|
||||
"{} is a block device (sr) — using {} (sg) for raw access", path, sg_path
|
||||
);
|
||||
return Ok((sg_path, Some(warning)));
|
||||
}
|
||||
}
|
||||
return Ok((path.to_string(), Some(format!(
|
||||
"{} is a block device (sr) — no matching sg device found", path
|
||||
))));
|
||||
}
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
Ok((path.to_string(), None))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Windows drive discovery and device resolution.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::identity::DriveId;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
|
||||
// Try CdRom0..CdRom15
|
||||
for i in 0..16 {
|
||||
let path = format!("\\\\.\\CdRom{}", i);
|
||||
if let Ok(mut transport) = crate::scsi::open(Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try drive letters if CdRom didn't find anything
|
||||
if drives.is_empty() {
|
||||
for letter in b'D'..=b'Z' {
|
||||
let path = format!("{}:", letter as char);
|
||||
if let Ok(mut transport) = crate::scsi::open(Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drives
|
||||
}
|
||||
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
Ok((normalize_path(path), None))
|
||||
}
|
||||
|
||||
/// Normalize a device path to Windows \\.\X: format.
|
||||
///
|
||||
/// Accepts: "D:", "D:\\", "\\.\D:", "\\.\CdRom0"
|
||||
fn normalize_path(path: &str) -> String {
|
||||
if path.starts_with("\\\\.\\") {
|
||||
return path.to_string();
|
||||
}
|
||||
let trimmed = path.trim_end_matches('\\');
|
||||
if trimmed.len() == 2 && trimmed.as_bytes()[1] == b':' {
|
||||
return format!("\\\\.\\{}", trimmed);
|
||||
}
|
||||
if path.to_lowercase().starts_with("cdrom") {
|
||||
return format!("\\\\.\\{}", path);
|
||||
}
|
||||
format!("\\\\.\\{}", path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_drive_letter() {
|
||||
assert_eq!(normalize_path("D:"), "\\\\.\\D:");
|
||||
assert_eq!(normalize_path("E:\\"), "\\\\.\\E:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_already_prefixed() {
|
||||
assert_eq!(normalize_path("\\\\.\\D:"), "\\\\.\\D:");
|
||||
assert_eq!(normalize_path("\\\\.\\CdRom0"), "\\\\.\\CdRom0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_cdrom() {
|
||||
assert_eq!(normalize_path("CdRom0"), "\\\\.\\CdRom0");
|
||||
}
|
||||
}
|
||||
+186
-54
@@ -1,92 +1,224 @@
|
||||
/// libfreemkv error codes.
|
||||
///
|
||||
/// The library returns structured error codes with context data.
|
||||
/// Applications are responsible for formatting user-facing messages.
|
||||
/// This keeps the library locale-independent and testable.
|
||||
//! Error types for libfreemkv.
|
||||
//!
|
||||
//! Every error is a code with structured data. No English text.
|
||||
//! Applications map codes to localized messages.
|
||||
//!
|
||||
//! # Error Code Ranges
|
||||
//!
|
||||
//! | Range | Category |
|
||||
//! |-------|----------|
|
||||
//! | E1xxx | Device errors |
|
||||
//! | E2xxx | Profile errors |
|
||||
//! | E3xxx | Unlock errors |
|
||||
//! | E4xxx | SCSI errors |
|
||||
//! | E5xxx | I/O errors |
|
||||
//! | E6xxx | Disc format errors |
|
||||
//! | E7xxx | AACS errors |
|
||||
//! | E8xxx | Keydb errors |
|
||||
//! | E9xxx | Mux errors |
|
||||
|
||||
/// Error code table.
|
||||
///
|
||||
/// | Code | Name | Meaning |
|
||||
/// |------|------|---------|
|
||||
/// | 1000 | DeviceNotFound | Device path doesn't exist or can't be opened |
|
||||
/// | 1001 | DevicePermission | Device exists but permission denied |
|
||||
/// | 2000 | UnsupportedDrive | Drive not in profile database |
|
||||
/// | 2001 | ProfileNotFound | Specific firmware version not in database |
|
||||
/// | 2002 | ProfileParse | Profile database is malformed |
|
||||
/// | 3000 | UnlockFailed | Drive rejected unlock command |
|
||||
/// | 3001 | SignatureMismatch | Wrong signature returned by drive |
|
||||
/// | 3002 | NotUnlocked | Raw read attempted before unlock |
|
||||
/// | 3003 | NotCalibrated | Raw read attempted before calibrate |
|
||||
/// | 4000 | ScsiError | SCSI command failed |
|
||||
/// | 4001 | ScsiTimeout | SCSI command timed out |
|
||||
/// | 5000 | IoError | OS-level I/O error |
|
||||
// ── Error codes ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
|
||||
pub const E_DEVICE_PERMISSION: u16 = 1001;
|
||||
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
|
||||
pub const E_PROFILE_NOT_FOUND: u16 = 2001;
|
||||
pub const E_PROFILE_PARSE: u16 = 2002;
|
||||
pub const E_UNLOCK_FAILED: u16 = 3000;
|
||||
pub const E_SIGNATURE_MISMATCH: u16 = 3001;
|
||||
pub const E_NOT_UNLOCKED: u16 = 3002;
|
||||
pub const E_NOT_CALIBRATED: u16 = 3003;
|
||||
pub const E_SCSI_ERROR: u16 = 4000;
|
||||
pub const E_SCSI_TIMEOUT: u16 = 4001;
|
||||
pub const E_IO_ERROR: u16 = 5000;
|
||||
pub const E_WRITE_ERROR: u16 = 5001;
|
||||
// Disc format (6xxx)
|
||||
pub const E_DISC_READ: u16 = 6000;
|
||||
pub const E_MPLS_PARSE: u16 = 6001;
|
||||
pub const E_CLPI_PARSE: u16 = 6002;
|
||||
pub const E_UDF_NOT_FOUND: u16 = 6003;
|
||||
pub const E_DISC_NO_TITLES: u16 = 6004;
|
||||
pub const E_DISC_TITLE_RANGE: u16 = 6005;
|
||||
pub const E_DISC_NO_EXTENTS: u16 = 6006;
|
||||
// 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. No English text.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
// 1xxx — Device errors
|
||||
// Device
|
||||
DeviceNotFound { path: String },
|
||||
DevicePermission { path: String },
|
||||
|
||||
// 2xxx — Profile errors
|
||||
// Profile
|
||||
UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String },
|
||||
ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String },
|
||||
ProfileParse { detail: String },
|
||||
ProfileParse,
|
||||
|
||||
// 3xxx — Unlock errors
|
||||
UnlockFailed { detail: String },
|
||||
// Unlock
|
||||
UnlockFailed,
|
||||
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
||||
NotUnlocked,
|
||||
NotCalibrated,
|
||||
|
||||
// 4xxx — SCSI errors
|
||||
// SCSI
|
||||
ScsiError { opcode: u8, status: u8, sense_key: u8 },
|
||||
ScsiTimeout { opcode: u8 },
|
||||
|
||||
// 5xxx — I/O errors
|
||||
// I/O
|
||||
IoError { source: std::io::Error },
|
||||
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 for programmatic handling.
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::DeviceNotFound { .. } => 1000,
|
||||
Error::DevicePermission { .. } => 1001,
|
||||
Error::UnsupportedDrive { .. } => 2000,
|
||||
Error::ProfileNotFound { .. } => 2001,
|
||||
Error::ProfileParse { .. } => 2002,
|
||||
Error::UnlockFailed { .. } => 3000,
|
||||
Error::SignatureMismatch { .. } => 3001,
|
||||
Error::NotUnlocked => 3002,
|
||||
Error::NotCalibrated => 3003,
|
||||
Error::ScsiError { .. } => 4000,
|
||||
Error::ScsiTimeout { .. } => 4001,
|
||||
Error::IoError { .. } => 5000,
|
||||
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::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
||||
Error::NotUnlocked => E_NOT_UNLOCKED,
|
||||
Error::NotCalibrated => E_NOT_CALIBRATED,
|
||||
Error::ScsiError { .. } => E_SCSI_ERROR,
|
||||
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
|
||||
Error::IoError { .. } => E_IO_ERROR,
|
||||
Error::WriteError => E_WRITE_ERROR,
|
||||
Error::DiscRead { .. } => E_DISC_READ,
|
||||
Error::MplsParse => E_MPLS_PARSE,
|
||||
Error::ClpiParse => E_CLPI_PARSE,
|
||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||
Error::DiscNoTitles => E_DISC_NO_TITLES,
|
||||
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
||||
Error::DiscNoExtents => E_DISC_NO_EXTENTS,
|
||||
Error::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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default Display — terse, for logs. Applications should format their own messages.
|
||||
/// 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, "E1000: device not found: {path}"),
|
||||
Error::DevicePermission { path } => write!(f, "E1001: permission denied: {path}"),
|
||||
Error::DeviceNotFound { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DevicePermission { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::UnsupportedDrive { vendor_id, product_id, product_revision } =>
|
||||
write!(f, "E2000: unsupported drive: {} {} {}", vendor_id.trim(), product_id.trim(), product_revision.trim()),
|
||||
write!(f, "E{}: {} {} {}", self.code(),
|
||||
vendor_id.trim(), product_id.trim(), product_revision.trim()),
|
||||
Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } =>
|
||||
write!(f, "E2001: no profile: {} {} {}", vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
|
||||
Error::ProfileParse { detail } => write!(f, "E2002: profile parse: {detail}"),
|
||||
Error::UnlockFailed { detail } => write!(f, "E3000: unlock failed: {detail}"),
|
||||
write!(f, "E{}: {} {} {}", self.code(),
|
||||
vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
|
||||
Error::SignatureMismatch { expected, got } =>
|
||||
write!(f, "E3001: signature mismatch: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
|
||||
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, "E3002: not unlocked"),
|
||||
Error::NotCalibrated => write!(f, "E3003: not calibrated"),
|
||||
Error::ScsiError { opcode, status, sense_key } =>
|
||||
write!(f, "E4000: SCSI 0x{opcode:02x} failed: status=0x{status:02x} sense=0x{sense_key:02x}"),
|
||||
Error::ScsiTimeout { opcode } => write!(f, "E4001: SCSI 0x{opcode:02x} timeout"),
|
||||
Error::IoError { source } => write!(f, "E5000: {source}"),
|
||||
write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key),
|
||||
Error::ScsiTimeout { opcode } =>
|
||||
write!(f, "E{}: 0x{:02x}", self.code(), opcode),
|
||||
Error::IoError { source } =>
|
||||
write!(f, "E{}: {}", self.code(), source),
|
||||
Error::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) {}
|
||||
+29
-1
@@ -39,8 +39,14 @@ pub struct DriveId {
|
||||
/// Format: CCYYMMDDHHMI (12 ASCII characters)
|
||||
pub firmware_date: String,
|
||||
|
||||
/// Drive serial number — GET CONFIGURATION Feature 0108h
|
||||
pub serial_number: String,
|
||||
|
||||
/// 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 {
|
||||
@@ -63,10 +69,30 @@ impl DriveId {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(Self::from_inquiry(&inquiry, &firmware_date))
|
||||
// GET CONFIGURATION Feature 0108h — Serial Number
|
||||
let mut gc_serial = vec![0u8; 256];
|
||||
let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
let serial_number = if let Ok(r) = transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) {
|
||||
if r.bytes_transferred > 12 {
|
||||
String::from_utf8_lossy(&gc_serial[12..r.bytes_transferred])
|
||||
.trim().to_string()
|
||||
} else { String::new() }
|
||||
} else { String::new() };
|
||||
|
||||
Ok(DriveId {
|
||||
vendor_id: ascii_field(&inquiry, 8, 16),
|
||||
product_id: ascii_field(&inquiry, 16, 32),
|
||||
product_revision: ascii_field(&inquiry, 32, 36),
|
||||
vendor_specific: ascii_field(&inquiry, 36, 43),
|
||||
firmware_date,
|
||||
serial_number,
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
raw_gc_010c: gc[..result.bytes_transferred].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build identity from raw INQUIRY bytes and firmware date string.
|
||||
/// Used by tests and when serial isn't available.
|
||||
pub fn from_inquiry(inquiry: &[u8], firmware_date: &str) -> Self {
|
||||
DriveId {
|
||||
vendor_id: ascii_field(inquiry, 8, 16),
|
||||
@@ -74,7 +100,9 @@ impl DriveId {
|
||||
product_revision: ascii_field(inquiry, 32, 36),
|
||||
vendor_specific: ascii_field(inquiry, 36, 43),
|
||||
firmware_date: firmware_date.to_string(),
|
||||
serial_number: String::new(),
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
raw_gc_010c: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Criterion Collection — `streamproperties.xml` + `playbackconfig.xml`
|
||||
//!
|
||||
//! Clean structured XML with Content/Qualifier per stream and
|
||||
//! stream number mapping via playbackconfig.
|
||||
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(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);
|
||||
if stream_infos.is_empty() { return None; }
|
||||
|
||||
// Stream number mapping from playbackconfig.xml
|
||||
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels = Vec::new();
|
||||
let mut audio_idx: u16 = 1;
|
||||
let mut sub_idx: u16 = 1;
|
||||
|
||||
for info in &stream_infos {
|
||||
let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| {
|
||||
match info.stream_type {
|
||||
StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n }
|
||||
StreamLabelType::Subtitle => { let n = sub_idx; sub_idx += 1; n }
|
||||
}
|
||||
});
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type: info.stream_type,
|
||||
language: info.language.clone(),
|
||||
name: String::new(),
|
||||
purpose: info.purpose,
|
||||
qualifier: info.qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: info.variant.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
struct StreamInfo {
|
||||
id: String,
|
||||
stream_type: StreamLabelType,
|
||||
language: String,
|
||||
variant: String,
|
||||
purpose: LabelPurpose,
|
||||
qualifier: LabelQualifier,
|
||||
}
|
||||
|
||||
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
|
||||
let mut infos = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < xml.len() {
|
||||
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Audio)
|
||||
} else if let Some(p) = xml[pos..].find("<SubtitleStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Subtitle)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
let end_tag = match stream_type {
|
||||
StreamLabelType::Audio => "</AudioStreamInfos>",
|
||||
StreamLabelType::Subtitle => "</SubtitleStreamInfos>",
|
||||
};
|
||||
|
||||
let block_end = match xml[tag..].find(end_tag) {
|
||||
Some(p) => tag + p + end_tag.len(),
|
||||
None => break,
|
||||
};
|
||||
|
||||
let block = &xml[tag..block_end];
|
||||
let id = extract_tag(block, "ID").unwrap_or_default();
|
||||
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
|
||||
let content = extract_tag(block, "Content").unwrap_or_default();
|
||||
let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default();
|
||||
|
||||
let (language, variant) = if lang_id.contains('_') {
|
||||
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
|
||||
(parts[0].to_lowercase(), parts[1].to_string())
|
||||
} else {
|
||||
(lang_id.to_lowercase(), String::new())
|
||||
};
|
||||
|
||||
let purpose = match content.as_str() {
|
||||
"COMMENTARY" => LabelPurpose::Commentary,
|
||||
_ => LabelPurpose::Normal,
|
||||
};
|
||||
|
||||
let qualifier = match qualifier_str.as_str() {
|
||||
"SDH" => LabelQualifier::Sdh,
|
||||
"DS" => LabelQualifier::DescriptiveService,
|
||||
_ => LabelQualifier::None,
|
||||
};
|
||||
|
||||
infos.push(StreamInfo { id, stream_type, language, variant, purpose, qualifier });
|
||||
pos = block_end;
|
||||
}
|
||||
infos
|
||||
}
|
||||
|
||||
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
|
||||
let mut pos = 0;
|
||||
while pos < xml.len() {
|
||||
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") {
|
||||
Some(p + pos)
|
||||
} else if let Some(p) = xml[pos..].find("<SubtitlesStreams>") {
|
||||
Some(p + pos)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tag_start = match tag_start {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
|
||||
let block_end = xml[tag_start..].find("</AudioStreams>")
|
||||
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
|
||||
.map(|p| tag_start + p + 20)
|
||||
.unwrap_or(xml.len());
|
||||
|
||||
let block = &xml[tag_start..block_end];
|
||||
|
||||
if let (Some(stream_id_str), Some(info_id)) = (extract_tag(block, "StreamID"), extract_tag(block, "StreamInfo_ID")) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
map.insert(info_id, stream_num);
|
||||
}
|
||||
}
|
||||
|
||||
pos = block_end;
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{}>", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = xml[start..].find(&close)? + start;
|
||||
Some(xml[start..end].trim().to_string())
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Warner CTRM — `menu_base.prop` and/or `language_streams.txt`
|
||||
//!
|
||||
//! Two sub-formats from the same framework. A disc may have one or both.
|
||||
//! When both exist, language_streams.txt provides structured types while
|
||||
//! menu_base.prop provides stream number → button name mapping.
|
||||
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "menu_base.prop")
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
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(reader, udf);
|
||||
|
||||
// Try menu_base.prop (stream numbers + key names)
|
||||
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) {
|
||||
(Some(ls), Some(mb)) => Some(merge(ls, mb)),
|
||||
(Some(ls), None) => Some(ls),
|
||||
(None, Some(mb)) => Some(mb),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
// language_streams has better type/purpose data, menu_base has button names
|
||||
// Match by stream number + type, take name from menu_base
|
||||
let mut result = ls;
|
||||
for label in &mut result {
|
||||
if let Some(mb_match) = mb.iter().find(|m|
|
||||
m.stream_type == label.stream_type && m.stream_number == label.stream_number
|
||||
) {
|
||||
if label.name.is_empty() && !mb_match.name.is_empty() {
|
||||
label.name = mb_match.name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── language_streams.txt parser ────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') { continue; }
|
||||
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if parts.len() < 4 { continue; }
|
||||
|
||||
let type_str = parts[1];
|
||||
let stream_num: u16 = match parts[2].parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let language = parts[3].to_string();
|
||||
let variant = if parts.len() > 4 { parts[4].to_string() } else { String::new() };
|
||||
|
||||
let (stream_type, purpose, qualifier) = match type_str {
|
||||
"audio_production" => (StreamLabelType::Audio, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"audio_commentary" => (StreamLabelType::Audio, LabelPurpose::Commentary, LabelQualifier::None),
|
||||
"audio_ime" => (StreamLabelType::Audio, LabelPurpose::Ime, LabelQualifier::None),
|
||||
"subtitle_production" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_commentary" => (StreamLabelType::Subtitle, LabelPurpose::Commentary, LabelQualifier::None),
|
||||
"subtitle_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::Forced),
|
||||
"subtitle_dual" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_bonus" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_ime" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::None),
|
||||
"subtitle_ime_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::Forced),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Classify variant code
|
||||
let mut codec_hint = String::new();
|
||||
let mut variant_code = String::new();
|
||||
let mut final_purpose = purpose;
|
||||
|
||||
if !variant.is_empty() {
|
||||
match variant.as_str() {
|
||||
// Codec variants — use shared label vocab
|
||||
"atmos" | "MLP" | "AC3" | "DTS" | "DDL" => {
|
||||
codec_hint = vocab::codec(&variant).to_string();
|
||||
}
|
||||
// Purpose variants
|
||||
"eda" => final_purpose = LabelPurpose::Descriptive,
|
||||
// Dialect variants — pass through raw code from disc
|
||||
"csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => {
|
||||
variant_code = variant.clone();
|
||||
}
|
||||
// Unknown — store as-is in codec_hint
|
||||
_ => codec_hint = variant.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name: String::new(),
|
||||
purpose: final_purpose,
|
||||
qualifier,
|
||||
codec_hint,
|
||||
variant: variant_code,
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
// ── menu_base.prop parser ──────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
let mut entries: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') { continue; }
|
||||
let eq_pos = match line.find('=') {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let full_key = &line[..eq_pos];
|
||||
let value = &line[eq_pos + 1..];
|
||||
|
||||
if let Some(dot_pos) = full_key.rfind('.') {
|
||||
let prefix = full_key[..dot_pos].to_string();
|
||||
let key = full_key[dot_pos + 1..].to_string();
|
||||
entries.entry(prefix).or_default().insert(key, value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
for (prefix, props) in &entries {
|
||||
// Audio: has "streamNumber" or "audioStream" and audio-related class
|
||||
let is_audio = props.get("class").map_or(false, |c| c.contains("AudioButton"))
|
||||
|| prefix.starts_with("audio_");
|
||||
let is_subtitle = props.get("class").map_or(false, |c| c.contains("SubtitleButton"))
|
||||
|| prefix.starts_with("subtitle_");
|
||||
|
||||
let stream_num_str = props.get("streamNumber")
|
||||
.or_else(|| props.get("audioStream"))
|
||||
.or_else(|| props.get("subtitleStream"));
|
||||
|
||||
let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if !is_audio && !is_subtitle { continue; }
|
||||
|
||||
let name = props.get("name").cloned().unwrap_or_default();
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
let purpose = if name_lower.contains("comment") || prefix.contains("comm") {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
|
||||
let qualifier = if is_subtitle && name_lower.contains("sdh") {
|
||||
LabelQualifier::Sdh
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
let stream_type = if is_audio { StreamLabelType::Audio } else { StreamLabelType::Subtitle };
|
||||
|
||||
// Try to extract language from audioLanguage/subtitleLanguage prop
|
||||
let language = props.get("audioLanguage")
|
||||
.or_else(|| props.get("subtitleLanguage"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name,
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
|
||||
Some(labels)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Stream label extraction from BD-J disc files.
|
||||
//!
|
||||
//! Each parser module represents one BD-J authoring framework.
|
||||
//! To add a new format:
|
||||
//! 1. Create `src/labels/myformat.rs`
|
||||
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
|
||||
//! 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;
|
||||
mod criterion;
|
||||
mod pixelogic;
|
||||
mod ctrm;
|
||||
pub mod vocab;
|
||||
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use crate::disc::{DiscTitle, Stream};
|
||||
|
||||
/// A stream label extracted from disc config files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamLabel {
|
||||
/// STN index (1-based)
|
||||
pub stream_number: u16,
|
||||
/// Audio or Subtitle
|
||||
pub stream_type: StreamLabelType,
|
||||
/// ISO 639-2 language code
|
||||
pub language: String,
|
||||
/// Display name (e.g. "Commentary", "Descriptive Audio")
|
||||
pub name: String,
|
||||
/// Stream purpose
|
||||
pub purpose: LabelPurpose,
|
||||
/// Additional qualifier
|
||||
pub qualifier: LabelQualifier,
|
||||
/// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos")
|
||||
pub codec_hint: String,
|
||||
/// Regional variant (e.g. "US", "UK", "Castilian", "Canadian")
|
||||
pub variant: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum StreamLabelType {
|
||||
Audio,
|
||||
Subtitle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LabelPurpose {
|
||||
Normal,
|
||||
Commentary,
|
||||
Descriptive,
|
||||
Score,
|
||||
Ime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LabelQualifier {
|
||||
None,
|
||||
Sdh,
|
||||
DescriptiveService,
|
||||
Forced,
|
||||
}
|
||||
|
||||
// ── Parser registry ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each entry: (name, detect_fn, parse_fn)
|
||||
// Order = priority. First match wins. Highest quality output first.
|
||||
|
||||
type DetectFn = fn(&UdfFs) -> bool;
|
||||
type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<Vec<StreamLabel>>;
|
||||
|
||||
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
("paramount", paramount::detect, paramount::parse),
|
||||
("criterion", criterion::detect, criterion::parse),
|
||||
("pixelogic", pixelogic::detect, pixelogic::parse),
|
||||
("ctrm", ctrm::detect, ctrm::parse),
|
||||
// ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser
|
||||
];
|
||||
|
||||
/// Search disc for config files, extract labels, apply to streams.
|
||||
/// This is 100% optional — if anything fails, streams are untouched.
|
||||
pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) {
|
||||
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
extract(reader, udf)
|
||||
})).unwrap_or_default();
|
||||
if labels.is_empty() { return; }
|
||||
|
||||
for title in titles.iter_mut() {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for stream in &mut title.streams {
|
||||
match stream {
|
||||
Stream::Audio(a) => {
|
||||
audio_idx += 1;
|
||||
if let Some(label) = labels.iter().find(|l|
|
||||
l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
|
||||
) {
|
||||
let mut parts = Vec::new();
|
||||
match label.purpose {
|
||||
LabelPurpose::Commentary => parts.push("Commentary".to_string()),
|
||||
LabelPurpose::Descriptive => parts.push("Descriptive Audio".to_string()),
|
||||
LabelPurpose::Score => parts.push("Score".to_string()),
|
||||
LabelPurpose::Ime => parts.push("IME".to_string()),
|
||||
LabelPurpose::Normal => {}
|
||||
}
|
||||
if !label.variant.is_empty() {
|
||||
parts.push(format!("({})", label.variant));
|
||||
}
|
||||
if !label.codec_hint.is_empty() {
|
||||
parts.push(label.codec_hint.clone());
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
a.label = parts.join(" ");
|
||||
} else if !label.name.is_empty() {
|
||||
a.label = label.name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Stream::Subtitle(s) => {
|
||||
sub_idx += 1;
|
||||
if let Some(label) = labels.iter().find(|l|
|
||||
l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
|
||||
) {
|
||||
if label.qualifier == LabelQualifier::Forced {
|
||||
s.forced = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
for (_name, detect, parse) in PARSERS {
|
||||
if detect(udf) {
|
||||
if let Some(labels) = parse(reader, udf) {
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a file exists in any BDMV/JAR subdirectory.
|
||||
pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool {
|
||||
find_jar_file(udf, filename).is_some()
|
||||
}
|
||||
|
||||
/// Find a file in any BDMV/JAR subdirectory, return its path.
|
||||
pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
|
||||
let jar_dir = udf.find_dir("/BDMV/JAR")?;
|
||||
for entry in &jar_dir.entries {
|
||||
if entry.is_dir {
|
||||
let path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
|
||||
// Check if file exists in this subdirectory
|
||||
for child in &entry.entries {
|
||||
if !child.is_dir && child.name.eq_ignore_ascii_case(filename) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a file from any BDMV/JAR subdirectory by filename.
|
||||
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(reader, &path).ok().filter(|d| !d.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Paramount/onQ — `playlists.xml`
|
||||
//!
|
||||
//! Richest structured format. Complete language lists with forced flags
|
||||
//! and commentary indices per playlist, all in XML attributes.
|
||||
//!
|
||||
//! ```xml
|
||||
//! <playlist name="Feature" id="00222"
|
||||
//! aud="eng,deu,spa,spa,fra"
|
||||
//! sub="eng,eng,zho,ces,dan"
|
||||
//! forced_sub="0,0,0,1,0"
|
||||
//! aud_com1_idx="10"
|
||||
//! sub_com1_idx="23,24,25" />
|
||||
//! ```
|
||||
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(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"
|
||||
let feature = find_feature_playlist(text)?;
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
// Parse audio streams
|
||||
if let Some(aud) = extract_attr(&feature, "aud") {
|
||||
let com_idx = extract_attr(&feature, "aud_com1_idx")
|
||||
.and_then(|s| s.parse::<usize>().ok());
|
||||
|
||||
for (i, lang) in aud.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
if lang.is_empty() { continue; }
|
||||
let purpose = if com_idx == Some(i) {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
labels.push(StreamLabel {
|
||||
stream_number: (i + 1) as u16,
|
||||
stream_type: StreamLabelType::Audio,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse subtitle streams
|
||||
if let Some(sub) = extract_attr(&feature, "sub") {
|
||||
let forced: Vec<bool> = extract_attr(&feature, "forced_sub")
|
||||
.map(|s| s.split(',').map(|f| f.trim() == "1").collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let com_indices: Vec<usize> = extract_attr(&feature, "sub_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for (i, lang) in sub.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
if lang.is_empty() { continue; }
|
||||
|
||||
let purpose = if com_indices.contains(&i) {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
|
||||
let qualifier = if forced.get(i).copied().unwrap_or(false) {
|
||||
LabelQualifier::Forced
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: (i + 1) as u16,
|
||||
stream_type: StreamLabelType::Subtitle,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
/// Find the feature playlist element (the one with the most audio tracks).
|
||||
fn find_feature_playlist(xml: &str) -> Option<String> {
|
||||
let mut best: Option<String> = None;
|
||||
let mut best_aud_count = 0;
|
||||
|
||||
let mut pos = 0;
|
||||
while let Some(start) = xml[pos..].find("<playlist ") {
|
||||
let abs_start = pos + start;
|
||||
let end = match xml[abs_start..].find("/>") {
|
||||
Some(p) => abs_start + p + 2,
|
||||
None => break,
|
||||
};
|
||||
let element = &xml[abs_start..end];
|
||||
|
||||
// Prefer name="Feature" explicitly
|
||||
if let Some(name) = extract_attr(element, "name") {
|
||||
if name.eq_ignore_ascii_case("Feature") {
|
||||
return Some(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise pick the one with the most audio streams
|
||||
if let Some(aud) = extract_attr(element, "aud") {
|
||||
let count = aud.split(',').count();
|
||||
if count > best_aud_count {
|
||||
best_aud_count = count;
|
||||
best = Some(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pos = end;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Extract an XML attribute value from an element string.
|
||||
fn extract_attr<'a>(element: &'a str, name: &str) -> Option<String> {
|
||||
let needle = format!("{}=\"", name);
|
||||
let start = element.find(&needle)? + needle.len();
|
||||
let end = element[start..].find('"')? + start;
|
||||
Some(element[start..end].to_string())
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Pixelogic — `bluray_project.bin`
|
||||
//!
|
||||
//! Binary file with embedded UTF-8 token strings in STN order per
|
||||
//! playlist section. Most common format (5/10 test discs).
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
|
||||
/// Known audio codec tokens
|
||||
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
|
||||
/// Known region tokens
|
||||
const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"];
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(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();
|
||||
let mut in_feature = false;
|
||||
let mut audio_num: u16 = 0;
|
||||
let mut sub_num: u16 = 0;
|
||||
|
||||
for s in &strings {
|
||||
// Detect feature section start
|
||||
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
|
||||
if in_feature { break; }
|
||||
in_feature = true;
|
||||
audio_num = 0;
|
||||
sub_num = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect section end
|
||||
if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
|
||||
break;
|
||||
}
|
||||
|
||||
if !in_feature { continue; }
|
||||
|
||||
if let Some(label) = parse_token(s) {
|
||||
match label.stream_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_num += 1;
|
||||
labels.push(StreamLabel { stream_number: audio_num, ..label });
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_num += 1;
|
||||
labels.push(StreamLabel { stream_number: sub_num, ..label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.split('_').collect();
|
||||
if parts.len() < 2 { return None; }
|
||||
|
||||
let lang = parts[0];
|
||||
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut codec = String::new();
|
||||
let mut purpose = LabelPurpose::Normal;
|
||||
let mut qualifier = LabelQualifier::None;
|
||||
let mut variant = String::new();
|
||||
let mut is_subtitle = false;
|
||||
let mut is_audio = false;
|
||||
|
||||
for &part in &parts[1..] {
|
||||
if part.is_empty() { continue; }
|
||||
if AUDIO_CODECS.contains(&part) { codec = vocab::codec(part).to_string(); is_audio = true; }
|
||||
else if part == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; }
|
||||
else if part == "ACOM" { purpose = LabelPurpose::Commentary; is_audio = true; }
|
||||
else if part == "ADLG" { is_audio = true; }
|
||||
else if part == "ATRI" { is_audio = true; }
|
||||
else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; }
|
||||
else if part == "SDLG" { is_subtitle = true; }
|
||||
else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; }
|
||||
else if part == "STRI" { is_subtitle = true; }
|
||||
else if part == "TXT" { is_subtitle = true; }
|
||||
else if part == "FOR" { qualifier = LabelQualifier::Forced; }
|
||||
else if REGIONS.contains(&part) { variant = part.to_string(); }
|
||||
else if part.starts_with("PGStream") { is_subtitle = true; }
|
||||
else { return None; }
|
||||
}
|
||||
|
||||
if !is_audio && !is_subtitle { return None; }
|
||||
|
||||
let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio };
|
||||
|
||||
Some(StreamLabel {
|
||||
stream_number: 0,
|
||||
stream_type,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: codec,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_strings(data: &[u8]) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for &b in data {
|
||||
if b >= 0x20 && b < 0x7f {
|
||||
current.push(b as char);
|
||||
} else {
|
||||
if current.len() > 3 {
|
||||
strings.push(current.clone());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if current.len() > 3 {
|
||||
strings.push(current);
|
||||
}
|
||||
strings
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Shared label vocabulary — values we are 100% confident about.
|
||||
//!
|
||||
//! Labels come from BD-J authoring tool files (bluray_project.bin,
|
||||
//! playlists.xml, menu_base.prop, etc.) — NOT from BD spec fields.
|
||||
//! This is NOT for MPLS/CLPI/STN data. Those follow the BD spec directly.
|
||||
//!
|
||||
//! Rules:
|
||||
//! - Only map values we are 100% certain about (published codec names).
|
||||
//! - Unknown codes (csp, eda, cf, etc.) pass through raw from disc.
|
||||
//! - The app/CLI handles display text, not the lib.
|
||||
|
||||
/// Map a codec identifier found in label data to its display name.
|
||||
///
|
||||
/// These are well-known codec identifiers used across multiple BD-J
|
||||
/// authoring tools. NOT BD spec STN codec IDs — those are decoded
|
||||
/// separately in mpls.rs.
|
||||
pub fn codec(code: &str) -> &str {
|
||||
match code {
|
||||
"MLP" => "TrueHD",
|
||||
"AC3" | "AC" => "Dolby Digital",
|
||||
"DTS" => "DTS",
|
||||
"DDL" => "Dolby Digital Plus",
|
||||
"WAV" => "PCM",
|
||||
"atmos" => "Dolby Atmos",
|
||||
_ => code,
|
||||
}
|
||||
}
|
||||
+86
-28
@@ -1,51 +1,109 @@
|
||||
//! libfreemkv — Open source raw disc access for optical drives.
|
||||
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||
//!
|
||||
//! Provides SCSI/MMC commands to enable raw reading mode on compatible
|
||||
//! Blu-ray drives, allowing direct sector access for disc archival
|
||||
//! and backup purposes.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The library is data-driven. Drive-specific SCSI command sequences
|
||||
//! are stored in profile files, not in code. Adding support for a new
|
||||
//! drive requires only a profile contribution — no rebuild needed.
|
||||
//!
|
||||
//! ```text
|
||||
//! DriveSession (high-level API)
|
||||
//! ├── Platform trait (per-chipset unlock logic)
|
||||
//! ├── DriveProfile (per-drive data from JSON profiles)
|
||||
//! └── ScsiTransport (SG_IO on Linux, IOKit on macOS)
|
||||
//! ```
|
||||
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
||||
//! sector reading. 206 bundled drive profiles. No external files needed.
|
||||
//!
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use libfreemkv::DriveSession;
|
||||
//! 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();
|
||||
//!
|
||||
//! session.unlock().unwrap();
|
||||
//! session.calibrate().unwrap();
|
||||
//! for title in &disc.titles {
|
||||
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
|
||||
//! }
|
||||
//!
|
||||
//! let mut buf = vec![0u8; 2048];
|
||||
//! let n = session.read_sectors(0, 1, &mut buf).unwrap();
|
||||
//! // Read content (decrypted automatically if AACS keys available)
|
||||
//! let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
//! while let Some(unit) = reader.read_unit().unwrap() {
|
||||
//! // 6144 bytes of decrypted content per unit
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # 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
|
||||
//! └── Platform
|
||||
//! └── 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
|
||||
//! ```
|
||||
//!
|
||||
//! # AACS Encryption
|
||||
//!
|
||||
//! Disc scanning automatically detects and handles AACS encryption.
|
||||
//! If a KEYDB.cfg is available (via `ScanOptions` or standard paths),
|
||||
//! the library resolves keys and decrypts content transparently.
|
||||
//!
|
||||
//! Supports AACS 1.0 (Blu-ray) and AACS 2.0 (UHD, with fallback).
|
||||
//!
|
||||
//! # Error Codes
|
||||
//!
|
||||
//! All errors are structured with numeric codes. No user-facing English
|
||||
//! text -- applications format their own messages.
|
||||
//!
|
||||
//! | Range | Category |
|
||||
//! |-------|----------|
|
||||
//! | E1xxx | Device errors (not found, permission) |
|
||||
//! | E2xxx | Profile errors (unsupported drive) |
|
||||
//! | E3xxx | Unlock errors (failed, signature) |
|
||||
//! | E4xxx | SCSI errors (command failed, timeout) |
|
||||
//! | E5xxx | I/O errors |
|
||||
//! | E6xxx | Disc format errors |
|
||||
//! | E7xxx | AACS errors |
|
||||
|
||||
pub mod error;
|
||||
pub mod sector;
|
||||
pub mod scsi;
|
||||
pub mod profile;
|
||||
pub mod platform;
|
||||
pub mod drive;
|
||||
pub mod identity;
|
||||
pub mod speed;
|
||||
pub mod udf;
|
||||
pub mod mpls;
|
||||
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, 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};
|
||||
|
||||
+628
@@ -0,0 +1,628 @@
|
||||
//! MPLS playlist parser — Blu-ray movie playlists.
|
||||
//!
|
||||
//! Each .mpls file in BDMV/PLAYLIST/ defines a title.
|
||||
//! Contains play items (clips) with in/out timestamps,
|
||||
//! stream info (video, audio, subtitle tracks).
|
||||
//!
|
||||
//! Reference: https://github.com/lw/BluRay/wiki/MPLS
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Parsed MPLS playlist.
|
||||
#[derive(Debug)]
|
||||
pub struct Playlist {
|
||||
/// MPLS version (e.g. "0200" or "0300")
|
||||
pub version: String,
|
||||
/// Play items in playback order
|
||||
pub play_items: Vec<PlayItem>,
|
||||
/// Streams from the first play item's STN table
|
||||
pub streams: Vec<StreamEntry>,
|
||||
}
|
||||
|
||||
/// A play item — one clip reference with in/out times.
|
||||
#[derive(Debug)]
|
||||
pub struct PlayItem {
|
||||
/// Clip filename without extension (e.g. "00001")
|
||||
pub clip_id: String,
|
||||
/// In-time in 45kHz ticks
|
||||
pub in_time: u32,
|
||||
/// Out-time in 45kHz ticks
|
||||
pub out_time: u32,
|
||||
/// Connection condition (1=seamless, 5/6=non-seamless)
|
||||
pub connection_condition: u8,
|
||||
}
|
||||
|
||||
/// A stream entry from the STN table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamEntry {
|
||||
/// Stream category: 1=video, 2=audio, 3=PG subtitle, 4=IG, 5=secondary audio, 6=secondary video, 7=DV EL
|
||||
pub stream_type: u8,
|
||||
/// MPEG-TS PID
|
||||
pub pid: u16,
|
||||
/// Coding type (0x24=HEVC, 0x1B=H264, 0x83=TrueHD, etc.)
|
||||
pub coding_type: u8,
|
||||
/// Video format (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p)
|
||||
pub video_format: u8,
|
||||
/// Video frame rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94)
|
||||
pub video_rate: u8,
|
||||
/// Audio channel layout (1=mono, 3=stereo, 6=5.1, 12=7.1)
|
||||
pub audio_format: u8,
|
||||
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz)
|
||||
pub audio_rate: u8,
|
||||
/// ISO 639-2 language code (e.g. "eng")
|
||||
pub language: String,
|
||||
/// HDR dynamic range (0=SDR, 1=HDR10, 2=Dolby Vision)
|
||||
pub dynamic_range: u8,
|
||||
/// Color space (0=unknown, 1=BT.709, 2=BT.2020)
|
||||
pub color_space: u8,
|
||||
/// Whether this is a secondary stream (commentary, PiP, DV EL)
|
||||
pub secondary: bool,
|
||||
}
|
||||
|
||||
/// Parse an MPLS file from raw bytes.
|
||||
pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
if data.len() < 40 {
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
if &data[0..4] != b"MPLS" {
|
||||
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::MplsParse);
|
||||
}
|
||||
|
||||
let pl = &data[playlist_start..];
|
||||
let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize;
|
||||
|
||||
let mut play_items = Vec::with_capacity(num_play_items);
|
||||
let mut streams = Vec::new();
|
||||
let mut pos = 10;
|
||||
|
||||
for item_idx in 0..num_play_items {
|
||||
if pos + 2 > pl.len() { break; }
|
||||
let item_length = u16::from_be_bytes([pl[pos], pl[pos + 1]]) as usize;
|
||||
if pos + 2 + item_length > pl.len() { break; }
|
||||
|
||||
let item = &pl[pos + 2..pos + 2 + item_length];
|
||||
if item.len() < 20 { pos += 2 + item_length; continue; }
|
||||
|
||||
let clip_id = String::from_utf8_lossy(&item[0..5]).to_string();
|
||||
let connection_condition = item[9] & 0x0F;
|
||||
let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]);
|
||||
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
|
||||
|
||||
// Parse STN table from the first play item
|
||||
// PlayItem layout after out_time:
|
||||
// [20:28] UO_mask_table (8 bytes)
|
||||
// [28] misc flags (1 byte)
|
||||
// [29] still_mode (1 byte)
|
||||
// [30:32] still_time (2 bytes)
|
||||
// [32:] STN_table
|
||||
const STN_OFFSET: usize = 32;
|
||||
if item_idx == 0 && item.len() > STN_OFFSET + 16 {
|
||||
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
|
||||
let n_video = item[STN_OFFSET + 4] as usize;
|
||||
let n_audio = item[STN_OFFSET + 5] as usize;
|
||||
let n_pg = item[STN_OFFSET + 6] as usize;
|
||||
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_dv = item[STN_OFFSET + 11] as usize;
|
||||
|
||||
let mut spos = STN_OFFSET + 16;
|
||||
|
||||
// Primary video
|
||||
for _ in 0..n_video {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// Primary audio
|
||||
for _ in 0..n_audio {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 2) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// PG subtitles
|
||||
for _ in 0..n_pg {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 3) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// IG (skip but advance)
|
||||
for _ in 0..n_ig {
|
||||
if let Some((_, next)) = parse_stream_entry(item, spos, 4) {
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// Secondary audio
|
||||
for _ in 0..n_sec_audio {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 2) {
|
||||
entry.stream_type = 5;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip extra ref bytes: 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; }
|
||||
}
|
||||
// Secondary video (PiP)
|
||||
for _ in 0..n_sec_video {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
entry.stream_type = 6;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip extra ref bytes (audio refs + PG refs)
|
||||
if next + 2 < item.len() {
|
||||
let n_arefs = item[next] as usize;
|
||||
let after_arefs = next + 2 + n_arefs + (n_arefs % 2);
|
||||
if after_arefs < item.len() {
|
||||
let n_prefs = item[after_arefs] as usize;
|
||||
spos = after_arefs + 2 + n_prefs + (n_prefs % 2);
|
||||
} else { spos = after_arefs; }
|
||||
} 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) {
|
||||
entry.stream_type = 7;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
}
|
||||
|
||||
play_items.push(PlayItem {
|
||||
clip_id,
|
||||
in_time,
|
||||
out_time,
|
||||
connection_condition,
|
||||
});
|
||||
|
||||
pos += 2 + item_length;
|
||||
}
|
||||
|
||||
Ok(Playlist {
|
||||
version,
|
||||
play_items,
|
||||
streams,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse one stream entry from the STN table.
|
||||
/// Returns (StreamEntry, next position) or None.
|
||||
fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
|
||||
if pos + 2 > item.len() { return None; }
|
||||
|
||||
// Stream entry: length(1) + data
|
||||
let se_len = item[pos] as usize;
|
||||
let se_end = pos + 1 + se_len;
|
||||
if se_end > item.len() { return None; }
|
||||
|
||||
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
|
||||
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
|
||||
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Stream attributes: length(1) + coding_type(1) + format-specific data
|
||||
if se_end + 2 > item.len() { return None; }
|
||||
let sa_len = item[se_end] as usize;
|
||||
let sa_end = se_end + 1 + sa_len;
|
||||
if sa_end > item.len() || sa_len < 1 { return None; }
|
||||
|
||||
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;
|
||||
let mut audio_rate = 0u8;
|
||||
let mut dynamic_range = 0u8;
|
||||
let mut color_space_val = 0u8;
|
||||
let mut language = String::new();
|
||||
|
||||
match stream_type {
|
||||
1 => {
|
||||
// Video: coding_type(1) + format_rate(1) + [hdr_info(1) if HEVC]
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
if sa.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sa[2..5]).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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some((StreamEntry {
|
||||
stream_type,
|
||||
pid,
|
||||
coding_type,
|
||||
video_format,
|
||||
video_rate,
|
||||
audio_format,
|
||||
audio_rate,
|
||||
language,
|
||||
dynamic_range,
|
||||
color_space: color_space_val,
|
||||
secondary: false,
|
||||
}, sa_end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal MPLS binary with given play items and STN streams on the first item.
|
||||
/// STN counts: (n_video, n_audio, n_pg, n_ig, n_sec_audio, n_sec_video, n_pip_pg, n_dv)
|
||||
fn build_mpls(
|
||||
play_items_data: &[(/*clip_id*/&[u8;5], /*conn*/u8, /*in_time*/u32, /*out_time*/u32)],
|
||||
stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8),
|
||||
stream_entries: &[Vec<u8>], // raw stream entry + attributes bytes for each stream
|
||||
) -> Vec<u8> {
|
||||
let playlist_start: u32 = 40; // right after the 40-byte header
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// File header: "MPLS" + version + playlist_start + 3 more offsets (unused)
|
||||
buf.extend_from_slice(b"MPLS0200");
|
||||
buf.extend_from_slice(&playlist_start.to_be_bytes());
|
||||
// mark_start, extension_start (unused by parser), padding to 40 bytes
|
||||
buf.extend_from_slice(&[0u8; 28]);
|
||||
|
||||
// PlayList section starts here (offset 40)
|
||||
// PlayList: length(4) + reserved(2) + num_play_items(2) + num_sub_paths(2) = 10 header bytes
|
||||
let pl_start = buf.len();
|
||||
buf.extend_from_slice(&[0u8; 4]); // length placeholder
|
||||
buf.extend_from_slice(&[0u8; 2]); // reserved
|
||||
buf.extend_from_slice(&(play_items_data.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
|
||||
|
||||
for (idx, (clip_id, conn, in_time, out_time)) in play_items_data.iter().enumerate() {
|
||||
// Build play item content
|
||||
let mut item = Vec::new();
|
||||
// [0..5] clip_id
|
||||
item.extend_from_slice(*clip_id);
|
||||
// [5..9] codec_id ("M2TS")
|
||||
item.extend_from_slice(b"M2TS");
|
||||
// [9] connection_condition in low nibble
|
||||
item.push(*conn & 0x0F);
|
||||
// [10..12] reserved
|
||||
item.extend_from_slice(&[0u8; 2]);
|
||||
// [12..16] in_time
|
||||
item.extend_from_slice(&in_time.to_be_bytes());
|
||||
// [16..20] out_time
|
||||
item.extend_from_slice(&out_time.to_be_bytes());
|
||||
// [20..28] UO_mask_table
|
||||
item.extend_from_slice(&[0u8; 8]);
|
||||
// [28] misc flags
|
||||
item.push(0);
|
||||
// [29] still_mode
|
||||
item.push(0);
|
||||
// [30..32] still_time
|
||||
item.extend_from_slice(&[0u8; 2]);
|
||||
|
||||
// STN table (only for the first play item)
|
||||
if idx == 0 {
|
||||
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
|
||||
let stn_header_start = item.len();
|
||||
item.extend_from_slice(&[0u8; 2]); // STN length placeholder
|
||||
item.extend_from_slice(&[0u8; 2]); // reserved
|
||||
item.push(stn_counts.0); // n_video
|
||||
item.push(stn_counts.1); // n_audio
|
||||
item.push(stn_counts.2); // n_pg
|
||||
item.push(stn_counts.3); // n_ig
|
||||
item.push(stn_counts.4); // n_sec_audio
|
||||
item.push(stn_counts.5); // n_sec_video
|
||||
item.push(stn_counts.6); // n_pip_pg
|
||||
item.push(stn_counts.7); // n_dv
|
||||
item.extend_from_slice(&[0u8; 4]); // reserved
|
||||
|
||||
// Stream entries
|
||||
for se in stream_entries {
|
||||
item.extend_from_slice(se);
|
||||
}
|
||||
|
||||
// Patch STN length
|
||||
let stn_len = (item.len() - stn_header_start - 2) as u16;
|
||||
let stn_len_bytes = stn_len.to_be_bytes();
|
||||
item[stn_header_start] = stn_len_bytes[0];
|
||||
item[stn_header_start + 1] = stn_len_bytes[1];
|
||||
}
|
||||
|
||||
// Write item_length(2) + item
|
||||
let item_length = item.len() as u16;
|
||||
buf.extend_from_slice(&item_length.to_be_bytes());
|
||||
buf.extend_from_slice(&item);
|
||||
}
|
||||
|
||||
// Patch PlayList length
|
||||
let pl_len = (buf.len() - pl_start - 4) as u32;
|
||||
let pl_len_bytes = pl_len.to_be_bytes();
|
||||
buf[pl_start] = pl_len_bytes[0];
|
||||
buf[pl_start + 1] = pl_len_bytes[1];
|
||||
buf[pl_start + 2] = pl_len_bytes[2];
|
||||
buf[pl_start + 3] = pl_len_bytes[3];
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Build a stream entry (stream_entry part + stream_attributes part).
|
||||
/// stream_entry: type=0x01 (PlayItem stream), PID given.
|
||||
/// For video: attrs = coding_type(1) + format_rate(1) [+ hdr_byte if HEVC]
|
||||
/// For audio: attrs = coding_type(1) + format_rate(1) + language(3)
|
||||
/// For PG: attrs = coding_type(1) + language(3)
|
||||
fn build_stream_entry_video(pid: u16, coding_type: u8, format: u8, rate: u8, hdr: Option<u8>) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
// Stream entry: length(1) + sub_path_type(1) + pid(2)
|
||||
out.push(3); // se_len = 3 bytes (type + pid_hi + pid_lo)
|
||||
out.push(0x01); // type: PlayItem stream
|
||||
out.extend_from_slice(&pid.to_be_bytes());
|
||||
// Stream attributes
|
||||
let mut attrs = vec![coding_type, (format << 4) | rate];
|
||||
if let Some(h) = hdr {
|
||||
attrs.push(h);
|
||||
}
|
||||
out.push(attrs.len() as u8); // sa_len
|
||||
out.extend_from_slice(&attrs);
|
||||
out
|
||||
}
|
||||
|
||||
fn build_stream_entry_audio(pid: u16, coding_type: u8, ch_layout: u8, sample_rate: u8, lang: &[u8; 3]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.push(3);
|
||||
out.push(0x01);
|
||||
out.extend_from_slice(&pid.to_be_bytes());
|
||||
// attrs: coding_type(1) + format_rate(1) + language(3)
|
||||
let attrs = vec![coding_type, (ch_layout << 4) | sample_rate, lang[0], lang[1], lang[2]];
|
||||
out.push(attrs.len() as u8);
|
||||
out.extend_from_slice(&attrs);
|
||||
out
|
||||
}
|
||||
|
||||
fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.push(3);
|
||||
out.push(0x01);
|
||||
out.extend_from_slice(&pid.to_be_bytes());
|
||||
// attrs: coding_type(1) + language(3)
|
||||
let attrs = vec![coding_type, lang[0], lang[1], lang[2]];
|
||||
out.push(attrs.len() as u8);
|
||||
out.extend_from_slice(&attrs);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_mpls() {
|
||||
let in_time: u32 = 90000; // 2 seconds at 45kHz
|
||||
let out_time: u32 = 4500000; // 100 seconds
|
||||
|
||||
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); // H264, 1080p, 23.976
|
||||
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"); // TrueHD, 5.1, 48kHz
|
||||
let pg = build_stream_entry_pg(0x1200, 0x90, b"eng"); // PGS subtitle
|
||||
|
||||
let data = build_mpls(
|
||||
&[(b"00001", 1, in_time, out_time)],
|
||||
(1, 1, 1, 0, 0, 0, 0, 0),
|
||||
&[video, audio, pg],
|
||||
);
|
||||
|
||||
let playlist = parse(&data).expect("should parse valid MPLS");
|
||||
assert_eq!(playlist.version, "0200");
|
||||
assert_eq!(playlist.play_items.len(), 1);
|
||||
assert_eq!(playlist.play_items[0].clip_id, "00001");
|
||||
assert_eq!(playlist.play_items[0].in_time, in_time);
|
||||
assert_eq!(playlist.play_items[0].out_time, out_time);
|
||||
assert_eq!(playlist.play_items[0].connection_condition, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_streams() {
|
||||
let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12)); // HEVC, 2160p, 23.976, HDR10+BT.2020
|
||||
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng");
|
||||
let pg = build_stream_entry_pg(0x1200, 0x90, b"fra");
|
||||
|
||||
let data = build_mpls(
|
||||
&[(b"00001", 1, 0, 9000000)],
|
||||
(1, 1, 1, 0, 0, 0, 0, 0),
|
||||
&[video, audio, pg],
|
||||
);
|
||||
|
||||
let playlist = parse(&data).expect("should parse");
|
||||
assert_eq!(playlist.streams.len(), 3);
|
||||
|
||||
// Video stream
|
||||
let v = &playlist.streams[0];
|
||||
assert_eq!(v.stream_type, 1);
|
||||
assert_eq!(v.pid, 0x1011);
|
||||
assert_eq!(v.coding_type, 0x24); // HEVC
|
||||
assert_eq!(v.video_format, 8); // 2160p
|
||||
assert_eq!(v.video_rate, 1); // 23.976
|
||||
assert_eq!(v.dynamic_range, 1); // HDR10
|
||||
assert_eq!(v.color_space, 2); // BT.2020
|
||||
assert!(!v.secondary);
|
||||
|
||||
// Audio stream
|
||||
let a = &playlist.streams[1];
|
||||
assert_eq!(a.stream_type, 2);
|
||||
assert_eq!(a.pid, 0x1100);
|
||||
assert_eq!(a.coding_type, 0x83); // TrueHD
|
||||
assert_eq!(a.audio_format, 6); // 5.1
|
||||
assert_eq!(a.audio_rate, 1); // 48kHz
|
||||
assert_eq!(a.language, "eng");
|
||||
assert!(!a.secondary);
|
||||
|
||||
// PG subtitle stream
|
||||
let s = &playlist.streams[2];
|
||||
assert_eq!(s.stream_type, 3);
|
||||
assert_eq!(s.pid, 0x1200);
|
||||
assert_eq!(s.coding_type, 0x90); // PGS
|
||||
assert_eq!(s.language, "fra");
|
||||
assert!(!s.secondary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_mpls(
|
||||
&[(b"00001", 1, 0, 9000000)],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
);
|
||||
data[0] = b'X';
|
||||
data[1] = b'X';
|
||||
data[2] = b'X';
|
||||
data[3] = b'X';
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truncated() {
|
||||
// Less than 40 bytes
|
||||
assert!(parse(&[0u8; 10]).is_err());
|
||||
assert!(parse(b"MPLS0200").is_err());
|
||||
assert!(parse(&[0u8; 39]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_play_items() {
|
||||
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
|
||||
|
||||
let data = build_mpls(
|
||||
&[
|
||||
(b"00001", 1, 90000, 4500000),
|
||||
(b"00002", 5, 4500000, 9000000),
|
||||
(b"00003", 6, 9000000, 13500000),
|
||||
],
|
||||
(1, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[video],
|
||||
);
|
||||
|
||||
let playlist = parse(&data).expect("should parse multiple play items");
|
||||
assert_eq!(playlist.play_items.len(), 3);
|
||||
assert_eq!(playlist.play_items[0].clip_id, "00001");
|
||||
assert_eq!(playlist.play_items[0].connection_condition, 1);
|
||||
assert_eq!(playlist.play_items[1].clip_id, "00002");
|
||||
assert_eq!(playlist.play_items[1].connection_condition, 5);
|
||||
assert_eq!(playlist.play_items[1].in_time, 4500000);
|
||||
assert_eq!(playlist.play_items[2].clip_id, "00003");
|
||||
assert_eq!(playlist.play_items[2].connection_condition, 6);
|
||||
assert_eq!(playlist.play_items[2].out_time, 13500000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_secondary_streams() {
|
||||
// Primary video
|
||||
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
|
||||
// Secondary audio (stream_type 5): build as audio, parser overrides type to 5
|
||||
let sec_audio_se = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"eng");
|
||||
// Need ref bytes after secondary audio: num_refs(1) + reserved(1) = 2 bytes min
|
||||
let mut sec_audio_with_refs = sec_audio_se;
|
||||
sec_audio_with_refs.push(0); // num_refs = 0
|
||||
sec_audio_with_refs.push(0); // reserved
|
||||
|
||||
// Secondary video (stream_type 6): build as video, parser overrides type to 6
|
||||
let sec_video_se = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
|
||||
// Need ref bytes: n_arefs(1) + reserved(1) + n_prefs(1) + reserved(1) = 4 bytes
|
||||
let mut sec_video_with_refs = sec_video_se;
|
||||
sec_video_with_refs.push(0); // n_arefs = 0
|
||||
sec_video_with_refs.push(0); // reserved
|
||||
sec_video_with_refs.push(0); // n_prefs = 0
|
||||
sec_video_with_refs.push(0); // reserved
|
||||
|
||||
let data = build_mpls(
|
||||
&[(b"00001", 1, 0, 9000000)],
|
||||
(1, 0, 0, 0, 1, 1, 0, 0), // 1 video, 0 audio, 0 pg, 0 ig, 1 sec_audio, 1 sec_video
|
||||
&[video, sec_audio_with_refs, sec_video_with_refs],
|
||||
);
|
||||
|
||||
let playlist = parse(&data).expect("should parse secondary streams");
|
||||
// Should have 3 streams: primary video, secondary audio, secondary video
|
||||
assert_eq!(playlist.streams.len(), 3);
|
||||
|
||||
// Primary video
|
||||
assert_eq!(playlist.streams[0].stream_type, 1);
|
||||
assert!(!playlist.streams[0].secondary);
|
||||
|
||||
// Secondary audio
|
||||
assert_eq!(playlist.streams[1].stream_type, 5);
|
||||
assert!(playlist.streams[1].secondary);
|
||||
assert_eq!(playlist.streams[1].pid, 0x1A00);
|
||||
|
||||
// Secondary video
|
||||
assert_eq!(playlist.streams[2].stream_type, 6);
|
||||
assert!(playlist.streams[2].secondary);
|
||||
assert_eq!(playlist.streams[2].pid, 0x1B00);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1100, pts, dts: None, data }
|
||||
}
|
||||
|
||||
// --- syncword detection ---
|
||||
|
||||
#[test]
|
||||
fn find_ac3_sync_at_start() {
|
||||
let data = [0x0B, 0x77, 0x01, 0x02, 0x03];
|
||||
assert_eq!(find_ac3_sync(&data), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ac3_sync_with_garbage_prefix() {
|
||||
let data = [0xFF, 0xFE, 0x0B, 0x77, 0x01, 0x02];
|
||||
assert_eq!(find_ac3_sync(&data), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ac3_sync_none() {
|
||||
let data = [0x0B, 0x78, 0x00, 0x00];
|
||||
assert_eq!(find_ac3_sync(&data), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ac3_sync_empty() {
|
||||
let data: [u8; 0] = [];
|
||||
assert_eq!(find_ac3_sync(&data), None);
|
||||
}
|
||||
|
||||
// --- parse syncword → frame extracted ---
|
||||
|
||||
#[test]
|
||||
fn parse_syncword() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
// AC3 frame starting with syncword
|
||||
let data = vec![0x0B, 0x77, 0x44, 0x55, 0x66, 0x77, 0x88];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_syncword_with_garbage_prefix() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
// Garbage bytes before syncword
|
||||
let data = vec![0xFF, 0xFE, 0x0B, 0x77, 0x44, 0x55];
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Data should start from the syncword
|
||||
assert_eq!(frames[0].data[0], 0x0B);
|
||||
assert_eq!(frames[0].data[1], 0x77);
|
||||
assert_eq!(frames[0].data.len(), 4); // syncword + 2 payload bytes
|
||||
}
|
||||
|
||||
// --- all frames are keyframes ---
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
for i in 0..5 {
|
||||
let data = vec![0x0B, 0x77, 0x00, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "AC3 frame {} should be a keyframe", i);
|
||||
}
|
||||
}
|
||||
|
||||
// --- codec_private is None ---
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = Ac3Parser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
// --- empty / too-short PES ---
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_byte_pes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let pes = make_pes(vec![0x0B], Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
fn pts_conversion() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let data = vec![0x0B, 0x77, 0x00, 0x01];
|
||||
// 45000 ticks = 0.5 seconds → 500_000_000 ns
|
||||
let pes = make_pes(data, Some(45000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 500_000_000);
|
||||
}
|
||||
|
||||
// --- None PTS ---
|
||||
|
||||
#[test]
|
||||
fn no_pts() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let data = vec![0x0B, 0x77, 0x00, 0x01];
|
||||
let pes = make_pes(data, None);
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 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 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1100, pts, dts: None, data }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_basic_frame() {
|
||||
let mut parser = DtsParser::new();
|
||||
// DTS core syncword: 7F FE 80 01 + payload
|
||||
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = DtsParser::new();
|
||||
for i in 0..3 {
|
||||
let data = vec![0x7F, 0xFE, 0x80, 0x01, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "DTS frame should always be keyframe");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = DtsParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = DtsParser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pts() {
|
||||
let mut parser = DtsParser::new();
|
||||
let pes = make_pes(vec![0x7F, 0xFE, 0x80, 0x01], None);
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1011, pts, dts: None, data }
|
||||
}
|
||||
|
||||
// --- find_start_code tests ---
|
||||
|
||||
#[test]
|
||||
fn find_start_code_3byte() {
|
||||
let data = [0x00, 0x00, 0x01, 0x65];
|
||||
assert_eq!(find_start_code(&data, 0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_4byte() {
|
||||
let data = [0x00, 0x00, 0x00, 0x01, 0x65];
|
||||
// find_start_code looks for 00 00 01 pattern, which starts at offset 1 in a 4-byte start code
|
||||
assert_eq!(find_start_code(&data, 0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_offset() {
|
||||
let data = [0xFF, 0xFF, 0x00, 0x00, 0x01, 0x09];
|
||||
assert_eq!(find_start_code(&data, 0), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_none() {
|
||||
let data = [0x00, 0x00, 0x00, 0x00];
|
||||
assert_eq!(find_start_code(&data, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_too_short() {
|
||||
let data = [0x00, 0x00];
|
||||
assert_eq!(find_start_code(&data, 0), None);
|
||||
}
|
||||
|
||||
// --- parse SPS+PPS → codec_private ---
|
||||
|
||||
#[test]
|
||||
fn parse_sps_pps() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// Build PES with SPS (type 7) + PPS (type 8) + IDR slice (type 5)
|
||||
// SPS NAL: 0x67 = 0_11_00111 (nal_type = 7), followed by profile/compat/level + payload
|
||||
// PPS NAL: 0x68 = 0_11_01000 (nal_type = 8)
|
||||
let mut data = Vec::new();
|
||||
// SPS: 00 00 01 [67 42 00 1E <payload>]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x67); // SPS
|
||||
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB, 0xCD]); // profile=0x42, compat=0x00, level=0x1E
|
||||
// PPS: 00 00 01 [68 <payload>]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x68); // PPS
|
||||
data.extend_from_slice(&[0xCE, 0x01]);
|
||||
// IDR slice: 00 00 01 [65 <payload>]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x65); // IDR
|
||||
data.extend_from_slice(&[0x88, 0x00, 0x10]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
// codec_private should now be available
|
||||
let cp = parser.codec_private();
|
||||
assert!(cp.is_some(), "codec_private should be Some after seeing SPS+PPS");
|
||||
let cp = cp.unwrap();
|
||||
|
||||
// AVCDecoderConfigurationRecord checks
|
||||
assert_eq!(cp[0], 1, "configurationVersion");
|
||||
assert_eq!(cp[1], 0x42, "profile from SPS[1]");
|
||||
assert_eq!(cp[2], 0x00, "compatibility from SPS[2]");
|
||||
assert_eq!(cp[3], 0x1E, "level from SPS[3]");
|
||||
assert_eq!(cp[4], 0xFF, "reserved + lengthSizeMinusOne=3");
|
||||
assert_eq!(cp[5], 0xE1, "reserved + numSPS=1");
|
||||
|
||||
// Frames should have been produced
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_before_sps_pps() {
|
||||
let parser = H264Parser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
// --- IDR keyframe detection ---
|
||||
|
||||
#[test]
|
||||
fn parse_idr_keyframe() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// PES with IDR NAL (type 5 = 0x65)
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x65); // IDR slice (nal_type = 5)
|
||||
data.extend_from_slice(&[0x88, 0x00, 0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "IDR slice should be detected as keyframe");
|
||||
}
|
||||
|
||||
// --- non-IDR → not keyframe ---
|
||||
|
||||
#[test]
|
||||
fn parse_non_idr() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// PES with non-IDR slice (type 1 = 0x61 or 0x41)
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x41); // non-IDR coded slice (nal_type = 1)
|
||||
data.extend_from_slice(&[0x9A, 0x00, 0x10]);
|
||||
|
||||
let pes = make_pes(data, Some(180000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(!frames[0].keyframe, "non-IDR slice should not be keyframe");
|
||||
}
|
||||
|
||||
// --- length prefix conversion ---
|
||||
|
||||
#[test]
|
||||
fn length_prefix_conversion() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// PES with a single non-IDR NAL
|
||||
let nal_payload = [0x41, 0xAA, 0xBB, 0xCC, 0xDD]; // type 1, 5 bytes
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&nal_payload);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
let frame_data = &frames[0].data;
|
||||
|
||||
// Should start with 4-byte big-endian length prefix
|
||||
assert!(frame_data.len() >= 4, "frame data should have length prefix");
|
||||
let length = u32::from_be_bytes([frame_data[0], frame_data[1], frame_data[2], frame_data[3]]);
|
||||
assert_eq!(length as usize, nal_payload.len(), "length prefix should match NAL size");
|
||||
|
||||
// Followed by the NAL data itself
|
||||
assert_eq!(&frame_data[4..], &nal_payload);
|
||||
|
||||
// No start code (00 00 01) should appear in the output
|
||||
for i in 0..frame_data.len().saturating_sub(2) {
|
||||
let is_sc = frame_data[i] == 0x00 && frame_data[i + 1] == 0x00 && frame_data[i + 2] == 0x01;
|
||||
assert!(!is_sc, "output should not contain Annex B start codes");
|
||||
}
|
||||
}
|
||||
|
||||
// --- SPS/PPS/AUD are stripped from frame data ---
|
||||
|
||||
#[test]
|
||||
fn sps_pps_aud_stripped_from_frame_data() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// AUD (type 9)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x09);
|
||||
data.push(0xF0);
|
||||
// SPS (type 7)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x67);
|
||||
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB]);
|
||||
// PPS (type 8)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x68);
|
||||
data.extend_from_slice(&[0xCE, 0x01]);
|
||||
// IDR (type 5) - only this should appear in frame data
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x65);
|
||||
data.extend_from_slice(&[0x88, 0x00]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
|
||||
// Frame data should only contain the IDR NAL (length-prefixed)
|
||||
let fd = &frames[0].data;
|
||||
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
|
||||
// IDR NAL is 0x65, 0x88 (trailing 0x00 is stripped as potential start code prefix)
|
||||
assert_eq!(length, 2);
|
||||
assert_eq!(fd[4], 0x65); // IDR NAL type byte
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
fn pts_conversion() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x41);
|
||||
data.extend_from_slice(&[0x00, 0x10]);
|
||||
|
||||
// PTS = 90000 (1 second at 90kHz) → 1_000_000_000 ns
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = H264Parser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- DTS preferred over PTS when present ---
|
||||
|
||||
#[test]
|
||||
fn dts_preferred_over_pts() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x41);
|
||||
data.extend_from_slice(&[0x00, 0x10]);
|
||||
|
||||
let pes = PesPacket {
|
||||
pid: 0x1011,
|
||||
pts: Some(180000), // 2 seconds
|
||||
dts: Some(90000), // 1 second
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
// DTS should be used, not PTS
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1011, pts, dts: None, data }
|
||||
}
|
||||
|
||||
/// Build an HEVC NAL header (2 bytes). Type is bits 1-6 of first byte.
|
||||
/// Format: forbidden(1) | type(6) | layer_id_high(1) || layer_id_low(5) | tid(3)
|
||||
fn hevc_nal_header(nal_type: u8) -> [u8; 2] {
|
||||
[(nal_type & 0x3F) << 1, 0x01] // tid=1
|
||||
}
|
||||
|
||||
// --- VPS+SPS+PPS → codec_private ---
|
||||
|
||||
#[test]
|
||||
fn parse_vps_sps_pps() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// VPS (type 32)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let vps_hdr = hevc_nal_header(32);
|
||||
data.extend_from_slice(&vps_hdr);
|
||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // VPS payload
|
||||
|
||||
// SPS (type 33)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let sps_hdr = hevc_nal_header(33);
|
||||
data.extend_from_slice(&sps_hdr);
|
||||
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0A, 0x0B, 0x0C, 0x0D]); // SPS payload (>12 bytes for level)
|
||||
|
||||
// PPS (type 34)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let pps_hdr = hevc_nal_header(34);
|
||||
data.extend_from_slice(&pps_hdr);
|
||||
data.extend_from_slice(&[0xDD, 0xEE]); // PPS payload
|
||||
|
||||
// IRAP slice (type 19 = IDR_W_RADL) so a frame is emitted
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let idr_hdr = hevc_nal_header(19);
|
||||
data.extend_from_slice(&idr_hdr);
|
||||
data.extend_from_slice(&[0x10, 0x20, 0x30]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let _frames = parser.parse(&pes);
|
||||
|
||||
let cp = parser.codec_private();
|
||||
assert!(cp.is_some(), "codec_private should be Some after VPS+SPS+PPS");
|
||||
|
||||
let cp = cp.unwrap();
|
||||
// configurationVersion = 1
|
||||
assert_eq!(cp[0], 1);
|
||||
// numOfArrays = 3 (VPS, SPS, PPS)
|
||||
assert_eq!(cp[22], 3);
|
||||
// Should be longer than the minimal header (23 bytes) + array entries
|
||||
assert!(cp.len() > 23, "codec_private should contain VPS+SPS+PPS data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_before_params() {
|
||||
let parser = HevcParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_missing_pps() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// Only VPS + SPS, no PPS
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(32));
|
||||
data.extend_from_slice(&[0xAA, 0xBB]);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(33));
|
||||
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]);
|
||||
// Add a slice so parse doesn't return empty
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
assert!(parser.codec_private().is_none(), "should be None without PPS");
|
||||
}
|
||||
|
||||
// --- IRAP keyframe detection ---
|
||||
|
||||
#[test]
|
||||
fn parse_irap_keyframe_idr_w_radl() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// IDR_W_RADL = type 19
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(19));
|
||||
data.extend_from_slice(&[0x10, 0x20, 0x30]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "IDR_W_RADL (type 19) should be keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_irap_keyframe_bla() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// BLA_W_LP = type 16
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(16));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "BLA_W_LP (type 16) should be keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_irap_keyframe_cra() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// CRA_NUT = type 21
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(21));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "CRA (type 21) should be keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_irap_type_23() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// RSV_IRAP_VCL23 = type 23 (upper boundary)
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(23));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "type 23 should be keyframe");
|
||||
}
|
||||
|
||||
// --- non-IRAP (trailing) → not keyframe ---
|
||||
|
||||
#[test]
|
||||
fn parse_trailing_not_keyframe() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// TRAIL_R = type 1
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(1));
|
||||
data.extend_from_slice(&[0x10, 0x20, 0x30]);
|
||||
|
||||
let pes = make_pes(data, Some(180000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(!frames[0].keyframe, "TRAIL_R (type 1) should not be keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tsa_not_keyframe() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// TSA_N = type 2
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(2));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(!frames[0].keyframe, "TSA_N (type 2) should not be keyframe");
|
||||
}
|
||||
|
||||
// --- VPS/SPS/PPS stripped from frame data ---
|
||||
|
||||
#[test]
|
||||
fn param_sets_stripped_from_frame() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// VPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(32));
|
||||
data.extend_from_slice(&[0xAA]);
|
||||
// SPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(33));
|
||||
data.extend_from_slice(&[0xBB]);
|
||||
// PPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(34));
|
||||
data.extend_from_slice(&[0xCC]);
|
||||
// IDR slice
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let idr_hdr = hevc_nal_header(19);
|
||||
data.extend_from_slice(&idr_hdr);
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
|
||||
// Frame data should only have the IDR NAL (length-prefixed)
|
||||
let fd = &frames[0].data;
|
||||
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
|
||||
// IDR NAL = 2 bytes header + 2 bytes payload = 4 bytes
|
||||
assert_eq!(length as usize + 4, fd.len(), "frame should contain exactly one length-prefixed NAL");
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = HevcParser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
fn pts_conversion() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(1));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
}
|
||||
@@ -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,73 @@
|
||||
//! 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 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1200, pts, dts: None, data }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_basic_segment() {
|
||||
let mut parser = PgsParser::new();
|
||||
// PGS segment data (PCS = presentation composition segment)
|
||||
let data = vec![0x16, 0x00, 0x00, 0x11, 0x01, 0x02, 0x03];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = PgsParser::new();
|
||||
for i in 0..3 {
|
||||
let data = vec![0x16, 0x00, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "PGS segment should always be keyframe");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = PgsParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = PgsParser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! 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 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1100, pts, dts: None, data }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_basic_frame() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
// TrueHD major sync: F8 72 6F BA (at 4-byte aligned position) + payload
|
||||
let data = vec![0xF8, 0x72, 0x6F, 0xBA, 0x01, 0x02, 0x03, 0x04];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
for i in 0..3 {
|
||||
let data = vec![0xF8, 0x72, 0x6F, 0xBA, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "TrueHD frame should always be keyframe");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = TrueHdParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1011, pts, dts: None, data }
|
||||
}
|
||||
|
||||
/// Build a VC-1 PES with sequence header + entry point + frame start code.
|
||||
fn build_vc1_iframe_pes() -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
// Sequence header: 00 00 01 0F + payload
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
// Entry point: 00 00 01 0E + payload
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_ENTRY_POINT]);
|
||||
data.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]);
|
||||
// Frame: 00 00 01 0D + payload
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66, 0x77, 0x88, 0x99]);
|
||||
data
|
||||
}
|
||||
|
||||
// --- sequence header detection ---
|
||||
|
||||
#[test]
|
||||
fn parse_sequence_header() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Sequence header present → keyframe
|
||||
assert!(frames[0].keyframe, "PES with sequence header should be keyframe");
|
||||
// seq_header should be stored internally
|
||||
assert!(parser.seq_header.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_entry_point() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
assert!(parser.entry_point.is_some());
|
||||
}
|
||||
|
||||
// --- codec_private is BITMAPINFOHEADER (40+ bytes) ---
|
||||
|
||||
#[test]
|
||||
fn codec_private_bitmapinfoheader() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
let cp = parser.codec_private();
|
||||
assert!(cp.is_some(), "codec_private should be Some after seq header + entry point");
|
||||
|
||||
let cp = cp.unwrap();
|
||||
// BITMAPINFOHEADER is 40 bytes + extra data
|
||||
assert!(cp.len() >= 40, "codec_private should be at least 40 bytes (BITMAPINFOHEADER)");
|
||||
|
||||
// biSize (first 4 bytes, little-endian) should equal total length
|
||||
let bi_size = u32::from_le_bytes([cp[0], cp[1], cp[2], cp[3]]);
|
||||
assert_eq!(bi_size as usize, cp.len(), "biSize should match total codec_private length");
|
||||
|
||||
// biCompression = "WVC1" at offset 16
|
||||
assert_eq!(&cp[16..20], b"WVC1", "FOURCC should be WVC1");
|
||||
|
||||
// biWidth at offset 4 (little-endian u32) = 1920
|
||||
let width = u32::from_le_bytes([cp[4], cp[5], cp[6], cp[7]]);
|
||||
assert_eq!(width, 1920);
|
||||
|
||||
// biHeight at offset 8 (little-endian u32) = 1080
|
||||
let height = u32::from_le_bytes([cp[8], cp[9], cp[10], cp[11]]);
|
||||
assert_eq!(height, 1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_before_data() {
|
||||
let parser = Vc1Parser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_missing_entry_point() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
// Only sequence header, no entry point
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
assert!(parser.codec_private().is_none(), "should be None without entry point");
|
||||
}
|
||||
|
||||
// --- frame without sequence header → not keyframe ---
|
||||
|
||||
#[test]
|
||||
fn parse_non_keyframe() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
// PES with only a frame start code (no sequence header)
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66, 0x77]);
|
||||
|
||||
let pes = make_pes(data, Some(180000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(!frames[0].keyframe, "frame without sequence header should not be keyframe");
|
||||
}
|
||||
|
||||
// --- frame data starts from frame start code ---
|
||||
|
||||
#[test]
|
||||
fn frame_data_starts_at_frame_sc() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data.clone(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Frame data should start with the frame start code (00 00 01 0D)
|
||||
assert!(frames[0].data.len() >= 4);
|
||||
assert_eq!(&frames[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
fn pts_conversion() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// --- DTS preferred over PTS ---
|
||||
|
||||
#[test]
|
||||
fn dts_preferred_over_pts() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66]);
|
||||
|
||||
let pes = PesPacket {
|
||||
pid: 0x1011,
|
||||
pts: Some(180000),
|
||||
dts: Some(90000),
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// --- find_next_sc utility ---
|
||||
|
||||
#[test]
|
||||
fn find_next_sc_basic() {
|
||||
let data = [0xAA, 0x00, 0x00, 0x01, 0x0D, 0xBB];
|
||||
assert_eq!(find_next_sc(&data, 0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_sc_none() {
|
||||
let data = [0xAA, 0xBB, 0xCC];
|
||||
assert_eq!(find_next_sc(&data, 0), None);
|
||||
}
|
||||
|
||||
// --- codec_private extra data contains seq header + entry point ---
|
||||
|
||||
#[test]
|
||||
fn codec_private_contains_extra_data() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
let cp = parser.codec_private().unwrap();
|
||||
// After the 40-byte BITMAPINFOHEADER, we should have seq_header + entry_point data
|
||||
let extra = &cp[40..];
|
||||
assert!(!extra.is_empty(), "extra data after BITMAPINFOHEADER should not be empty");
|
||||
// Extra data should start with the sequence header start code
|
||||
assert_eq!(&extra[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
}
|
||||
}
|
||||
+130
@@ -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(()) }
|
||||
}
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
//! 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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_id_roundtrip() {
|
||||
// 1-byte IDs have high bit set (0x80..=0xFF)
|
||||
for &id in &[0x80u32, 0xA3, 0xFF] {
|
||||
let mut buf = Vec::new();
|
||||
write_id(&mut buf, id).unwrap();
|
||||
assert_eq!(buf.len(), 1);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, consumed) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, id, "1-byte ID roundtrip failed for 0x{:X}", id);
|
||||
assert_eq!(consumed, 1);
|
||||
}
|
||||
// 2-byte IDs (0x4000..=0x7FFF)
|
||||
for &id in &[0x4286u32, 0x4282, 0x7FFF] {
|
||||
let mut buf = Vec::new();
|
||||
write_id(&mut buf, id).unwrap();
|
||||
assert_eq!(buf.len(), 2);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, consumed) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, id, "2-byte ID roundtrip failed for 0x{:X}", id);
|
||||
assert_eq!(consumed, 2);
|
||||
}
|
||||
// 3-byte IDs (0x200000..=0x3FFFFF)
|
||||
for &id in &[0x22B59Cu32, 0x23E383] {
|
||||
let mut buf = Vec::new();
|
||||
write_id(&mut buf, id).unwrap();
|
||||
assert_eq!(buf.len(), 3);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, consumed) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, id, "3-byte ID roundtrip failed for 0x{:X}", id);
|
||||
assert_eq!(consumed, 3);
|
||||
}
|
||||
// 4-byte IDs (0x10000000..=0x1FFFFFFF)
|
||||
for &id in &[EBML, SEGMENT, TRACKS, CLUSTER] {
|
||||
let mut buf = Vec::new();
|
||||
write_id(&mut buf, id).unwrap();
|
||||
assert_eq!(buf.len(), 4);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, consumed) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, id, "4-byte ID roundtrip failed for 0x{:X}", id);
|
||||
assert_eq!(consumed, 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_size_roundtrip() {
|
||||
let test_sizes: &[u64] = &[0, 1, 0x7E, 127, 128, 0x3FFE, 16383, 16384, 0x1FFFFE, 0x0FFFFFFE, 0x1_0000_0000];
|
||||
for &size in test_sizes {
|
||||
let mut buf = Vec::new();
|
||||
write_size(&mut buf, size).unwrap();
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, _consumed) = read_size(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, size, "size roundtrip failed for {}", size);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_uint_roundtrip() {
|
||||
let test_vals: &[u64] = &[0, 1, 127, 255, 256, 0xFFFF, 0xFF_FFFF, 0xFFFF_FFFF, 1_000_000_000_000];
|
||||
let test_id = EBML_VERSION;
|
||||
for &val in test_vals {
|
||||
let mut buf = Vec::new();
|
||||
write_uint(&mut buf, test_id, val).unwrap();
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (id, id_len) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(id, test_id);
|
||||
let (size, _) = read_size(&mut cursor).unwrap();
|
||||
let read_val = read_uint_val(&mut cursor, size as usize).unwrap();
|
||||
assert_eq!(read_val, val, "uint roundtrip failed for {}", val);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_string_roundtrip() {
|
||||
let test_strings = &["", "matroska", "freemkv", "Hello, World!", "unicode: \u{1F600}"];
|
||||
let test_id = EBML_DOC_TYPE;
|
||||
for &s in test_strings {
|
||||
let mut buf = Vec::new();
|
||||
write_string(&mut buf, test_id, s).unwrap();
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (id, _) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(id, test_id);
|
||||
let (size, _) = read_size(&mut cursor).unwrap();
|
||||
let read_s = read_string_val(&mut cursor, size as usize).unwrap();
|
||||
assert_eq!(read_s, s, "string roundtrip failed for {:?}", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_float_roundtrip() {
|
||||
let test_vals: &[f64] = &[0.0, 1.0, -1.0, 3.14159265358979, 48000.0, 7200000.0, f64::MIN, f64::MAX];
|
||||
let test_id = DURATION;
|
||||
for &val in test_vals {
|
||||
let mut buf = Vec::new();
|
||||
write_float(&mut buf, test_id, val).unwrap();
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (id, _) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(id, test_id);
|
||||
let (size, _) = read_size(&mut cursor).unwrap();
|
||||
assert_eq!(size, 8);
|
||||
let read_val = read_float_val(&mut cursor, size as usize).unwrap();
|
||||
assert_eq!(read_val.to_bits(), val.to_bits(), "float roundtrip failed for {}", val);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_size() {
|
||||
let mut buf = Vec::new();
|
||||
write_unknown_size(&mut buf).unwrap();
|
||||
assert_eq!(buf.len(), 8);
|
||||
assert_eq!(buf[0], 0x01);
|
||||
for &b in &buf[1..] {
|
||||
assert_eq!(b, 0xFF, "unknown size bytes should all be 0xFF after first byte");
|
||||
}
|
||||
// Reading it back should yield u64::MAX
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (size, consumed) = read_size(&mut cursor).unwrap();
|
||||
assert_eq!(size, u64::MAX);
|
||||
assert_eq!(consumed, 8);
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
//! IsoStream — read/write Blu-ray ISO disc images.
|
||||
//!
|
||||
//! Read: parses UDF filesystem inside the ISO using the same pipeline as
|
||||
//! DiscStream (titles, streams, labels, AACS). An ISO is a flat image of
|
||||
//! 2048-byte sectors — sector N starts at byte offset N * 2048.
|
||||
//!
|
||||
//! Write: creates a sector-by-sector disc image from a SectorReader source.
|
||||
|
||||
use std::io::{self, Read, Write, Seek, SeekFrom};
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use super::IOStream;
|
||||
use crate::disc::{Disc, DiscTitle, ScanOptions};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const SECTOR_SIZE: u64 = 2048;
|
||||
|
||||
/// File-backed sector reader for ISO images.
|
||||
pub struct IsoSectorReader {
|
||||
file: File,
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl IsoSectorReader {
|
||||
pub fn open(path: &str) -> io::Result<Self> {
|
||||
let file = File::open(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
|
||||
let size = file.metadata()?.len();
|
||||
let capacity = (size / SECTOR_SIZE) as u32;
|
||||
Ok(Self { file, capacity })
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> u32 { self.capacity }
|
||||
}
|
||||
|
||||
impl SectorReader for IsoSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let bytes = count as usize * SECTOR_SIZE as usize;
|
||||
self.file.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file.read_exact(&mut buf[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Blu-ray ISO image stream.
|
||||
///
|
||||
/// Read: opens ISO, parses UDF (same as DiscStream), streams BD-TS content.
|
||||
/// Write: receives sector data and writes to ISO file.
|
||||
pub struct IsoStream {
|
||||
disc_title: DiscTitle,
|
||||
disc: Option<Disc>,
|
||||
reader: Option<IsoSectorReader>,
|
||||
writer: Option<io::BufWriter<File>>,
|
||||
/// Sector ranges to read: (start_lba, sector_count)
|
||||
extents: Vec<(u32, u32)>,
|
||||
extent_idx: usize,
|
||||
sectors_remaining: u32,
|
||||
sector_buf: [u8; SECTOR_SIZE as usize],
|
||||
buf_pos: usize,
|
||||
buf_len: usize,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl IsoStream {
|
||||
/// Open an ISO file for reading. Parses UDF, scans titles, streams, labels.
|
||||
pub fn open(path: &str, title_index: Option<usize>, opts: &ScanOptions) -> io::Result<Self> {
|
||||
let mut reader = IsoSectorReader::open(path)?;
|
||||
let capacity = reader.capacity();
|
||||
|
||||
let disc = Disc::scan_image(&mut reader, capacity, opts)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||
|
||||
let idx = title_index.unwrap_or(0).min(disc.titles.len().saturating_sub(1));
|
||||
let disc_title = if disc.titles.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "no titles found in ISO image"));
|
||||
} else {
|
||||
disc.titles[idx].clone()
|
||||
};
|
||||
|
||||
let extents: Vec<(u32, u32)> = disc_title.extents.iter()
|
||||
.map(|e| (e.start_lba, e.sector_count))
|
||||
.collect();
|
||||
|
||||
let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0);
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title,
|
||||
disc: Some(disc),
|
||||
reader: Some(reader),
|
||||
writer: None,
|
||||
extents,
|
||||
extent_idx: 0,
|
||||
sectors_remaining,
|
||||
sector_buf: [0u8; SECTOR_SIZE as usize],
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an ISO file for writing. Receives raw sector data.
|
||||
pub fn create(path: &str) -> io::Result<Self> {
|
||||
let file = File::create(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
|
||||
let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title: DiscTitle::empty(),
|
||||
disc: None,
|
||||
reader: None,
|
||||
writer: Some(writer),
|
||||
extents: Vec::new(),
|
||||
extent_idx: 0,
|
||||
sectors_remaining: 0,
|
||||
sector_buf: [0u8; SECTOR_SIZE as usize],
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set metadata (for write mode).
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the full Disc (for listing all titles).
|
||||
pub fn disc(&self) -> Option<&Disc> { self.disc.as_ref() }
|
||||
|
||||
/// Read the next sector from the current extent.
|
||||
fn read_next_sector(&mut self) -> io::Result<bool> {
|
||||
let reader = match self.reader.as_mut() {
|
||||
Some(r) => r,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
if self.extent_idx >= self.extents.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let (start_lba, total) = self.extents[self.extent_idx];
|
||||
let offset = total - self.sectors_remaining;
|
||||
let lba = start_lba + offset;
|
||||
|
||||
reader.read_sectors(lba, 1, &mut self.sector_buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||
self.buf_pos = 0;
|
||||
self.buf_len = SECTOR_SIZE as usize;
|
||||
|
||||
self.sectors_remaining -= 1;
|
||||
if self.sectors_remaining == 0 {
|
||||
self.extent_idx += 1;
|
||||
if self.extent_idx < self.extents.len() {
|
||||
self.sectors_remaining = self.extents[self.extent_idx].1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for IsoStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(ref mut w) = self.writer {
|
||||
w.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for IsoStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if self.eof { return Ok(0); }
|
||||
|
||||
// Drain current sector buffer
|
||||
if self.buf_pos < self.buf_len {
|
||||
let n = (self.buf_len - self.buf_pos).min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.sector_buf[self.buf_pos..self.buf_pos + n]);
|
||||
self.buf_pos += n;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
// Read next sector
|
||||
if self.read_next_sector()? {
|
||||
let n = self.buf_len.min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.sector_buf[..n]);
|
||||
self.buf_pos = n;
|
||||
Ok(n)
|
||||
} else {
|
||||
self.eof = true;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use crate::sector::SectorReader;
|
||||
|
||||
#[test]
|
||||
fn iso_reader_read_sectors() {
|
||||
// Create a temp file with known sector data
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("libfreemkv_test_iso_sectors.iso");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
// Write 4 sectors of known data
|
||||
{
|
||||
let mut f = File::create(&path).unwrap();
|
||||
for sector_idx in 0u8..4 {
|
||||
let mut sector = [sector_idx; SECTOR_SIZE as usize];
|
||||
sector[0] = sector_idx;
|
||||
sector[2047] = sector_idx.wrapping_mul(0x37);
|
||||
f.write_all(§or).unwrap();
|
||||
}
|
||||
f.flush().unwrap();
|
||||
}
|
||||
|
||||
let mut reader = IsoSectorReader::open(path_str).unwrap();
|
||||
assert_eq!(reader.capacity(), 4);
|
||||
|
||||
// Read sector 0
|
||||
let mut buf = [0u8; SECTOR_SIZE as usize];
|
||||
let n = reader.read_sectors(0, 1, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE as usize);
|
||||
assert_eq!(buf[0], 0);
|
||||
assert_eq!(buf[2047], 0u8.wrapping_mul(0x37));
|
||||
|
||||
// Read sector 2
|
||||
let n = reader.read_sectors(2, 1, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE as usize);
|
||||
assert_eq!(buf[0], 2);
|
||||
assert_eq!(buf[1], 2); // filled with sector_idx
|
||||
assert_eq!(buf[2047], 2u8.wrapping_mul(0x37));
|
||||
|
||||
// Read 2 sectors at once (sectors 1 and 2)
|
||||
let mut buf2 = [0u8; SECTOR_SIZE as usize * 2];
|
||||
let n = reader.read_sectors(1, 2, &mut buf2).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE as usize * 2);
|
||||
assert_eq!(buf2[0], 1); // sector 1 first byte
|
||||
assert_eq!(buf2[SECTOR_SIZE as usize], 2); // sector 2 first byte
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_reader_capacity() {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("libfreemkv_test_iso_capacity.iso");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
// Write exactly 10 sectors
|
||||
{
|
||||
let mut f = File::create(&path).unwrap();
|
||||
let data = vec![0u8; SECTOR_SIZE as usize * 10];
|
||||
f.write_all(&data).unwrap();
|
||||
f.flush().unwrap();
|
||||
}
|
||||
|
||||
let reader = IsoSectorReader::open(path_str).unwrap();
|
||||
assert_eq!(reader.capacity(), 10);
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for IsoStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.writer.as_mut() {
|
||||
Some(w) => w.write(buf),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"iso:// opened for reading — cannot write")),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.writer.as_mut() {
|
||||
Some(w) => w.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,88 @@
|
||||
//! NullStream — discards all data. Write-only. For benchmarking.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use super::IOStream;
|
||||
use crate::disc::DiscTitle;
|
||||
|
||||
/// Null stream — accepts writes, discards data. For benchmarking rip speed.
|
||||
pub struct NullStream {
|
||||
disc_title: DiscTitle,
|
||||
bytes_written: u64,
|
||||
}
|
||||
|
||||
impl NullStream {
|
||||
pub fn new() -> Self {
|
||||
Self { disc_title: DiscTitle::empty(), bytes_written: 0 }
|
||||
}
|
||||
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bytes_written(&self) -> u64 { self.bytes_written }
|
||||
}
|
||||
|
||||
impl IOStream for NullStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
fn finish(&mut self) -> io::Result<()> { Ok(()) }
|
||||
}
|
||||
|
||||
impl Write for NullStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.bytes_written += buf.len() as u64;
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> { Ok(()) }
|
||||
}
|
||||
|
||||
impl Read for NullStream {
|
||||
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "null stream is write-only"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn null_counts_bytes() {
|
||||
let mut ns = NullStream::new();
|
||||
assert_eq!(ns.bytes_written(), 0);
|
||||
ns.write_all(&[0u8; 100]).unwrap();
|
||||
assert_eq!(ns.bytes_written(), 100);
|
||||
ns.write_all(&[1u8; 50]).unwrap();
|
||||
assert_eq!(ns.bytes_written(), 150);
|
||||
// Single write returns correct count
|
||||
let n = ns.write(&[0u8; 200]).unwrap();
|
||||
assert_eq!(n, 200);
|
||||
assert_eq!(ns.bytes_written(), 350);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_read_errors() {
|
||||
let mut ns = NullStream::new();
|
||||
let mut buf = [0u8; 10];
|
||||
let err = ns.read(&mut buf).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_finish_ok() {
|
||||
let mut ns = NullStream::new();
|
||||
ns.write_all(&[0u8; 1000]).unwrap();
|
||||
ns.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_implements_iostream() {
|
||||
let ns = NullStream::new();
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(ns);
|
||||
boxed.write_all(&[0u8; 50]).unwrap();
|
||||
let info = boxed.info();
|
||||
assert_eq!(info.streams.len(), 0);
|
||||
boxed.finish().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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,100 @@
|
||||
//! StdioStream — raw byte pipe via stdin/stdout. Format-agnostic.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use super::IOStream;
|
||||
use crate::disc::DiscTitle;
|
||||
|
||||
/// Stdio stream — reads from stdin, writes to stdout.
|
||||
///
|
||||
/// No headers, no metadata, no format opinions. Just bytes.
|
||||
/// The format is determined by whatever is on the other end.
|
||||
pub struct StdioStream {
|
||||
disc_title: DiscTitle,
|
||||
reader: Option<io::Stdin>,
|
||||
writer: Option<io::Stdout>,
|
||||
}
|
||||
|
||||
impl StdioStream {
|
||||
/// Create a stdio stream for reading (stdin).
|
||||
pub fn input() -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
reader: Some(io::stdin()),
|
||||
writer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a stdio stream for writing (stdout).
|
||||
pub fn output() -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
reader: None,
|
||||
writer: Some(io::stdout()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set metadata (for output — passed through from input side).
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for StdioStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(ref mut w) = self.writer {
|
||||
w.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for StdioStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self.reader {
|
||||
Some(ref mut r) => r.read(buf),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"stdio:// opened for output — cannot read")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for StdioStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.writer {
|
||||
Some(ref mut w) => w.write(buf),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"stdio:// opened for input — cannot write")),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.writer {
|
||||
Some(ref mut w) => w.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn stdio_output_write_errors_on_read() {
|
||||
let mut stream = StdioStream::output();
|
||||
let mut buf = [0u8; 10];
|
||||
let err = stream.read(&mut buf).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(err.to_string().contains("cannot read"), "got: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_input_read_errors_on_write() {
|
||||
let mut stream = StdioStream::input();
|
||||
let err = stream.write(&[0u8; 10]).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(err.to_string().contains("cannot write"), "got: {}", err);
|
||||
}
|
||||
}
|
||||
+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]
|
||||
|
||||
-248
@@ -1,248 +0,0 @@
|
||||
//! SCSI/MMC command interface.
|
||||
//!
|
||||
//! Platform backends:
|
||||
//! - Linux: SG_IO ioctl
|
||||
//! - macOS: IOKit SCSI passthrough (planned)
|
||||
//! - Windows: SPTI (planned)
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DataDirection {
|
||||
None,
|
||||
FromDevice,
|
||||
ToDevice,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScsiResult {
|
||||
pub status: u8,
|
||||
pub bytes_transferred: usize,
|
||||
pub sense: [u8; 32],
|
||||
}
|
||||
|
||||
/// Low-level SCSI transport — implemented per platform.
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
|
||||
// ─── Linux: SG_IO ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const SG_IO: libc::c_ulong = 0x2285;
|
||||
#[cfg(target_os = "linux")]
|
||||
const SG_DXFER_NONE: i32 = -1;
|
||||
#[cfg(target_os = "linux")]
|
||||
const SG_DXFER_TO_DEV: i32 = -2;
|
||||
#[cfg(target_os = "linux")]
|
||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
struct sg_io_hdr {
|
||||
interface_id: i32,
|
||||
dxfer_direction: i32,
|
||||
cmd_len: u8,
|
||||
mx_sb_len: u8,
|
||||
iovec_count: u16,
|
||||
dxfer_len: u32,
|
||||
dxferp: *mut u8,
|
||||
cmdp: *const u8,
|
||||
sbp: *mut u8,
|
||||
timeout: u32,
|
||||
flags: u32,
|
||||
pack_id: i32,
|
||||
usr_ptr: *mut libc::c_void,
|
||||
status: u8,
|
||||
masked_status: u8,
|
||||
msg_status: u8,
|
||||
sb_len_wr: u8,
|
||||
host_status: u16,
|
||||
driver_status: u16,
|
||||
resid: i32,
|
||||
duration: u32,
|
||||
info: u32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct SgIoTransport {
|
||||
fd: i32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl SgIoTransport {
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_bytes = device.as_os_str().as_bytes();
|
||||
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
|
||||
c_path.extend_from_slice(path_bytes);
|
||||
c_path.push(0);
|
||||
|
||||
let fd = unsafe { libc::open(c_path.as_ptr() as *const libc::c_char, libc::O_RDWR | libc::O_NONBLOCK) };
|
||||
if fd < 0 {
|
||||
return Err(Error::DeviceNotFound { path: device.display().to_string() });
|
||||
}
|
||||
Ok(SgIoTransport { fd })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd); }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl ScsiTransport for SgIoTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let mut sense = [0u8; 32];
|
||||
|
||||
let dxfer_direction = match direction {
|
||||
DataDirection::None => SG_DXFER_NONE,
|
||||
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
|
||||
DataDirection::ToDevice => SG_DXFER_TO_DEV,
|
||||
};
|
||||
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = dxfer_direction;
|
||||
hdr.cmd_len = cdb.len() as u8;
|
||||
hdr.mx_sb_len = sense.len() as u8;
|
||||
hdr.dxfer_len = data.len() as u32;
|
||||
hdr.dxferp = data.as_mut_ptr();
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
|
||||
let ret = unsafe {
|
||||
libc::ioctl(self.fd, SG_IO, &mut hdr as *mut sg_io_hdr)
|
||||
};
|
||||
|
||||
if ret < 0 {
|
||||
return Err(Error::IoError { source: std::io::Error::last_os_error() });
|
||||
}
|
||||
|
||||
let bytes_transferred = (data.len() as i32 - hdr.resid) as usize;
|
||||
|
||||
if hdr.status != 0 {
|
||||
let sense_key = if hdr.sb_len_wr > 2 { sense[2] & 0x0F } else { 0 };
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: hdr.status,
|
||||
bytes_transferred,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── macOS: IOKit (planned) ─────────────────────────────────────────────────
|
||||
|
||||
// TODO: IOKit MMC SCSI passthrough
|
||||
// Use IOSCSIPeripheralDeviceType05 (MMC device nub)
|
||||
// Send SCSITaskInterface commands via IOKit user client
|
||||
|
||||
// ─── Windows: SPTI (planned) ────────────────────────────────────────────────
|
||||
|
||||
// TODO: SCSI Pass Through Interface
|
||||
// Use CreateFile on \\.\CdRomN
|
||||
// Send IOCTL_SCSI_PASS_THROUGH_DIRECT
|
||||
|
||||
// ─── Platform-agnostic open ─────────────────────────────────────────────────
|
||||
|
||||
/// Open a SCSI transport for the given device path.
|
||||
pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{ Ok(Box::new(SgIoTransport::open(device)?)) }
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ Err(Error::DeviceNotFound { path: format!("{}: platform not yet supported (Linux only)", device.display()) }) }
|
||||
}
|
||||
|
||||
// ─── CDB builders (platform-agnostic) ───────────────────────────────────────
|
||||
|
||||
/// SCSI INQUIRY response.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InquiryResult {
|
||||
pub vendor_id: String,
|
||||
pub model: String,
|
||||
pub firmware: String,
|
||||
pub raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Send INQUIRY command and parse the standard response fields.
|
||||
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
|
||||
let cdb = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
|
||||
let mut buf = [0u8; 96];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
|
||||
let vendor = String::from_utf8_lossy(&buf[8..16]).trim().to_string();
|
||||
let model = String::from_utf8_lossy(&buf[16..32]).trim().to_string();
|
||||
let firmware = String::from_utf8_lossy(&buf[32..36]).trim().to_string();
|
||||
|
||||
Ok(InquiryResult {
|
||||
vendor_id: vendor,
|
||||
model,
|
||||
firmware,
|
||||
raw: buf.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send GET CONFIGURATION for feature 0x010C (Firmware Information).
|
||||
pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let cdb = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut buf = [0u8; 16];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
Ok(buf.to_vec())
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER (0x3C) CDB.
|
||||
pub fn build_read_buffer(mode: u8, buffer_id: u8, offset: u32, length: u32) -> [u8; 10] {
|
||||
[
|
||||
0x3C, mode, buffer_id,
|
||||
(offset >> 16) as u8, (offset >> 8) as u8, offset as u8,
|
||||
(length >> 16) as u8, (length >> 8) as u8, length as u8,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a SET CD SPEED (0xBB) CDB.
|
||||
pub fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
[
|
||||
0xBB, 0x00,
|
||||
(read_speed >> 8) as u8, read_speed as u8,
|
||||
0xFF, 0xFF,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a READ(10) CDB with the raw read flag (0x08).
|
||||
pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
|
||||
[
|
||||
0x28, 0x08,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Linux SCSI transport via SG_IO ioctl.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use super::{ScsiTransport, ScsiResult, DataDirection};
|
||||
use std::path::Path;
|
||||
|
||||
const SG_IO: u32 = 0x2285;
|
||||
const SG_DXFER_NONE: i32 = -1;
|
||||
const SG_DXFER_TO_DEV: i32 = -2;
|
||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
struct sg_io_hdr {
|
||||
interface_id: i32,
|
||||
dxfer_direction: i32,
|
||||
cmd_len: u8,
|
||||
mx_sb_len: u8,
|
||||
iovec_count: u16,
|
||||
dxfer_len: u32,
|
||||
dxferp: *mut u8,
|
||||
cmdp: *const u8,
|
||||
sbp: *mut u8,
|
||||
timeout: u32,
|
||||
flags: u32,
|
||||
pack_id: i32,
|
||||
usr_ptr: *mut libc::c_void,
|
||||
status: u8,
|
||||
masked_status: u8,
|
||||
msg_status: u8,
|
||||
sb_len_wr: u8,
|
||||
host_status: u16,
|
||||
driver_status: u16,
|
||||
resid: i32,
|
||||
duration: u32,
|
||||
info: u32,
|
||||
}
|
||||
|
||||
pub struct SgIoTransport {
|
||||
fd: i32,
|
||||
}
|
||||
|
||||
impl SgIoTransport {
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_bytes = device.as_os_str().as_bytes();
|
||||
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
|
||||
c_path.extend_from_slice(path_bytes);
|
||||
c_path.push(0);
|
||||
|
||||
let fd = unsafe {
|
||||
libc::open(c_path.as_ptr() as *const libc::c_char, libc::O_RDWR | libc::O_NONBLOCK)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(Error::DeviceNotFound { path: device.display().to_string() });
|
||||
}
|
||||
Ok(SgIoTransport { fd })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd); }
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for SgIoTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let mut sense = [0u8; 32];
|
||||
|
||||
let dxfer_direction = match direction {
|
||||
DataDirection::None => SG_DXFER_NONE,
|
||||
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
|
||||
DataDirection::ToDevice => SG_DXFER_TO_DEV,
|
||||
};
|
||||
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = dxfer_direction;
|
||||
hdr.cmd_len = cdb.len() as u8;
|
||||
hdr.mx_sb_len = sense.len() as u8;
|
||||
hdr.dxfer_len = data.len() as u32;
|
||||
hdr.dxferp = data.as_mut_ptr();
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
|
||||
let ret = unsafe {
|
||||
libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr)
|
||||
};
|
||||
|
||||
if ret < 0 {
|
||||
return Err(Error::IoError { source: std::io::Error::last_os_error() });
|
||||
}
|
||||
|
||||
let bytes_transferred = (data.len() as i32 - hdr.resid) as usize;
|
||||
|
||||
if hdr.status != 0 {
|
||||
let sense_key = if hdr.sb_len_wr > 2 { sense[2] & 0x0F } else { 0 };
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: hdr.status,
|
||||
bytes_transferred,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! macOS SCSI transport via IOKit SCSITaskDeviceInterface.
|
||||
//!
|
||||
//! Sends SCSI commands to optical drives through IOKit's SCSI Architecture
|
||||
//! Model family. Accepts BSD device paths like `/dev/disk2` or `/dev/rdisk2`.
|
||||
//!
|
||||
//! Requires exclusive access to the device — unmount the disc first:
|
||||
//! `diskutil unmountDisk /dev/disk2`
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use super::{DataDirection, ScsiResult, ScsiTransport};
|
||||
use std::path::Path;
|
||||
|
||||
// ── IOKit / CoreFoundation type aliases ─────────────────────────────────────
|
||||
|
||||
type CFMutableDictionaryRef = *mut std::ffi::c_void;
|
||||
type IOObject = u32;
|
||||
type IOReturn = i32;
|
||||
type MachPort = u32;
|
||||
|
||||
/// Opaque COM interface pointer — `*mut *mut VTable` (double-indirect).
|
||||
/// IOKit plugins use COM-style vtables: the pointer points to a pointer
|
||||
/// to the function table.
|
||||
type ComRef = *mut *mut std::ffi::c_void;
|
||||
|
||||
const K_IO_RETURN_SUCCESS: IOReturn = 0;
|
||||
|
||||
// SCSI data transfer directions (SCSITaskLib.h)
|
||||
const K_SCSI_DATA_TRANSFER_NO_DATA: u8 = 0;
|
||||
const K_SCSI_DATA_TRANSFER_FROM_TARGET: u8 = 1;
|
||||
const K_SCSI_DATA_TRANSFER_TO_TARGET: u8 = 2;
|
||||
|
||||
// SCSI task status values
|
||||
const K_SCSI_TASK_STATUS_GOOD: u8 = 0x00;
|
||||
|
||||
const K_MAX_CDB_SIZE: usize = 16;
|
||||
const K_SENSE_DATA_SIZE: usize = 32;
|
||||
|
||||
// ── IOKit plugin UUIDs ──────────────────────────────────────────────────────
|
||||
// From IOKit/scsi/SCSITaskLib.h
|
||||
|
||||
/// kIOMMCDeviceUserClientTypeID — plugin type for MMC (optical) devices.
|
||||
const K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
|
||||
0x97, 0xAB, 0xCF, 0x5C, 0x45, 0x71, 0x11, 0xD6,
|
||||
0xB6, 0xA0, 0x00, 0x30, 0x65, 0xA4, 0x7A, 0xEE,
|
||||
];
|
||||
|
||||
/// kIOCFPlugInInterfaceID — base IOCFPlugin interface.
|
||||
const K_IO_CFPLUGIN_INTERFACE_ID: [u8; 16] = [
|
||||
0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4,
|
||||
0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F,
|
||||
];
|
||||
|
||||
/// kIOSCSITaskDeviceInterfaceID — the interface we QueryInterface for.
|
||||
const K_IO_SCSI_TASK_DEVICE_INTERFACE_ID: [u8; 16] = [
|
||||
0x61, 0x3E, 0x48, 0xB0, 0x30, 0x01, 0x11, 0xD6,
|
||||
0xA4, 0xC0, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
|
||||
];
|
||||
|
||||
// ── Scatter/gather element ──────────────────────────────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
struct SCSITaskSGElement {
|
||||
address: u64,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
// ── External IOKit / CoreFoundation functions ───────────────────────────────
|
||||
|
||||
extern "C" {
|
||||
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
|
||||
fn IOBSDNameMatching(
|
||||
master: MachPort,
|
||||
options: u32,
|
||||
bsd_name: *const u8,
|
||||
) -> CFMutableDictionaryRef;
|
||||
fn IOServiceGetMatchingService(
|
||||
master: MachPort,
|
||||
matching: CFMutableDictionaryRef,
|
||||
) -> IOObject;
|
||||
fn IOObjectRelease(object: IOObject) -> IOReturn;
|
||||
fn IORegistryEntryGetParentEntry(
|
||||
entry: IOObject,
|
||||
plane: *const u8,
|
||||
parent: *mut IOObject,
|
||||
) -> IOReturn;
|
||||
fn IOObjectConformsTo(object: IOObject, class_name: *const u8) -> u8;
|
||||
fn IOCreatePlugInInterfaceForService(
|
||||
service: IOObject,
|
||||
plugin_type: *const [u8; 16],
|
||||
interface_type: *const [u8; 16],
|
||||
the_interface: *mut ComRef,
|
||||
the_score: *mut i32,
|
||||
) -> IOReturn;
|
||||
}
|
||||
|
||||
// ── COM vtable helpers ──────────────────────────────────────────────────────
|
||||
//
|
||||
// IOKit plugin interfaces use COM-style vtables. A ComRef is **vtable —
|
||||
// dereferencing once gives the vtable pointer, then index into it for
|
||||
// individual function pointers.
|
||||
//
|
||||
// All vtable indices verified against Apple open source:
|
||||
// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h
|
||||
|
||||
/// Read a function pointer from a COM vtable at the given index.
|
||||
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
|
||||
let vtable = *iface as *const *const std::ffi::c_void;
|
||||
let fn_ptr = *vtable.add(index);
|
||||
std::mem::transmute_copy(&fn_ptr)
|
||||
}
|
||||
|
||||
/// Call Release (vtable index 3) on any COM interface.
|
||||
fn com_release(iface: ComRef) {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> u32;
|
||||
unsafe {
|
||||
let f: Fn = vtable_fn(iface, 3);
|
||||
f(iface);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCSITaskDeviceInterface vtable ──────────────────────────────────────────
|
||||
//
|
||||
// Index Method
|
||||
// 0 _reserved
|
||||
// 1 QueryInterface
|
||||
// 2 AddRef
|
||||
// 3 Release
|
||||
// 4 IsExclusiveAccessAvailable
|
||||
// 5 AddCallbackDispatcherToRunLoop
|
||||
// 6 RemoveCallbackDispatcherFromRunLoop
|
||||
// 7 ObtainExclusiveAccess
|
||||
// 8 ReleaseExclusiveAccess
|
||||
// 9 CreateSCSITask
|
||||
|
||||
const VTIDX_OBTAIN_EXCLUSIVE: usize = 7;
|
||||
const VTIDX_RELEASE_EXCLUSIVE: usize = 8;
|
||||
const VTIDX_CREATE_TASK: usize = 9;
|
||||
|
||||
// ── SCSITaskInterface vtable ────────────────────────────────────────────────
|
||||
//
|
||||
// Index Method
|
||||
// 0 _reserved
|
||||
// 1 QueryInterface
|
||||
// 2 AddRef
|
||||
// 3 Release
|
||||
// 4 IsTaskActive
|
||||
// 5 SetTaskAttribute
|
||||
// 6 GetTaskAttribute
|
||||
// 7 GetTaskState
|
||||
// 8 SetCommandDescriptorBlock
|
||||
// 9 GetCommandDescriptorBlockSize
|
||||
// 10 GetCommandDescriptorBlock
|
||||
// 11 SetScatterGatherEntries
|
||||
// 12 SetTimeoutDuration
|
||||
// 13 GetTimeoutDuration
|
||||
// 14 SetTaskCompletionCallback
|
||||
// 15 ExecuteTaskSync
|
||||
// 16 ExecuteTaskAsync
|
||||
// 17 AbortTask
|
||||
// 18 GetSCSIServiceResponse
|
||||
// 19 GetTaskStatus
|
||||
// 20 GetRealizedDataTransferCount
|
||||
// 21 GetAutoSenseData
|
||||
|
||||
const VTIDX_SET_CDB: usize = 8;
|
||||
const VTIDX_SET_SG: usize = 11;
|
||||
const VTIDX_SET_TIMEOUT: usize = 12;
|
||||
const VTIDX_EXECUTE_SYNC: usize = 15;
|
||||
|
||||
// ── Transport implementation ────────────────────────────────────────────────
|
||||
|
||||
pub struct MacScsiTransport {
|
||||
device_iface: ComRef,
|
||||
exclusive: bool,
|
||||
}
|
||||
|
||||
impl MacScsiTransport {
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Strip /dev/ prefix to get BSD name (e.g. "disk2")
|
||||
let bsd_name = if let Some(rest) = dev_str.strip_prefix("/dev/r") {
|
||||
rest
|
||||
} else if let Some(rest) = dev_str.strip_prefix("/dev/") {
|
||||
rest
|
||||
} else {
|
||||
dev_str
|
||||
};
|
||||
|
||||
let service = find_scsi_service(bsd_name)?;
|
||||
|
||||
// Create IOKit plugin for the MMC device
|
||||
let mut plugin: ComRef = std::ptr::null_mut();
|
||||
let mut score: i32 = 0;
|
||||
let kr = unsafe {
|
||||
IOCreatePlugInInterfaceForService(
|
||||
service,
|
||||
&K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID,
|
||||
&K_IO_CFPLUGIN_INTERFACE_ID,
|
||||
&mut plugin,
|
||||
&mut score,
|
||||
)
|
||||
};
|
||||
unsafe { IOObjectRelease(service) };
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: IOKit plugin creation failed (0x{:08x})", dev_str, kr),
|
||||
});
|
||||
}
|
||||
|
||||
// QueryInterface for SCSITaskDeviceInterface
|
||||
let mut device_iface: ComRef = std::ptr::null_mut();
|
||||
let hr = unsafe {
|
||||
type QiFn = unsafe extern "C" fn(ComRef, *const [u8; 16], *mut ComRef) -> i32;
|
||||
let qi: QiFn = vtable_fn(plugin, 1);
|
||||
qi(plugin, &K_IO_SCSI_TASK_DEVICE_INTERFACE_ID, &mut device_iface)
|
||||
};
|
||||
com_release(plugin);
|
||||
|
||||
if hr != 0 || device_iface.is_null() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: SCSITaskDeviceInterface not available", dev_str),
|
||||
});
|
||||
}
|
||||
|
||||
// Obtain exclusive access
|
||||
let kr = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: Fn = vtable_fn(device_iface, VTIDX_OBTAIN_EXCLUSIVE);
|
||||
f(device_iface)
|
||||
};
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
com_release(device_iface);
|
||||
return Err(Error::DevicePermission {
|
||||
path: format!(
|
||||
"{}: exclusive access denied (0x{:08x}). Try: diskutil unmountDisk {}",
|
||||
dev_str, kr, dev_str
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MacScsiTransport {
|
||||
device_iface,
|
||||
exclusive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MacScsiTransport {
|
||||
fn drop(&mut self) {
|
||||
if self.exclusive {
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: Fn = vtable_fn(self.device_iface, VTIDX_RELEASE_EXCLUSIVE);
|
||||
f(self.device_iface);
|
||||
}
|
||||
}
|
||||
com_release(self.device_iface);
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for MacScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
// Create a SCSI task
|
||||
let task: ComRef = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> ComRef;
|
||||
let f: Fn = vtable_fn(self.device_iface, VTIDX_CREATE_TASK);
|
||||
f(self.device_iface)
|
||||
};
|
||||
if task.is_null() {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Set CDB
|
||||
let mut cdb_padded = [0u8; K_MAX_CDB_SIZE];
|
||||
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);
|
||||
cdb_padded[..cdb_len].copy_from_slice(&cdb[..cdb_len]);
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, *const u8, u8) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_CDB);
|
||||
f(task, cdb_padded.as_ptr(), cdb_len as u8);
|
||||
}
|
||||
|
||||
// Set scatter/gather and transfer direction
|
||||
let iokit_dir = match direction {
|
||||
DataDirection::None => K_SCSI_DATA_TRANSFER_NO_DATA,
|
||||
DataDirection::FromDevice => K_SCSI_DATA_TRANSFER_FROM_TARGET,
|
||||
DataDirection::ToDevice => K_SCSI_DATA_TRANSFER_TO_TARGET,
|
||||
};
|
||||
|
||||
if direction != DataDirection::None && !data.is_empty() {
|
||||
let sg = SCSITaskSGElement {
|
||||
address: data.as_mut_ptr() as u64,
|
||||
length: data.len() as u64,
|
||||
};
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(
|
||||
ComRef, *const SCSITaskSGElement, u8, u64, u8,
|
||||
) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
|
||||
f(task, &sg, 1, data.len() as u64, iokit_dir);
|
||||
}
|
||||
} else {
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(
|
||||
ComRef, *const SCSITaskSGElement, u8, u64, u8,
|
||||
) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
|
||||
f(task, std::ptr::null(), 0, 0, K_SCSI_DATA_TRANSFER_NO_DATA);
|
||||
}
|
||||
}
|
||||
|
||||
// Set timeout (IOKit SCSITask takes milliseconds)
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, u32);
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_TIMEOUT);
|
||||
f(task, timeout_ms);
|
||||
}
|
||||
|
||||
// Execute synchronously
|
||||
let mut sense = [0u8; K_SENSE_DATA_SIZE];
|
||||
let mut task_status: u8 = 0;
|
||||
let mut realized_count: u64 = 0;
|
||||
|
||||
let kr = unsafe {
|
||||
type Fn = unsafe extern "C" fn(
|
||||
ComRef, *mut u8, *mut u8, *mut u64,
|
||||
) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_EXECUTE_SYNC);
|
||||
f(task, sense.as_mut_ptr(), &mut task_status, &mut realized_count)
|
||||
};
|
||||
|
||||
com_release(task);
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if task_status != K_SCSI_TASK_STATUS_GOOD {
|
||||
let sense_key = if sense[2] != 0 { sense[2] & 0x0F } else { 0 };
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: task_status,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: task_status,
|
||||
bytes_transferred: realized_count as usize,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── IOKit service discovery ─────────────────────────────────────────────────
|
||||
|
||||
/// BSD name → IOKit service for the SCSI device.
|
||||
///
|
||||
/// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub.
|
||||
fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
|
||||
let mut master: MachPort = 0;
|
||||
let kr = unsafe { IOMasterPort(0, &mut master) };
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: IOMasterPort failed", bsd_name),
|
||||
});
|
||||
}
|
||||
|
||||
// IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name }
|
||||
let mut bsd_c = bsd_name.as_bytes().to_vec();
|
||||
bsd_c.push(0);
|
||||
let matching = unsafe { IOBSDNameMatching(master, 0, bsd_c.as_ptr()) };
|
||||
if matching.is_null() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: IOBSDNameMatching failed", bsd_name),
|
||||
});
|
||||
}
|
||||
|
||||
// Find the single IOMedia service (consumes the matching dict)
|
||||
let media = unsafe { IOServiceGetMatchingService(master, matching) };
|
||||
if media == 0 {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: no IOMedia found", bsd_name),
|
||||
});
|
||||
}
|
||||
|
||||
// Walk up the IOService plane to find the authoring device.
|
||||
// The chain is typically:
|
||||
// IOMedia → IOPartitionScheme → IOMedia → IOBlockStorageDriver
|
||||
// → IOSCSIPeripheralDeviceNub (this is what we want)
|
||||
//
|
||||
// We walk up until we find a service that IOCreatePlugInInterfaceForService
|
||||
// accepts with kIOMMCDeviceUserClientTypeID, or until we hit the root.
|
||||
let service = walk_to_authoring_device(media);
|
||||
unsafe { IOObjectRelease(media) };
|
||||
|
||||
service.ok_or_else(|| Error::DeviceNotFound {
|
||||
path: format!("{}: no SCSI authoring device in IORegistry", bsd_name),
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk up the IOService plane from an IOMedia to the SCSI authoring device.
|
||||
fn walk_to_authoring_device(start: IOObject) -> Option<IOObject> {
|
||||
let mut current = start;
|
||||
// Retain start so we can release uniformly in the loop
|
||||
// (IORegistryEntryGetParentEntry retains the parent for us)
|
||||
|
||||
// Target class names for authoring devices
|
||||
let target_classes: &[&[u8]] = &[
|
||||
b"IOSCSIPeripheralDeviceNub\0",
|
||||
b"IOBDBlockStorageDevice\0",
|
||||
b"IODVDBlockStorageDevice\0",
|
||||
b"IOCDBlockStorageDevice\0",
|
||||
b"IOBlockStorageDevice\0",
|
||||
];
|
||||
|
||||
// Walk up to 10 levels (more than enough)
|
||||
for _ in 0..10 {
|
||||
let mut parent: IOObject = 0;
|
||||
let kr = unsafe {
|
||||
IORegistryEntryGetParentEntry(current, b"IOService\0".as_ptr(), &mut parent)
|
||||
};
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
}
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if this parent matches any of our target classes
|
||||
for class in target_classes {
|
||||
if unsafe { IOObjectConformsTo(parent, class.as_ptr()) } != 0 {
|
||||
return Some(parent);
|
||||
}
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
}
|
||||
None
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
//! SCSI/MMC command interface.
|
||||
//!
|
||||
//! Platform backends are in separate files:
|
||||
//! - `linux.rs` — SG_IO ioctl
|
||||
//! - `macos.rs` — IOKit SCSITaskDeviceInterface
|
||||
//! - `windows.rs` — SPTI (SCSI Pass-Through Interface)
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
// ── SCSI opcodes (SPC-4, MMC-6) ────────────────────────────────────────────
|
||||
|
||||
pub const SCSI_INQUIRY: u8 = 0x12;
|
||||
pub const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
pub const SCSI_READ_10: u8 = 0x28;
|
||||
pub const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
pub const SCSI_READ_TOC: u8 = 0x43;
|
||||
pub const SCSI_GET_CONFIGURATION: u8 = 0x46;
|
||||
pub const SCSI_SET_CD_SPEED: u8 = 0xBB;
|
||||
pub const SCSI_SEND_KEY: u8 = 0xA3;
|
||||
pub const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
pub const SCSI_READ_12: u8 = 0xA8;
|
||||
pub const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
|
||||
|
||||
/// AACS key class for REPORT KEY / SEND KEY commands.
|
||||
pub const AACS_KEY_CLASS: u8 = 0x02;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DataDirection {
|
||||
None,
|
||||
FromDevice,
|
||||
ToDevice,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScsiResult {
|
||||
pub status: u8,
|
||||
pub bytes_transferred: usize,
|
||||
pub sense: [u8; 32],
|
||||
}
|
||||
|
||||
/// Low-level SCSI transport — one implementation per platform.
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
}
|
||||
|
||||
// ── Platform-agnostic open ──────────────────────────────────────────────────
|
||||
|
||||
/// Open a SCSI transport for the given device path.
|
||||
/// Selects the right backend for the current platform.
|
||||
pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{ Ok(Box::new(linux::SgIoTransport::open(device)?)) }
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{ Ok(Box::new(macos::MacScsiTransport::open(device)?)) }
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{ Ok(Box::new(windows::SptiTransport::open(device)?)) }
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{ Err(Error::DeviceNotFound { path: format!("{}: unsupported platform", device.display()) }) }
|
||||
}
|
||||
|
||||
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
|
||||
|
||||
/// SCSI INQUIRY response.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InquiryResult {
|
||||
pub vendor_id: String,
|
||||
pub model: String,
|
||||
pub firmware: String,
|
||||
pub raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Send INQUIRY and parse standard response fields.
|
||||
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
|
||||
let cdb = [SCSI_INQUIRY, 0x00, 0x00, 0x00, 0x60, 0x00];
|
||||
let mut buf = [0u8; 96];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
|
||||
Ok(InquiryResult {
|
||||
vendor_id: String::from_utf8_lossy(&buf[8..16]).trim().to_string(),
|
||||
model: String::from_utf8_lossy(&buf[16..32]).trim().to_string(),
|
||||
firmware: String::from_utf8_lossy(&buf[32..36]).trim().to_string(),
|
||||
raw: buf.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send GET CONFIGURATION for feature 0x010C (Firmware Information).
|
||||
pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let cdb = [SCSI_GET_CONFIGURATION, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut buf = [0u8; 16];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
Ok(buf.to_vec())
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER CDB.
|
||||
pub fn build_read_buffer(mode: u8, buffer_id: u8, offset: u32, length: u32) -> [u8; 10] {
|
||||
[
|
||||
SCSI_READ_BUFFER, mode, buffer_id,
|
||||
(offset >> 16) as u8, (offset >> 8) as u8, offset as u8,
|
||||
(length >> 16) as u8, (length >> 8) as u8, length as u8,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a SET CD SPEED CDB.
|
||||
pub fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
[
|
||||
SCSI_SET_CD_SPEED, 0x00,
|
||||
(read_speed >> 8) as u8, read_speed as u8,
|
||||
0xFF, 0xFF,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a READ(10) CDB with the raw read flag.
|
||||
pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
|
||||
[
|
||||
SCSI_READ_10, 0x08,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Windows SCSI transport via SPTI (SCSI Pass-Through Interface).
|
||||
//!
|
||||
//! Sends SCSI commands through DeviceIoControl with IOCTL_SCSI_PASS_THROUGH_DIRECT.
|
||||
//! Accepts device paths like `D:`, `E:`, `\\.\CdRom0`, or `\\.\D:`.
|
||||
//!
|
||||
//! Requires administrator privileges for raw SCSI access.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use super::{DataDirection, ScsiResult, ScsiTransport};
|
||||
use std::path::Path;
|
||||
|
||||
// ── Windows constants ──────────────────────────────────────────────────────
|
||||
|
||||
const IOCTL_SCSI_PASS_THROUGH_DIRECT: u32 = 0x4D014;
|
||||
const SCSI_IOCTL_DATA_OUT: u8 = 0;
|
||||
const SCSI_IOCTL_DATA_IN: u8 = 1;
|
||||
const SCSI_IOCTL_DATA_UNSPECIFIED: u8 = 2;
|
||||
|
||||
const GENERIC_READ: u32 = 0x80000000;
|
||||
const GENERIC_WRITE: u32 = 0x40000000;
|
||||
const FILE_SHARE_READ: u32 = 0x00000001;
|
||||
const FILE_SHARE_WRITE: u32 = 0x00000002;
|
||||
const OPEN_EXISTING: u32 = 3;
|
||||
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
|
||||
const INVALID_HANDLE_VALUE: isize = -1;
|
||||
|
||||
const K_MAX_CDB_SIZE: usize = 16;
|
||||
const K_SENSE_SIZE: usize = 32;
|
||||
|
||||
// ── SCSI_PASS_THROUGH_DIRECT structure ─────────────────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ScsiPassThroughDirect {
|
||||
Length: u16,
|
||||
ScsiStatus: u8,
|
||||
PathId: u8,
|
||||
TargetId: u8,
|
||||
Lun: u8,
|
||||
CdbLength: u8,
|
||||
SenseInfoLength: u8,
|
||||
DataIn: u8,
|
||||
_padding1: [u8; 3],
|
||||
DataTransferLength: u32,
|
||||
TimeOutValue: u32,
|
||||
DataBuffer: *mut u8,
|
||||
SenseInfoOffset: u32,
|
||||
Cdb: [u8; K_MAX_CDB_SIZE],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct SptwbDirect {
|
||||
spt: ScsiPassThroughDirect,
|
||||
sense: [u8; K_SENSE_SIZE],
|
||||
}
|
||||
|
||||
// ── Windows FFI ────────────────────────────────────────────────────────────
|
||||
|
||||
extern "system" {
|
||||
fn CreateFileW(
|
||||
lpFileName: *const u16,
|
||||
dwDesiredAccess: u32,
|
||||
dwShareMode: u32,
|
||||
lpSecurityAttributes: *const std::ffi::c_void,
|
||||
dwCreationDisposition: u32,
|
||||
dwFlagsAndAttributes: u32,
|
||||
hTemplateFile: *const std::ffi::c_void,
|
||||
) -> isize;
|
||||
|
||||
fn CloseHandle(hObject: isize) -> i32;
|
||||
|
||||
fn DeviceIoControl(
|
||||
hDevice: isize,
|
||||
dwIoControlCode: u32,
|
||||
lpInBuffer: *mut std::ffi::c_void,
|
||||
nInBufferSize: u32,
|
||||
lpOutBuffer: *mut std::ffi::c_void,
|
||||
nOutBufferSize: u32,
|
||||
lpBytesReturned: *mut u32,
|
||||
lpOverlapped: *mut std::ffi::c_void,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
// ── Transport implementation ───────────────────────────────────────────────
|
||||
|
||||
pub struct SptiTransport {
|
||||
handle: isize,
|
||||
}
|
||||
|
||||
/// Normalize a device path to Windows \\.\X: format.
|
||||
fn normalize_device_path(path: &str) -> String {
|
||||
if path.starts_with("\\\\.\\") { return path.to_string(); }
|
||||
let trimmed = path.trim_end_matches('\\');
|
||||
if trimmed.len() == 2 && trimmed.as_bytes()[1] == b':' {
|
||||
return format!("\\\\.\\{}", trimmed);
|
||||
}
|
||||
if path.to_lowercase().starts_with("cdrom") {
|
||||
return format!("\\\\.\\{}", path);
|
||||
}
|
||||
format!("\\\\.\\{}", path)
|
||||
}
|
||||
|
||||
impl SptiTransport {
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Normalize device path to \\.\X: format
|
||||
let win_path = normalize_device_path(dev_str);
|
||||
let wide: Vec<u16> = win_path.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: cannot open device (run as administrator)", dev_str),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SptiTransport { handle })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SptiTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe { CloseHandle(self.handle); }
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for SptiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let mut sptwb: SptwbDirect = unsafe { std::mem::zeroed() };
|
||||
|
||||
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);
|
||||
sptwb.spt.Length = std::mem::size_of::<ScsiPassThroughDirect>() as u16;
|
||||
sptwb.spt.CdbLength = cdb_len as u8;
|
||||
sptwb.spt.SenseInfoLength = K_SENSE_SIZE as u8;
|
||||
sptwb.spt.DataIn = match direction {
|
||||
DataDirection::None => SCSI_IOCTL_DATA_UNSPECIFIED,
|
||||
DataDirection::FromDevice => SCSI_IOCTL_DATA_IN,
|
||||
DataDirection::ToDevice => SCSI_IOCTL_DATA_OUT,
|
||||
};
|
||||
sptwb.spt.DataTransferLength = data.len() as u32;
|
||||
sptwb.spt.TimeOutValue = (timeout_ms / 1000).max(1) as u32;
|
||||
sptwb.spt.DataBuffer = if data.is_empty() { std::ptr::null_mut() } else { data.as_mut_ptr() };
|
||||
sptwb.spt.SenseInfoOffset = std::mem::offset_of!(SptwbDirect, sense) as u32;
|
||||
sptwb.spt.Cdb[..cdb_len].copy_from_slice(&cdb[..cdb_len]);
|
||||
|
||||
let buf_size = std::mem::size_of::<SptwbDirect>() as u32;
|
||||
let mut bytes_returned: u32 = 0;
|
||||
|
||||
let ok = unsafe {
|
||||
DeviceIoControl(
|
||||
self.handle,
|
||||
IOCTL_SCSI_PASS_THROUGH_DIRECT,
|
||||
&mut sptwb as *mut _ as *mut std::ffi::c_void,
|
||||
buf_size,
|
||||
&mut sptwb as *mut _ as *mut std::ffi::c_void,
|
||||
buf_size,
|
||||
&mut bytes_returned,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if sptwb.spt.ScsiStatus != 0 {
|
||||
let sense_key = if sptwb.sense[2] != 0 { sptwb.sense[2] & 0x0F } else { 0 };
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: sptwb.spt.ScsiStatus,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
let mut sense = [0u8; 32];
|
||||
sense.copy_from_slice(&sptwb.sense);
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: sptwb.spt.ScsiStatus,
|
||||
bytes_transferred: sptwb.spt.DataTransferLength as usize,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
-95
@@ -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,73 +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..=0xFFFE => DriveSpeed::BD12x,
|
||||
0xFFFF => DriveSpeed::Max,
|
||||
_ => DriveSpeed::Max,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
]
|
||||
}
|
||||
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
//! UDF filesystem reader — read files from Blu-ray discs.
|
||||
//!
|
||||
//! Blu-ray discs use UDF 2.50 with metadata partitions.
|
||||
//! The read sequence follows pointers through the disc structure:
|
||||
//!
|
||||
//! Sector 256 (AVDP)
|
||||
//! → VDS (Partition Descriptor + Logical Volume Descriptor)
|
||||
//! → Metadata Partition (virtual partition stored as a file)
|
||||
//! → File Set Descriptor (FSD)
|
||||
//! → Root Directory ICB
|
||||
//! → Directory data (File Identifier Descriptors)
|
||||
//! → BDMV/PLAYLIST/*.mpls, BDMV/CLIPINF/*.clpi
|
||||
//!
|
||||
//! Each step reads one or two sectors. No bulk reads needed.
|
||||
//!
|
||||
//! References:
|
||||
//! ECMA-167 (UDF base)
|
||||
//! UDF 2.50 (OSTA) — metadata partition extension
|
||||
//! BD-ROM Part 3 — Blu-ray filesystem profile
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
|
||||
/// A UDF filesystem parsed from disc.
|
||||
#[derive(Debug)]
|
||||
pub struct UdfFs {
|
||||
/// Root directory with full tree
|
||||
pub root: DirEntry,
|
||||
/// UDF Volume Identifier from Primary Volume Descriptor
|
||||
pub volume_id: String,
|
||||
/// Physical partition start (absolute sector)
|
||||
partition_start: u32,
|
||||
/// Metadata partition start (absolute sector)
|
||||
/// For UDF 2.50 discs, all file/directory references use metadata-relative LBAs
|
||||
metadata_start: u32,
|
||||
/// Metadata partition size in sectors
|
||||
metadata_sectors: u32,
|
||||
}
|
||||
|
||||
/// A directory or file entry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
/// LBA within the metadata partition (add metadata_start for absolute)
|
||||
pub meta_lba: u32,
|
||||
/// File size in bytes (from ICB info_length)
|
||||
pub size: u64,
|
||||
/// Child entries (if directory)
|
||||
pub entries: Vec<DirEntry>,
|
||||
}
|
||||
|
||||
impl UdfFs {
|
||||
/// Physical partition start sector.
|
||||
pub fn partition_start(&self) -> u32 { self.partition_start }
|
||||
|
||||
/// Metadata partition start sector.
|
||||
pub fn metadata_start(&self) -> u32 { self.metadata_start }
|
||||
|
||||
/// Find a directory by path (e.g. "/BDMV/PLAYLIST").
|
||||
/// Path matching is case-insensitive.
|
||||
pub fn find_dir(&self, path: &str) -> Option<&DirEntry> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
})?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
/// Read a file by path, returning its raw bytes.
|
||||
/// Reads sector by sector from disc — no buffering.
|
||||
/// Get the absolute starting LBA of a file on disc.
|
||||
/// Used by the rip pipeline to locate m2ts content sectors.
|
||||
pub fn file_start_lba(&self, 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;
|
||||
|
||||
// Navigate to parent directory
|
||||
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() }
|
||||
)?;
|
||||
}
|
||||
|
||||
// Find the file
|
||||
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() }
|
||||
)?;
|
||||
|
||||
// Read the file's ICB to get its data extent
|
||||
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),
|
||||
// NOT the metadata partition. ICBs are in metadata, data is in physical.
|
||||
let sector_count = ((data_len as u64 + 2047) / 2048) as u32;
|
||||
let mut data = vec![0u8; (sector_count as usize) * 2048];
|
||||
let abs_start = self.partition_start + data_lba;
|
||||
|
||||
for i in 0..sector_count {
|
||||
let offset = (i as usize) * 2048;
|
||||
read_sector(reader, abs_start + i, &mut data[offset..offset + 2048])?;
|
||||
}
|
||||
|
||||
data.truncate(entry.size as usize);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Collect all sector ranges needed for disc-info and AACS.
|
||||
///
|
||||
/// Returns a list of (start_lba, sector_count) ranges covering:
|
||||
/// - UDF structure (AVDP, VDS, metadata partition, directories)
|
||||
/// - BDMV/PLAYLIST/*.mpls, CLIPINF/*.clpi, JAR/*, META/*, *.bdmv
|
||||
/// - AACS/* (Content*.cer, Unit_Key_RO.inf, CPSUnit*.cci)
|
||||
///
|
||||
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
|
||||
/// MKB_RO.inf, ContentHash*, ContentRevocation*
|
||||
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
|
||||
// Covers AVDP, VDS, partition descriptor, metadata ICB, FSD, all directories
|
||||
let meta_end = self.metadata_start + self.metadata_sectors;
|
||||
ranges.push((0, meta_end));
|
||||
|
||||
// Walk tree, collect ranges for each metadata file
|
||||
self.collect_file_ranges(reader, &self.root, &mut ranges)?;
|
||||
|
||||
// Merge overlapping/adjacent ranges and sort
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
let merged = merge_ranges(&ranges);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn collect_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(reader, child, ranges)?;
|
||||
} else {
|
||||
// Include the ICB sector itself (in metadata partition)
|
||||
ranges.push((self.meta_to_abs(child.meta_lba), 1));
|
||||
|
||||
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
|
||||
if child.size > 50_000_000 {
|
||||
continue;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a metadata-partition-relative LBA to an absolute sector number.
|
||||
fn meta_to_abs(&self, meta_lba: u32) -> u32 {
|
||||
self.metadata_start + meta_lba
|
||||
}
|
||||
|
||||
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
|
||||
/// and return its first allocation extent: (data_lba, data_length).
|
||||
/// The data_lba is partition-relative.
|
||||
fn read_icb_extent(&self, 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(reader, self.meta_to_abs(meta_lba), &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
// 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)
|
||||
266 => {
|
||||
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
|
||||
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
|
||||
261 => {
|
||||
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
|
||||
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::DiscRead { sector: 0 }),
|
||||
};
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(extents)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the UDF filesystem from a Blu-ray disc.
|
||||
///
|
||||
/// Follows the UDF pointer chain:
|
||||
/// 1. AVDP (sector 256) → VDS location
|
||||
/// 2. VDS → Partition Descriptor (physical partition start)
|
||||
/// → Logical Volume Descriptor (FSD location + partition maps)
|
||||
/// 3. Metadata partition file → metadata content location
|
||||
/// 4. FSD → root directory ICB
|
||||
/// 5. Root directory → file tree
|
||||
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(reader, 256, &mut avdp)?;
|
||||
|
||||
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
||||
if tag_id != 2 {
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
|
||||
// (We use the VDS at sectors 32+, not the reserve copy at sector 32768+)
|
||||
|
||||
// Step 2: Read Volume Descriptor Sequence (sectors 32-37 typically)
|
||||
// Find Partition Descriptor (tag 5) and Logical Volume Descriptor (tag 6)
|
||||
let mut partition_start: u32 = 0;
|
||||
let mut num_partition_maps: u32 = 0;
|
||||
let mut lvd_sector: Option<u32> = None;
|
||||
let mut volume_id = String::new();
|
||||
let mut metadata_size_bytes: u32 = 0;
|
||||
|
||||
for i in 32..64 {
|
||||
let mut desc = [0u8; 2048];
|
||||
read_sector(reader, i, &mut desc)?;
|
||||
|
||||
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
|
||||
match desc_tag {
|
||||
// Primary Volume Descriptor — volume identifier at offset 24, 32-byte d-string
|
||||
1 => {
|
||||
volume_id = parse_dstring(&desc[24..56]);
|
||||
}
|
||||
// Partition Descriptor — tells us where the physical partition starts
|
||||
5 => {
|
||||
partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]);
|
||||
}
|
||||
// Logical Volume Descriptor — contains FSD location and partition maps
|
||||
6 => {
|
||||
num_partition_maps = u32::from_le_bytes([desc[268], desc[269], desc[270], desc[271]]);
|
||||
lvd_sector = Some(i);
|
||||
}
|
||||
// Terminating Descriptor — end of VDS
|
||||
8 => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
if partition_start == 0 {
|
||||
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::DiscRead { sector: 0 })?;
|
||||
|
||||
// Read LVD to check partition map type
|
||||
let mut lvd = [0u8; 2048];
|
||||
read_sector(reader, lvd_sec, &mut lvd)?;
|
||||
|
||||
// Parse partition maps starting at offset 440
|
||||
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
|
||||
let _pm1_type = lvd[440]; // First map type
|
||||
let pm1_len = lvd[441] as usize;
|
||||
|
||||
if pm1_len > 0 && 440 + pm1_len < 2048 {
|
||||
let pm2_type = lvd[440 + pm1_len]; // Second map type
|
||||
|
||||
if pm2_type == 2 {
|
||||
// Type 2 = metadata partition
|
||||
// The metadata file ICB is at physical partition lba 0
|
||||
// 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(reader, meta_file_lba, &mut meta_icb)?;
|
||||
|
||||
let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]);
|
||||
if meta_tag == 266 {
|
||||
// Extended File Entry — get allocation extent
|
||||
let l_ea = u32::from_le_bytes([meta_icb[208], meta_icb[209],
|
||||
meta_icb[210], meta_icb[211]]) as usize;
|
||||
let ad_off = 216 + l_ea;
|
||||
let ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
|
||||
meta_icb[ad_off + 2], meta_icb[ad_off + 3]]) & 0x3FFFFFFF;
|
||||
metadata_size_bytes = ad_len;
|
||||
let ad_pos = u32::from_le_bytes([meta_icb[ad_off + 4], meta_icb[ad_off + 5],
|
||||
meta_icb[ad_off + 6], meta_icb[ad_off + 7]]);
|
||||
// Metadata content starts at partition_start + ad_pos
|
||||
partition_start + ad_pos
|
||||
} else {
|
||||
// Fallback: no metadata partition, use physical partition directly
|
||||
partition_start
|
||||
}
|
||||
} else {
|
||||
partition_start
|
||||
}
|
||||
} else {
|
||||
partition_start
|
||||
}
|
||||
} else {
|
||||
// Single partition map — no metadata partition (older UDF)
|
||||
partition_start
|
||||
};
|
||||
|
||||
// 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(reader, metadata_start, &mut fsd)?;
|
||||
|
||||
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
|
||||
if fsd_tag != 256 {
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Root Directory ICB: long_ad at FSD offset 400
|
||||
// long_ad = extent_length(4) + extent_location: lba(4) + part_ref(2) + impl_use(6)
|
||||
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(reader, partition_start, metadata_start, root_lba, "", 0)?;
|
||||
|
||||
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
|
||||
|
||||
Ok(UdfFs {
|
||||
root,
|
||||
volume_id,
|
||||
partition_start,
|
||||
metadata_start,
|
||||
metadata_sectors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a UDF directory and its children (up to max_depth levels).
|
||||
///
|
||||
/// Each directory is an ICB (Extended File Entry) pointing to directory data
|
||||
/// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir
|
||||
/// and points to its ICB.
|
||||
fn read_directory(
|
||||
reader: &mut dyn SectorReader,
|
||||
part_start: u32,
|
||||
meta_start: u32,
|
||||
meta_lba: u32,
|
||||
name: &str,
|
||||
depth: u32,
|
||||
) -> Result<DirEntry> {
|
||||
// Read ICB for this directory
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(reader, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
// Get allocation extent: where the directory data lives
|
||||
let (ad_len, ad_pos) = match tag {
|
||||
266 => {
|
||||
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
|
||||
let ad_off = 216 + l_ea;
|
||||
let len = u32::from_le_bytes([icb[ad_off], icb[ad_off + 1],
|
||||
icb[ad_off + 2], icb[ad_off + 3]]) & 0x3FFFFFFF;
|
||||
let pos = u32::from_le_bytes([icb[ad_off + 4], icb[ad_off + 5],
|
||||
icb[ad_off + 6], icb[ad_off + 7]]);
|
||||
(len, pos)
|
||||
}
|
||||
261 => {
|
||||
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
|
||||
let ad_off = 176 + l_ea;
|
||||
let len = u32::from_le_bytes([icb[ad_off], icb[ad_off + 1],
|
||||
icb[ad_off + 2], icb[ad_off + 3]]) & 0x3FFFFFFF;
|
||||
let pos = u32::from_le_bytes([icb[ad_off + 4], icb[ad_off + 5],
|
||||
icb[ad_off + 6], icb[ad_off + 7]]);
|
||||
(len, pos)
|
||||
}
|
||||
_ => {
|
||||
return Ok(DirEntry {
|
||||
name: name.to_string(), is_dir: true, meta_lba, size: 0, entries: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Read directory data
|
||||
let dir_abs = meta_start + ad_pos;
|
||||
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(reader, dir_abs + i,
|
||||
&mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048])?;
|
||||
}
|
||||
|
||||
// Parse File Identifier Descriptors
|
||||
let mut entries = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos + 38 < dir_data.len().min(ad_len as usize) {
|
||||
let fid_tag = u16::from_le_bytes([dir_data[pos], dir_data[pos + 1]]);
|
||||
if fid_tag != 257 {
|
||||
break;
|
||||
}
|
||||
|
||||
let file_chars = dir_data[pos + 18];
|
||||
let l_fi = dir_data[pos + 19] as usize;
|
||||
|
||||
// FID ICB is a long_ad starting at offset 20:
|
||||
// [20:24] = extent_length
|
||||
// [24:28] = extent_location (LBA within metadata partition)
|
||||
// [28:30] = partition_reference_number
|
||||
// [30:36] = implementation_use
|
||||
let icb_lba = u32::from_le_bytes([dir_data[pos + 24], dir_data[pos + 25],
|
||||
dir_data[pos + 26], dir_data[pos + 27]]);
|
||||
let l_iu = u16::from_le_bytes([dir_data[pos + 36], dir_data[pos + 37]]) as usize;
|
||||
|
||||
let is_dir = (file_chars & 0x02) != 0;
|
||||
let is_parent = (file_chars & 0x08) != 0;
|
||||
|
||||
if !is_parent && l_fi > 0 {
|
||||
let name_start = pos + 38 + l_iu;
|
||||
let entry_name = parse_udf_name(&dir_data[name_start..name_start + l_fi]);
|
||||
|
||||
if !entry_name.is_empty() {
|
||||
// Read the ICB to get file size
|
||||
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(reader, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
|
||||
entries.push(subdir);
|
||||
} else {
|
||||
entries.push(DirEntry {
|
||||
name: entry_name,
|
||||
is_dir,
|
||||
meta_lba: icb_lba,
|
||||
size: file_size,
|
||||
entries: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance to next FID (4-byte aligned)
|
||||
let fid_len = ((38 + l_iu + l_fi + 3) & !3) as usize;
|
||||
pos += fid_len;
|
||||
}
|
||||
|
||||
Ok(DirEntry {
|
||||
name: name.to_string(),
|
||||
is_dir: true,
|
||||
meta_lba,
|
||||
size: ad_len as u64,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read file size (info_length) from an Extended File Entry ICB.
|
||||
fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result<u64> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(reader, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
match tag {
|
||||
// Both File Entry (261) and Extended File Entry (266) have
|
||||
// info_length as a u64 at offset 56
|
||||
261 | 266 => {
|
||||
Ok(u64::from_le_bytes([icb[56], icb[57], icb[58], icb[59],
|
||||
icb[60], icb[61], icb[62], icb[63]]))
|
||||
}
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a UDF filename from raw bytes.
|
||||
///
|
||||
/// UDF uses a compression ID as the first byte:
|
||||
/// 8 = 8-bit characters (ASCII)
|
||||
/// 16 = 16-bit big-endian Unicode (UTF-16BE)
|
||||
fn parse_udf_name(data: &[u8]) -> String {
|
||||
if data.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match data[0] {
|
||||
8 => {
|
||||
// 8-bit ASCII
|
||||
String::from_utf8_lossy(&data[1..]).trim().to_string()
|
||||
}
|
||||
16 => {
|
||||
// 16-bit big-endian Unicode
|
||||
let mut s = String::new();
|
||||
let chars = &data[1..];
|
||||
for i in (0..chars.len()).step_by(2) {
|
||||
if i + 1 < chars.len() {
|
||||
let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
|
||||
if let Some(ch) = char::from_u32(c as u32) {
|
||||
s.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
_ => String::from_utf8_lossy(&data[1..]).trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge overlapping or adjacent (start, count) ranges.
|
||||
fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
|
||||
if ranges.is_empty() { return Vec::new(); }
|
||||
let mut result = vec![ranges[0]];
|
||||
for &(start, count) in &ranges[1..] {
|
||||
let last = result.last_mut().unwrap();
|
||||
let last_end = last.0 + last.1;
|
||||
if start <= last_end + 1 {
|
||||
// Overlapping or adjacent — extend
|
||||
let new_end = (start + count).max(last_end);
|
||||
last.1 = new_end - last.0;
|
||||
} else {
|
||||
result.push((start, count));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse a UDF d-string (fixed-length field with length byte at the end).
|
||||
/// Used for Volume Identifier and other UDF descriptor strings.
|
||||
/// The first byte of content is a compression ID: 8 = ASCII, 16 = UTF-16BE.
|
||||
fn parse_dstring(data: &[u8]) -> String {
|
||||
if data.is_empty() { return String::new(); }
|
||||
let len = *data.last().unwrap() as usize;
|
||||
if len == 0 || len > data.len() { return String::new(); }
|
||||
let content = &data[..len];
|
||||
if content.is_empty() { return String::new(); }
|
||||
match content[0] {
|
||||
8 => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
|
||||
16 => {
|
||||
let mut s = String::new();
|
||||
let chars = &content[1..];
|
||||
for i in (0..chars.len()).step_by(2) {
|
||||
if i + 1 < chars.len() {
|
||||
let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
|
||||
if c != 0 {
|
||||
if let Some(ch) = char::from_u32(c as u32) { s.push(ch); }
|
||||
}
|
||||
}
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
_ => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single 2048-byte sector from the drive.
|
||||
/// Uses standard READ(10) — no unlock required.
|
||||
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
reader.read_sectors(lba, 1, buf)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Disc scanning pipeline tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::sector::SectorReader;
|
||||
use libfreemkv::{Disc, DiscTitle, ScanOptions};
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
|
||||
/// Minimal mock sector reader for disc scan tests.
|
||||
struct MockSectorReader {
|
||||
sectors: HashMap<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MockSectorReader {
|
||||
fn new() -> Self {
|
||||
Self { sectors: HashMap::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for MockSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let total = count as usize * SECTOR_SIZE;
|
||||
for i in 0..count as u32 {
|
||||
let offset = i as usize * SECTOR_SIZE;
|
||||
if let Some(data) = self.sectors.get(&(lba + i)) {
|
||||
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
|
||||
} else {
|
||||
buf[offset..offset + SECTOR_SIZE].fill(0);
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
|
||||
// ── scan_image tests ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn scan_image_empty_reader() {
|
||||
// An empty reader has no AVDP at sector 256 -> UDF parse fails
|
||||
let mut reader = MockSectorReader::new();
|
||||
let opts = ScanOptions::default();
|
||||
let result = Disc::scan_image(&mut reader, 0, &opts);
|
||||
assert!(result.is_err(), "scan_image should fail with empty reader (no AVDP)");
|
||||
}
|
||||
|
||||
// ── DiscTitle tests ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn disc_title_empty() {
|
||||
let t = DiscTitle::empty();
|
||||
assert_eq!(t.playlist, "");
|
||||
assert_eq!(t.playlist_id, 0);
|
||||
assert_eq!(t.duration_secs, 0.0);
|
||||
assert_eq!(t.size_bytes, 0);
|
||||
assert!(t.clips.is_empty());
|
||||
assert!(t.streams.is_empty());
|
||||
assert!(t.extents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_title_duration_display() {
|
||||
let mut t = DiscTitle::empty();
|
||||
|
||||
// 2 hours 15 minutes = 8100 seconds
|
||||
t.duration_secs = 8100.0;
|
||||
assert_eq!(t.duration_display(), "2h 15m");
|
||||
|
||||
// 0 hours 5 minutes = 300 seconds
|
||||
t.duration_secs = 300.0;
|
||||
assert_eq!(t.duration_display(), "0h 05m");
|
||||
|
||||
// Exact hour boundary
|
||||
t.duration_secs = 3600.0;
|
||||
assert_eq!(t.duration_display(), "1h 00m");
|
||||
|
||||
// Large value: 10 hours 30 minutes
|
||||
t.duration_secs = 37800.0;
|
||||
assert_eq!(t.duration_display(), "10h 30m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_title_size_gb() {
|
||||
let mut t = DiscTitle::empty();
|
||||
|
||||
// Exactly 1 GiB
|
||||
t.size_bytes = 1024 * 1024 * 1024;
|
||||
assert!((t.size_gb() - 1.0).abs() < 0.001);
|
||||
|
||||
// 50 GiB (typical BD)
|
||||
t.size_bytes = 50 * 1024 * 1024 * 1024;
|
||||
assert!((t.size_gb() - 50.0).abs() < 0.001);
|
||||
|
||||
// Zero
|
||||
t.size_bytes = 0;
|
||||
assert_eq!(t.size_gb(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_title_total_sectors() {
|
||||
let mut t = DiscTitle::empty();
|
||||
assert_eq!(t.total_sectors(), 0);
|
||||
|
||||
t.extents.push(libfreemkv::Extent { start_lba: 0, sector_count: 100 });
|
||||
t.extents.push(libfreemkv::Extent { start_lba: 200, sector_count: 50 });
|
||||
assert_eq!(t.total_sectors(), 150);
|
||||
}
|
||||
|
||||
// ── ScanOptions tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn scan_options_default() {
|
||||
let opts = ScanOptions::default();
|
||||
assert!(opts.keydb_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb() {
|
||||
let opts = ScanOptions::with_keydb("/tmp/KEYDB.cfg");
|
||||
assert_eq!(
|
||||
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
|
||||
"/tmp/KEYDB.cfg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb_pathbuf() {
|
||||
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
|
||||
let opts = ScanOptions::with_keydb(path.clone());
|
||||
assert_eq!(opts.keydb_path.unwrap(), path);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
//! Integration tests for the IOStream pipeline.
|
||||
|
||||
use std::io::{Cursor, Read, Write, Seek, SeekFrom};
|
||||
use libfreemkv::*;
|
||||
use libfreemkv::mux::meta::M2tsMeta;
|
||||
|
||||
fn sample_disc_title() -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: "Test Movie".into(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 7200.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: vec![
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011, codec: Codec::Hevc,
|
||||
resolution: "2160p".into(), frame_rate: "23.976".into(),
|
||||
hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: "Main".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100, codec: Codec::TrueHd,
|
||||
channels: "7.1".into(), language: "eng".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false,
|
||||
label: "English Atmos".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1101, codec: Codec::Ac3,
|
||||
channels: "5.1".into(), language: "fra".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false,
|
||||
label: "French".into(),
|
||||
}),
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200, codec: Codec::Pgs,
|
||||
language: "eng".into(), forced: false,
|
||||
}),
|
||||
],
|
||||
extents: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL parsing ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_url_disc() {
|
||||
let u = parse_url("disc://");
|
||||
assert_eq!(u.scheme, "disc");
|
||||
assert_eq!(u.path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_disc_device() {
|
||||
let u = parse_url("disc:///dev/sg4");
|
||||
assert_eq!(u.scheme, "disc");
|
||||
assert_eq!(u.path, "/dev/sg4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_mkv() {
|
||||
let u = parse_url("mkv://Dune.mkv");
|
||||
assert_eq!(u.scheme, "mkv");
|
||||
assert_eq!(u.path, "Dune.mkv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_network() {
|
||||
let u = parse_url("network://10.0.0.1:9000");
|
||||
assert_eq!(u.scheme, "network");
|
||||
assert_eq!(u.path, "10.0.0.1:9000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_bare_path_rejected() {
|
||||
let u = parse_url("Dune.mkv");
|
||||
assert_eq!(u.scheme, "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_null() {
|
||||
let u = parse_url("null://");
|
||||
assert_eq!(u.scheme, "null");
|
||||
assert_eq!(u.path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_m2ts_with_path() {
|
||||
let u = parse_url("m2ts:///tmp/Dune.m2ts");
|
||||
assert_eq!(u.scheme, "m2ts");
|
||||
assert_eq!(u.path, "/tmp/Dune.m2ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_m2ts_relative() {
|
||||
let u = parse_url("m2ts://Dune.m2ts");
|
||||
assert_eq!(u.scheme, "m2ts");
|
||||
assert_eq!(u.path, "Dune.m2ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_bare_path_errors() {
|
||||
let result = libfreemkv::open_input("Dune.mkv", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("not a valid stream URL"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_bare_path_errors() {
|
||||
let dt = sample_disc_title();
|
||||
let result = libfreemkv::open_output("Dune.mkv", &dt);
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("not a valid stream URL"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_m2ts_empty_path_errors() {
|
||||
let result = libfreemkv::open_input("m2ts://", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("requires a file path"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_null_input_errors() {
|
||||
let result = libfreemkv::open_input("null://", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("write-only"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_disc_errors() {
|
||||
let dt = sample_disc_title();
|
||||
let result = libfreemkv::open_output("disc://", &dt);
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("read-only"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_network_no_port_errors() {
|
||||
let result = libfreemkv::open_input("network://10.0.0.1", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||
assert!(msg.contains("missing port"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_stdio() {
|
||||
let u = parse_url("stdio://");
|
||||
assert_eq!(u.scheme, "stdio");
|
||||
assert_eq!(u.path, "");
|
||||
}
|
||||
|
||||
// ── M2TS metadata roundtrip ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_meta_roundtrip() {
|
||||
let dt = sample_disc_title();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
let restored = meta.to_title();
|
||||
|
||||
assert_eq!(restored.playlist, dt.playlist);
|
||||
assert_eq!(restored.duration_secs, dt.duration_secs);
|
||||
assert_eq!(restored.streams.len(), dt.streams.len());
|
||||
|
||||
// Check video
|
||||
if let Stream::Video(v) = &restored.streams[0] {
|
||||
assert_eq!(v.codec, Codec::Hevc);
|
||||
assert_eq!(v.resolution, "2160p");
|
||||
assert_eq!(v.label, "Main");
|
||||
} else { panic!("expected video"); }
|
||||
|
||||
// Check audio
|
||||
if let Stream::Audio(a) = &restored.streams[1] {
|
||||
assert_eq!(a.codec, Codec::TrueHd);
|
||||
assert_eq!(a.language, "eng");
|
||||
assert_eq!(a.label, "English Atmos");
|
||||
} else { panic!("expected audio"); }
|
||||
|
||||
// Check subtitle
|
||||
if let Stream::Subtitle(s) = &restored.streams[3] {
|
||||
assert_eq!(s.language, "eng");
|
||||
assert!(!s.forced);
|
||||
} else { panic!("expected subtitle"); }
|
||||
}
|
||||
|
||||
// ── M2TS header write + read ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_header_write_read() {
|
||||
let dt = sample_disc_title();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
|
||||
// Write header to buffer
|
||||
let mut buf = Vec::new();
|
||||
libfreemkv::mux::meta::write_header(&mut buf, &meta).unwrap();
|
||||
|
||||
// Verify magic
|
||||
assert_eq!(&buf[..4], b"FMKV");
|
||||
|
||||
// Verify 192-byte alignment
|
||||
assert_eq!(buf.len() % 192, 0);
|
||||
|
||||
// Read it back
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let read_back = libfreemkv::mux::meta::read_header(&mut cursor).unwrap().unwrap();
|
||||
assert_eq!(read_back.title, "Test Movie");
|
||||
assert_eq!(read_back.duration, 7200.0);
|
||||
assert_eq!(read_back.streams.len(), 4);
|
||||
|
||||
// Cursor should be at end of header (192-aligned)
|
||||
assert_eq!(cursor.position() as usize, buf.len());
|
||||
}
|
||||
|
||||
// ── M2tsStream write + read ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_stream_write_read() {
|
||||
let dt = sample_disc_title();
|
||||
|
||||
// Build fake BD-TS packets
|
||||
let mut ts_data = Vec::new();
|
||||
for i in 0..10u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = 0x10;
|
||||
pkt[6] = 0x11;
|
||||
pkt[7] = 0x10;
|
||||
pkt[8] = i;
|
||||
ts_data.extend_from_slice(&pkt);
|
||||
}
|
||||
|
||||
// Write through M2tsStream to a Cursor
|
||||
let output = Cursor::new(Vec::new());
|
||||
let mut stream = M2tsStream::new(output).meta(&dt);
|
||||
stream.write_all(&ts_data).unwrap();
|
||||
stream.finish().unwrap();
|
||||
|
||||
// M2tsStream consumed the cursor — we need the inner data.
|
||||
// For this test, write to a shared buffer instead.
|
||||
// Use a second pass: write header + data manually to verify read side.
|
||||
let mut encoded = Vec::new();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
libfreemkv::mux::meta::write_header(&mut encoded, &meta).unwrap();
|
||||
encoded.extend_from_slice(&ts_data);
|
||||
|
||||
// Read back
|
||||
let cursor = Cursor::new(encoded);
|
||||
let mut stream = M2tsStream::open(cursor).unwrap();
|
||||
let info = stream.info();
|
||||
assert_eq!(info.streams.len(), 4);
|
||||
assert_eq!(info.duration_secs, 7200.0);
|
||||
|
||||
// Read BD-TS data
|
||||
let mut read_buf = vec![0u8; 192 * 10];
|
||||
let mut total = 0;
|
||||
loop {
|
||||
match stream.read(&mut read_buf[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(total, 192 * 10);
|
||||
for i in 0..10u8 {
|
||||
assert_eq!(read_buf[i as usize * 192 + 4], 0x47);
|
||||
assert_eq!(read_buf[i as usize * 192 + 8], i);
|
||||
}
|
||||
}
|
||||
|
||||
// ── M2tsStream passthrough identity ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_passthrough_preserves_data() {
|
||||
let dt = sample_disc_title();
|
||||
|
||||
// Create original BD-TS data
|
||||
let mut original = Vec::new();
|
||||
for i in 0..100u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = (i % 3) << 4;
|
||||
pkt[6] = i;
|
||||
for j in 8..192 { pkt[j] = i.wrapping_add(j as u8); }
|
||||
original.extend_from_slice(&pkt);
|
||||
}
|
||||
|
||||
// Build encoded: header + original data
|
||||
let mut encoded = Vec::new();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
libfreemkv::mux::meta::write_header(&mut encoded, &meta).unwrap();
|
||||
encoded.extend_from_slice(&original);
|
||||
|
||||
// Read back through M2tsStream
|
||||
let cursor = Cursor::new(encoded);
|
||||
let mut stream = M2tsStream::open(cursor).unwrap();
|
||||
let mut decoded = vec![0u8; original.len()];
|
||||
let mut total = 0;
|
||||
loop {
|
||||
match stream.read(&mut decoded[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// BD-TS data must be byte-identical
|
||||
assert_eq!(total, original.len());
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
// ── IOStream trait ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_implements_iostream() {
|
||||
let dt = sample_disc_title();
|
||||
let output = Cursor::new(Vec::new());
|
||||
let stream = M2tsStream::new(output).meta(&dt);
|
||||
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(stream);
|
||||
let meta = boxed.info();
|
||||
assert_eq!(meta.streams.len(), 4);
|
||||
|
||||
let pkt = [0u8; 192];
|
||||
boxed.write_all(&pkt).unwrap();
|
||||
boxed.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m2ts_read_returns_error_on_write_stream() {
|
||||
let output = Cursor::new(Vec::new());
|
||||
let stream = M2tsStream::new(output);
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(stream);
|
||||
let mut buf = [0u8; 10];
|
||||
assert!(boxed.read(&mut buf).is_err());
|
||||
}
|
||||
|
||||
// ── DiscTitle::empty ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn disc_title_empty() {
|
||||
let dt = DiscTitle::empty();
|
||||
assert_eq!(dt.streams.len(), 0);
|
||||
assert_eq!(dt.duration_secs, 0.0);
|
||||
assert!(dt.playlist.is_empty());
|
||||
}
|
||||
|
||||
// ── Meta codec roundtrip ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn meta_codec_roundtrip() {
|
||||
// Test that all codec types survive from_title -> to_title
|
||||
let codecs_video = &[Codec::Hevc, Codec::H264, Codec::Vc1, Codec::Mpeg2];
|
||||
let codecs_audio = &[Codec::Ac3, Codec::Ac3Plus, Codec::TrueHd, Codec::DtsHdMa, Codec::DtsHdHr, Codec::Dts, Codec::Lpcm];
|
||||
let codecs_sub = &[Codec::Pgs];
|
||||
|
||||
let mut streams = Vec::new();
|
||||
for (i, &codec) in codecs_video.iter().enumerate() {
|
||||
streams.push(Stream::Video(VideoStream {
|
||||
pid: (0x1011 + i) as u16, codec,
|
||||
resolution: "1080p".into(), frame_rate: "23.976".into(),
|
||||
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: String::new(),
|
||||
}));
|
||||
}
|
||||
for (i, &codec) in codecs_audio.iter().enumerate() {
|
||||
streams.push(Stream::Audio(AudioStream {
|
||||
pid: (0x1100 + i) as u16, codec,
|
||||
channels: "5.1".into(), language: "eng".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false,
|
||||
label: String::new(),
|
||||
}));
|
||||
}
|
||||
for (i, &codec) in codecs_sub.iter().enumerate() {
|
||||
streams.push(Stream::Subtitle(SubtitleStream {
|
||||
pid: (0x1200 + i) as u16, codec,
|
||||
language: "eng".into(), forced: false,
|
||||
}));
|
||||
}
|
||||
|
||||
let dt = DiscTitle {
|
||||
playlist: "Codec Test".into(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 100.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams,
|
||||
extents: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
let restored = meta.to_title();
|
||||
|
||||
assert_eq!(restored.streams.len(), dt.streams.len());
|
||||
for (orig, rest) in dt.streams.iter().zip(restored.streams.iter()) {
|
||||
match (orig, rest) {
|
||||
(Stream::Video(o), Stream::Video(r)) => assert_eq!(o.codec, r.codec, "video codec mismatch"),
|
||||
(Stream::Audio(o), Stream::Audio(r)) => assert_eq!(o.codec, r.codec, "audio codec mismatch"),
|
||||
(Stream::Subtitle(o), Stream::Subtitle(r)) => assert_eq!(o.codec, r.codec, "subtitle codec mismatch"),
|
||||
_ => panic!("stream type mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_empty_streams() {
|
||||
let dt = DiscTitle {
|
||||
playlist: "Empty".into(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 0.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
assert_eq!(meta.streams.len(), 0);
|
||||
let restored = meta.to_title();
|
||||
assert_eq!(restored.streams.len(), 0);
|
||||
assert_eq!(restored.playlist, "Empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_all_stream_types() {
|
||||
let dt = DiscTitle {
|
||||
playlist: "Full".into(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 3600.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: vec![
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011, codec: Codec::Hevc,
|
||||
resolution: "2160p".into(), frame_rate: "23.976".into(),
|
||||
hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: "Primary".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100, codec: Codec::TrueHd,
|
||||
channels: "7.1".into(), language: "eng".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false,
|
||||
label: "Primary Audio".into(),
|
||||
}),
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200, codec: Codec::Pgs,
|
||||
language: "fra".into(), forced: true,
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1110, codec: Codec::Ac3,
|
||||
channels: "stereo".into(), language: "eng".into(),
|
||||
sample_rate: "48kHz".into(), secondary: true,
|
||||
label: "Commentary".into(),
|
||||
}),
|
||||
],
|
||||
extents: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
let restored = meta.to_title();
|
||||
|
||||
assert_eq!(restored.streams.len(), 4);
|
||||
|
||||
// Video preserved
|
||||
if let Stream::Video(v) = &restored.streams[0] {
|
||||
assert_eq!(v.codec, Codec::Hevc);
|
||||
assert_eq!(v.resolution, "2160p");
|
||||
assert_eq!(v.label, "Primary");
|
||||
assert!(!v.secondary);
|
||||
} else { panic!("expected video"); }
|
||||
|
||||
// Primary audio preserved
|
||||
if let Stream::Audio(a) = &restored.streams[1] {
|
||||
assert_eq!(a.codec, Codec::TrueHd);
|
||||
assert_eq!(a.channels, "7.1");
|
||||
assert!(!a.secondary);
|
||||
} else { panic!("expected audio"); }
|
||||
|
||||
// Subtitle preserved (forced flag)
|
||||
if let Stream::Subtitle(s) = &restored.streams[2] {
|
||||
assert_eq!(s.language, "fra");
|
||||
assert!(s.forced);
|
||||
} else { panic!("expected subtitle"); }
|
||||
|
||||
// Secondary audio preserved
|
||||
if let Stream::Audio(a) = &restored.streams[3] {
|
||||
assert_eq!(a.codec, Codec::Ac3);
|
||||
assert!(a.secondary);
|
||||
assert_eq!(a.label, "Commentary");
|
||||
} else { panic!("expected secondary audio"); }
|
||||
}
|
||||
|
||||
// ── MkvStream tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mkvstream_write_finish() {
|
||||
let output = Cursor::new(Vec::new());
|
||||
let dt = sample_disc_title();
|
||||
let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024);
|
||||
|
||||
// Write some fake BD-TS packets (they won't produce valid MKV frames
|
||||
// since there is no real codec data, but it should not panic)
|
||||
for i in 0..20u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
// PID 0x1011 (video)
|
||||
pkt[5] = 0x10;
|
||||
pkt[6] = 0x11;
|
||||
pkt[7] = 0x10;
|
||||
pkt[8] = i;
|
||||
stream.write_all(&pkt).unwrap();
|
||||
}
|
||||
|
||||
// finish should not panic even without valid codec data
|
||||
stream.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkvstream_meta_sets_title() {
|
||||
let output = Cursor::new(Vec::new());
|
||||
let dt = sample_disc_title();
|
||||
let stream = MkvStream::new(output).meta(&dt);
|
||||
|
||||
let info = stream.info();
|
||||
assert_eq!(info.playlist, "Test Movie");
|
||||
assert_eq!(info.duration_secs, 7200.0);
|
||||
assert_eq!(info.streams.len(), 4);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
//! UDF parser tests using a MockSectorReader.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::sector::SectorReader;
|
||||
use libfreemkv::udf;
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
|
||||
/// In-memory sector reader backed by a HashMap<LBA, sector_data>.
|
||||
/// Any LBA not in the map returns zeroed sectors.
|
||||
struct MockSectorReader {
|
||||
sectors: HashMap<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MockSectorReader {
|
||||
fn new() -> Self {
|
||||
Self { sectors: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Write a full 2048-byte sector at the given LBA.
|
||||
fn set_sector(&mut self, lba: u32, data: Vec<u8>) {
|
||||
assert_eq!(data.len(), SECTOR_SIZE, "sector data must be exactly 2048 bytes");
|
||||
self.sectors.insert(lba, data);
|
||||
}
|
||||
|
||||
/// Write partial data into a sector (rest is zeroed).
|
||||
fn set_sector_partial(&mut self, lba: u32, data: &[u8]) {
|
||||
let mut sector = vec![0u8; SECTOR_SIZE];
|
||||
let len = data.len().min(SECTOR_SIZE);
|
||||
sector[..len].copy_from_slice(&data[..len]);
|
||||
self.sectors.insert(lba, sector);
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for MockSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let total = count as usize * SECTOR_SIZE;
|
||||
assert!(buf.len() >= total, "buffer too small");
|
||||
for i in 0..count as u32 {
|
||||
let offset = i as usize * SECTOR_SIZE;
|
||||
if let Some(data) = self.sectors.get(&(lba + i)) {
|
||||
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
|
||||
} else {
|
||||
// Return zeros for unmapped sectors
|
||||
buf[offset..offset + SECTOR_SIZE].fill(0);
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: build raw sector data ──────────────────────────────────────────
|
||||
|
||||
/// Build an AVDP sector (tag_id=2) pointing to VDS at the given LBA.
|
||||
fn make_avdp_sector(vds_lba: u32) -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
// Tag ID = 2 (AVDP) at bytes [0..2]
|
||||
s[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||
// Main VDS extent location at bytes [16..20]
|
||||
s[16..20].copy_from_slice(&vds_lba.to_le_bytes());
|
||||
// Main VDS extent length at bytes [20..24] (arbitrary, say 6 sectors)
|
||||
s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a Primary Volume Descriptor (tag_id=1) with the given volume ID.
|
||||
fn make_pvd_sector(volume_id: &str) -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&1u16.to_le_bytes());
|
||||
// Volume ID at offset 24, d-string format:
|
||||
// compression_id(1 byte) + ASCII chars + length byte at position 55
|
||||
if !volume_id.is_empty() {
|
||||
let id_bytes = volume_id.as_bytes();
|
||||
s[24] = 8; // compression ID = ASCII
|
||||
let copy_len = id_bytes.len().min(30);
|
||||
s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]);
|
||||
// d-string length byte at end of 32-byte field (offset 55)
|
||||
s[55] = (1 + copy_len) as u8; // compression byte + chars
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a Partition Descriptor (tag_id=5) with partition_start.
|
||||
fn make_partition_desc(partition_start: u32) -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&5u16.to_le_bytes());
|
||||
// Partition start at bytes [188..192]
|
||||
s[188..192].copy_from_slice(&partition_start.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a Logical Volume Descriptor (tag_id=6) with a single partition map
|
||||
/// (no metadata partition -- simplest case).
|
||||
fn make_lvd_sector_simple() -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&6u16.to_le_bytes());
|
||||
// num_partition_maps at bytes [268..272] = 1 (no metadata partition)
|
||||
s[268..272].copy_from_slice(&1u32.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a Terminating Descriptor (tag_id=8).
|
||||
fn make_terminator() -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&8u16.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a File Set Descriptor (tag_id=256) with root ICB at the given meta LBA.
|
||||
fn make_fsd_sector(root_meta_lba: u32) -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&256u16.to_le_bytes());
|
||||
// Root directory ICB long_ad at offset 400:
|
||||
// [400..404] = extent_length (one sector = 2048)
|
||||
s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||
// [404..408] = extent_location (LBA within metadata partition)
|
||||
s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build an Extended File Entry (tag_id=266) for a directory with the given
|
||||
/// allocation extent pointing to directory data.
|
||||
fn make_dir_icb(data_meta_lba: u32, data_len: u32) -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&266u16.to_le_bytes());
|
||||
// info_length at offset 56 (u64)
|
||||
s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes());
|
||||
// L_EA at offset 208 = 0 (no extended attributes)
|
||||
s[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
// L_AD at offset 212 = 8 (one short allocation descriptor)
|
||||
s[212..216].copy_from_slice(&8u32.to_le_bytes());
|
||||
// Short allocation descriptor at offset 216:
|
||||
// [216..220] = extent_length (type=0 in top 2 bits, length in low 30 bits)
|
||||
s[216..220].copy_from_slice(&data_len.to_le_bytes());
|
||||
// [220..224] = extent_position (LBA within metadata partition)
|
||||
s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a File Identifier Descriptor (tag_id=257) for a named entry.
|
||||
/// Returns raw bytes (not padded to full sector).
|
||||
fn make_fid(name: &str, icb_meta_lba: u32, is_dir: bool) -> Vec<u8> {
|
||||
// FID name: compression_id(1) + ASCII bytes
|
||||
let mut name_bytes = vec![8u8]; // compression ID = 8 (ASCII)
|
||||
name_bytes.extend_from_slice(name.as_bytes());
|
||||
let l_fi = name_bytes.len() as u8;
|
||||
|
||||
let file_chars: u8 = if is_dir { 0x02 } else { 0x00 };
|
||||
|
||||
// Fixed header = 38 bytes, L_IU = 0
|
||||
let fid_len = ((38 + 0 + l_fi as usize + 3) & !3) as usize; // 4-byte aligned
|
||||
let mut fid = vec![0u8; fid_len];
|
||||
// tag_id = 257
|
||||
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
|
||||
// file_characteristics at offset 18
|
||||
fid[18] = file_chars;
|
||||
// L_FI at offset 19
|
||||
fid[19] = l_fi;
|
||||
// ICB long_ad at offset 20:
|
||||
// [20..24] = extent_length (2048)
|
||||
fid[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||
// [24..28] = extent_location (LBA)
|
||||
fid[24..28].copy_from_slice(&icb_meta_lba.to_le_bytes());
|
||||
// L_IU at offset 36 = 0
|
||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
|
||||
// Name starts at offset 38
|
||||
fid[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
|
||||
fid
|
||||
}
|
||||
|
||||
/// Build a parent FID (file_chars = 0x08, no name).
|
||||
fn make_parent_fid() -> Vec<u8> {
|
||||
let fid_len = ((38 + 0 + 0 + 3) & !3) as usize;
|
||||
let mut fid = vec![0u8; fid_len];
|
||||
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
|
||||
fid[18] = 0x08; // parent
|
||||
fid[19] = 0; // L_FI = 0
|
||||
fid
|
||||
}
|
||||
|
||||
/// Build a File Entry ICB (tag_id=261) for a file, with the given extent.
|
||||
fn make_file_icb(data_lba: u32, data_len: u32, file_size: u64) -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&261u16.to_le_bytes());
|
||||
// info_length at offset 56 (u64)
|
||||
s[56..64].copy_from_slice(&file_size.to_le_bytes());
|
||||
// L_EA at offset 168 = 0
|
||||
s[168..172].copy_from_slice(&0u32.to_le_bytes());
|
||||
// L_AD at offset 172 = 8
|
||||
s[172..176].copy_from_slice(&8u32.to_le_bytes());
|
||||
// Short allocation descriptor at offset 176:
|
||||
s[176..180].copy_from_slice(&data_len.to_le_bytes());
|
||||
s[180..184].copy_from_slice(&data_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mock_sector_reader_roundtrip() {
|
||||
let mut reader = MockSectorReader::new();
|
||||
|
||||
// Write a recognizable pattern to sector 100
|
||||
let mut data = vec![0u8; SECTOR_SIZE];
|
||||
data[0] = 0xAB;
|
||||
data[1] = 0xCD;
|
||||
data[2047] = 0xFF;
|
||||
reader.set_sector(100, data.clone());
|
||||
|
||||
// Read it back
|
||||
let mut buf = vec![0u8; SECTOR_SIZE];
|
||||
let n = reader.read_sectors(100, 1, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE);
|
||||
assert_eq!(buf[0], 0xAB);
|
||||
assert_eq!(buf[1], 0xCD);
|
||||
assert_eq!(buf[2047], 0xFF);
|
||||
|
||||
// Reading an unmapped sector returns zeros
|
||||
let mut buf2 = vec![0xFFu8; SECTOR_SIZE];
|
||||
let n2 = reader.read_sectors(999, 1, &mut buf2).unwrap();
|
||||
assert_eq!(n2, SECTOR_SIZE);
|
||||
assert_eq!(buf2[0], 0);
|
||||
assert_eq!(buf2[2047], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_sector_reader_multi_sector() {
|
||||
let mut reader = MockSectorReader::new();
|
||||
|
||||
let mut s10 = vec![0u8; SECTOR_SIZE];
|
||||
s10[0] = 10;
|
||||
reader.set_sector(10, s10);
|
||||
|
||||
let mut s11 = vec![0u8; SECTOR_SIZE];
|
||||
s11[0] = 11;
|
||||
reader.set_sector(11, s11);
|
||||
|
||||
// Read 2 consecutive sectors
|
||||
let mut buf = vec![0u8; SECTOR_SIZE * 2];
|
||||
let n = reader.read_sectors(10, 2, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE * 2);
|
||||
assert_eq!(buf[0], 10);
|
||||
assert_eq!(buf[SECTOR_SIZE], 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_no_avdp() {
|
||||
// Empty reader — sector 256 is all zeros, tag_id=0 != 2
|
||||
let mut reader = MockSectorReader::new();
|
||||
let result = udf::read_filesystem(&mut reader);
|
||||
assert!(result.is_err(), "should fail when no AVDP at sector 256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_bad_avdp_tag() {
|
||||
// Put a sector at 256 with wrong tag_id
|
||||
let mut reader = MockSectorReader::new();
|
||||
let mut bad = vec![0u8; SECTOR_SIZE];
|
||||
bad[0..2].copy_from_slice(&99u16.to_le_bytes()); // tag_id=99, not 2
|
||||
reader.set_sector(256, bad);
|
||||
|
||||
let result = udf::read_filesystem(&mut reader);
|
||||
assert!(result.is_err(), "should fail when AVDP tag_id is not 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_no_partition_descriptor() {
|
||||
// Valid AVDP but VDS has no Partition Descriptor (tag 5)
|
||||
let mut reader = MockSectorReader::new();
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
// Put a terminator immediately at sector 32
|
||||
reader.set_sector(32, make_terminator());
|
||||
|
||||
let result = udf::read_filesystem(&mut reader);
|
||||
assert!(result.is_err(), "should fail when no partition descriptor in VDS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_bad_fsd_tag() {
|
||||
// Valid AVDP + VDS with partition desc + LVD, but FSD at metadata_start has wrong tag
|
||||
let mut reader = MockSectorReader::new();
|
||||
let partition_start = 512;
|
||||
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
reader.set_sector(32, make_pvd_sector("TEST_DISC"));
|
||||
reader.set_sector(33, make_partition_desc(partition_start));
|
||||
reader.set_sector(34, make_lvd_sector_simple());
|
||||
reader.set_sector(35, make_terminator());
|
||||
// With 1 partition map, metadata_start = partition_start.
|
||||
// FSD should be at sector partition_start but we leave it as zeros (tag_id=0 != 256).
|
||||
|
||||
let result = udf::read_filesystem(&mut reader);
|
||||
assert!(result.is_err(), "should fail when FSD tag_id is not 256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_minimal_valid() {
|
||||
// Build a minimal valid UDF image: AVDP -> VDS -> FSD -> empty root dir
|
||||
let mut reader = MockSectorReader::new();
|
||||
let partition_start: u32 = 512;
|
||||
let root_icb_meta_lba: u32 = 1; // relative to metadata_start
|
||||
let root_data_meta_lba: u32 = 2;
|
||||
|
||||
// Sector 256: AVDP
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
|
||||
// VDS at sectors 32..35
|
||||
reader.set_sector(32, make_pvd_sector("MY_DISC"));
|
||||
reader.set_sector(33, make_partition_desc(partition_start));
|
||||
reader.set_sector(34, make_lvd_sector_simple());
|
||||
reader.set_sector(35, make_terminator());
|
||||
|
||||
// FSD at partition_start (since single partition map, metadata_start = partition_start)
|
||||
reader.set_sector(partition_start, make_fsd_sector(root_icb_meta_lba));
|
||||
|
||||
// Root directory ICB at metadata_start + root_icb_meta_lba
|
||||
// Points to directory data at root_data_meta_lba, length = 40 (one parent FID)
|
||||
let parent_fid = make_parent_fid();
|
||||
let dir_data_len = parent_fid.len() as u32;
|
||||
reader.set_sector(
|
||||
partition_start + root_icb_meta_lba,
|
||||
make_dir_icb(root_data_meta_lba, dir_data_len),
|
||||
);
|
||||
|
||||
// Root directory data: just a parent FID (empty directory)
|
||||
reader.set_sector_partial(partition_start + root_data_meta_lba, &parent_fid);
|
||||
|
||||
let fs = udf::read_filesystem(&mut reader).expect("should parse minimal UDF");
|
||||
assert_eq!(fs.volume_id, "MY_DISC");
|
||||
assert!(fs.root.is_dir);
|
||||
assert!(fs.root.entries.is_empty(), "root should have no children");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_with_subdirectory() {
|
||||
// Build a UDF image with root -> BDMV (dir) -> test.mpls (file)
|
||||
let mut reader = MockSectorReader::new();
|
||||
let partition_start: u32 = 512;
|
||||
|
||||
// Layout (all relative to partition_start which equals metadata_start):
|
||||
// meta LBA 0 = FSD
|
||||
// meta LBA 1 = root ICB
|
||||
// meta LBA 2 = root dir data
|
||||
// meta LBA 3 = BDMV ICB
|
||||
// meta LBA 4 = BDMV dir data
|
||||
// meta LBA 5 = test.mpls file ICB
|
||||
// meta LBA 10 = test.mpls file data (partition-relative)
|
||||
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
reader.set_sector(32, make_pvd_sector("DISC_WITH_BDMV"));
|
||||
reader.set_sector(33, make_partition_desc(partition_start));
|
||||
reader.set_sector(34, make_lvd_sector_simple());
|
||||
reader.set_sector(35, make_terminator());
|
||||
|
||||
// FSD -> root ICB at meta LBA 1
|
||||
reader.set_sector(partition_start + 0, make_fsd_sector(1));
|
||||
|
||||
// Root dir: parent FID + BDMV dir FID
|
||||
let parent_fid = make_parent_fid();
|
||||
let bdmv_fid = make_fid("BDMV", 3, true);
|
||||
let mut root_data = Vec::new();
|
||||
root_data.extend_from_slice(&parent_fid);
|
||||
root_data.extend_from_slice(&bdmv_fid);
|
||||
let root_data_len = root_data.len() as u32;
|
||||
|
||||
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data_len));
|
||||
reader.set_sector_partial(partition_start + 2, &root_data);
|
||||
|
||||
// BDMV dir: parent FID + test.mpls file FID
|
||||
let bdmv_parent = make_parent_fid();
|
||||
let mpls_fid = make_fid("test.mpls", 5, false);
|
||||
let mut bdmv_data = Vec::new();
|
||||
bdmv_data.extend_from_slice(&bdmv_parent);
|
||||
bdmv_data.extend_from_slice(&mpls_fid);
|
||||
let bdmv_data_len = bdmv_data.len() as u32;
|
||||
|
||||
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data_len));
|
||||
reader.set_sector_partial(partition_start + 4, &bdmv_data);
|
||||
|
||||
// test.mpls file ICB (File Entry tag 261)
|
||||
reader.set_sector(partition_start + 5, make_file_icb(10, 1024, 1024));
|
||||
|
||||
let fs = udf::read_filesystem(&mut reader).expect("should parse UDF with subdir");
|
||||
assert_eq!(fs.volume_id, "DISC_WITH_BDMV");
|
||||
|
||||
// Root should have one child: BDMV
|
||||
assert_eq!(fs.root.entries.len(), 1);
|
||||
let bdmv = &fs.root.entries[0];
|
||||
assert_eq!(bdmv.name, "BDMV");
|
||||
assert!(bdmv.is_dir);
|
||||
|
||||
// BDMV should have one child: test.mpls
|
||||
assert_eq!(bdmv.entries.len(), 1);
|
||||
let mpls = &bdmv.entries[0];
|
||||
assert_eq!(mpls.name, "test.mpls");
|
||||
assert!(!mpls.is_dir);
|
||||
assert_eq!(mpls.size, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_dir_case_insensitive() {
|
||||
// Build a UDF image with BDMV/PLAYLIST directories, then search with various cases
|
||||
let mut reader = MockSectorReader::new();
|
||||
let partition_start: u32 = 512;
|
||||
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
reader.set_sector(32, make_pvd_sector("CASE_TEST"));
|
||||
reader.set_sector(33, make_partition_desc(partition_start));
|
||||
reader.set_sector(34, make_lvd_sector_simple());
|
||||
reader.set_sector(35, make_terminator());
|
||||
|
||||
// FSD
|
||||
reader.set_sector(partition_start + 0, make_fsd_sector(1));
|
||||
|
||||
// Root -> BDMV
|
||||
let root_data = [make_parent_fid(), make_fid("BDMV", 3, true)].concat();
|
||||
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data.len() as u32));
|
||||
reader.set_sector_partial(partition_start + 2, &root_data);
|
||||
|
||||
// BDMV -> PLAYLIST
|
||||
let bdmv_data = [make_parent_fid(), make_fid("PLAYLIST", 5, true)].concat();
|
||||
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data.len() as u32));
|
||||
reader.set_sector_partial(partition_start + 4, &bdmv_data);
|
||||
|
||||
// PLAYLIST (empty)
|
||||
let playlist_data = make_parent_fid();
|
||||
reader.set_sector(partition_start + 5, make_dir_icb(6, playlist_data.len() as u32));
|
||||
reader.set_sector_partial(partition_start + 6, &playlist_data);
|
||||
|
||||
let fs = udf::read_filesystem(&mut reader).expect("should parse");
|
||||
|
||||
// Exact case
|
||||
assert!(fs.find_dir("BDMV/PLAYLIST").is_some());
|
||||
// Lower case
|
||||
assert!(fs.find_dir("bdmv/playlist").is_some());
|
||||
// Mixed case
|
||||
assert!(fs.find_dir("Bdmv/Playlist").is_some());
|
||||
assert!(fs.find_dir("BdMv/PlayList").is_some());
|
||||
// Leading/trailing slashes
|
||||
assert!(fs.find_dir("/BDMV/PLAYLIST/").is_some());
|
||||
// Nonexistent
|
||||
assert!(fs.find_dir("BDMV/STREAM").is_none());
|
||||
assert!(fs.find_dir("NONEXISTENT").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sector_reader_is_object_safe() {
|
||||
// Verify SectorReader can be used as a trait object
|
||||
let mut reader = MockSectorReader::new();
|
||||
reader.set_sector(0, vec![42u8; SECTOR_SIZE]);
|
||||
|
||||
let dyn_reader: &mut dyn SectorReader = &mut reader;
|
||||
let mut buf = vec![0u8; SECTOR_SIZE];
|
||||
let n = dyn_reader.read_sectors(0, 1, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE);
|
||||
assert_eq!(buf[0], 42);
|
||||
}
|
||||
Reference in New Issue
Block a user