mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a693ef965b | ||
|
|
0596307c19 | ||
|
|
18c6365e08 | ||
|
|
981f30b1b0 | ||
|
|
04086d73a0 | ||
|
|
20e404e59a | ||
|
|
1bea8eb650 | ||
|
|
0165f18fad | ||
|
|
ca3914d537 | ||
|
|
80fcffd190 | ||
|
|
7437af39c8 | ||
|
|
bcd6925170 | ||
|
|
fba1eb189c | ||
|
|
a328ad3cd8 | ||
|
|
9791e60c65 | ||
|
|
8b1ded84cd | ||
|
|
3e4febe7f5 | ||
|
|
aa7afc0c98 | ||
|
|
29f9e916d6 | ||
|
|
30d59063d5 | ||
|
|
6e958b0f71 | ||
|
|
911d260695 | ||
|
|
59d4eb8854 | ||
|
|
8820f7a460 | ||
|
|
8de99bb8e8 | ||
|
|
3e5e570573 | ||
|
|
f95894bba3 | ||
|
|
597f512eca | ||
|
|
8e907393a3 | ||
|
|
c47b9a9c3c | ||
|
|
d7b9d87075 | ||
|
|
e0f40583c4 | ||
|
|
d06a37d3dc | ||
|
|
add9a929a5 | ||
|
|
9bb1d02245 | ||
|
|
dc706e8153 | ||
|
|
0f18906ede | ||
|
|
bd7220c62b | ||
|
|
404c005e0b | ||
|
|
87342290c5 | ||
|
|
8bbf630d82 | ||
|
|
45dddc1810 | ||
|
|
cfa80cb881 | ||
|
|
b510ae9b29 | ||
|
|
9a1c3cc218 | ||
|
|
9ae7d6b38a | ||
|
|
d983985faa | ||
|
|
56fe26c9b8 | ||
|
|
7a83f3244d | ||
|
|
15f5b49fc3 | ||
|
|
ff6004a567 | ||
|
|
bd644d2f60 | ||
|
|
ab63950ef1 | ||
|
|
cab87ffdf4 | ||
|
|
ccb1fadedf | ||
|
|
610f1b62b6 | ||
|
|
f400e4fc72 | ||
|
|
5a9980cb6b | ||
|
|
2f48877992 | ||
|
|
7c59d063ee | ||
|
|
e4ffe7cc91 | ||
|
|
2e23e848df | ||
|
|
fbdfccdd83 | ||
|
|
2f52188f77 | ||
|
|
252e58cab0 | ||
|
|
3b96976a7a | ||
|
|
3385de5704 | ||
|
|
e9c907a2ca | ||
|
|
db1f6bc446 |
@@ -6,23 +6,33 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo test
|
||||
|
||||
check-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo check
|
||||
|
||||
check-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo check
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo test
|
||||
|
||||
publish:
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
env:
|
||||
|
||||
+183
@@ -1,5 +1,188 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.8 (2026-04-20)
|
||||
|
||||
### Disc verify
|
||||
- **verify::verify_title()** — sector-by-sector health check. Classifies sectors as Good/Slow/Recovered/Bad. Progress callback, chapter mapping, sector ranges.
|
||||
|
||||
## 0.11.7 (2026-04-19)
|
||||
|
||||
### TrueHD parser rewrite
|
||||
- **12-bit length mask** — access unit length is lower 12 bits of first 2 bytes, not full 16. Upper 4 bits are parity nibble. Wrong mask caused misaligned frame splits.
|
||||
- **AC-3 frame skipping** — BD-TS TrueHD PES contains interleaved AC-3 frames (same PID). Parser now detects AC-3 sync word (0x0B77) and skips those frames.
|
||||
- **Cross-PES buffering** — access units that span PES packet boundaries are correctly reassembled.
|
||||
- **Per-unit timestamps** — each access unit gets incrementing PTS (1/1200th second apart) instead of all units in one PES sharing the same timestamp.
|
||||
- **Major sync detection** — keyframe flag set when access unit contains MLP major sync (0xF8726FBA).
|
||||
- Result: zero TrueHD decode errors on UHD and BD (was ~19 per 30 seconds).
|
||||
|
||||
## 0.11.6 (2026-04-18)
|
||||
|
||||
### TrueHD fix (incomplete)
|
||||
- Initial attempt at TrueHD header stripping — wrong approach, superseded by 0.11.7.
|
||||
|
||||
## 0.11.5 (2026-04-18)
|
||||
|
||||
### MKV container fixes — Jellyfin/player compatibility
|
||||
- **Timestamp normalization** — MKV and M2TS output starts at 0.000s instead of raw disc PTS offset. Fixes playback failures in Jellyfin and other players.
|
||||
- **DefaultDuration** — correct frame rate written to MKV track header. Fixes wrong avg_frame_rate (was 293/12, now 24000/1001).
|
||||
- **HDR Colour metadata** — MatrixCoefficients, TransferCharacteristics, Primaries, Range written to MKV video track. Enables HDR tone mapping in players.
|
||||
- **DisplayWidth/DisplayHeight** — aspect ratio fields in MKV video track.
|
||||
- **Chapters (Blu-ray)** — accept mark_type 0 as chapter entry (was filtering to type 1 only, which no disc uses).
|
||||
- **Chapters (DVD)** — extract chapter timestamps from PGC program map + cell durations.
|
||||
- **Default disposition** — only first video and first audio track marked default. Fixes wrong auto-selection in players.
|
||||
|
||||
## 0.11.3 (2026-04-18)
|
||||
|
||||
### Unified versioning
|
||||
- All freemkv repos now share the same version number. No functional changes from 0.10.10.
|
||||
|
||||
## 0.10.10 (2026-04-18)
|
||||
|
||||
### Dual-layer disc fix
|
||||
- **UDF extent allocation** — use actual UDF allocation descriptors (`file_extents()`) instead of assuming m2ts files are contiguous from `file_start_lba`. Dual-layer UHD discs split large files across many extents (~1 GB each). The old single-extent assumption truncated rips at ~37% on affected discs.
|
||||
- **Read error propagation** — `fill_extents()` returns `io::Result<bool>` so SCSI read errors propagate to the caller instead of being silently treated as EOF.
|
||||
|
||||
## 0.10.9 (2026-04-17)
|
||||
|
||||
### Fast disc identification
|
||||
- **Disc::identify()** — reads UDF filesystem only (name, format, layers, encrypted). ~3s on USB vs 18s for full scan. No AACS handshake or playlist parsing.
|
||||
- **KEYDB path fix** — added `~/.config/freemkv/keydb.cfg` to search paths. Fixes silent rip hang when KEYDB exists but isn't found by `resolve_keydb()`.
|
||||
|
||||
## 0.10.8 (2026-04-17)
|
||||
|
||||
### Buffered UDF reads
|
||||
- **BufferedSectorReader** — prefetches batch sectors on single-sector reads. USB drives have ~500ms per SCSI command; this eliminates scan hangs.
|
||||
- **Metadata partition pre-read** — loads entire UDF metadata partition into memory after initial parse.
|
||||
- Scan time reduced from 10+ minutes to ~18 seconds on USB.
|
||||
|
||||
## 0.10.7 (2026-04-17)
|
||||
|
||||
### DiscStream::new()
|
||||
- Replaced open_drive(), open_iso(), from_reader() with single new() constructor
|
||||
- Stream accepts ContentFormat and sets up demuxer internally
|
||||
- Removed disc:// case from input() — callers use primitives directly
|
||||
|
||||
## 0.10.6 (2026-04-16)
|
||||
|
||||
### Docker compatibility
|
||||
- **Drive discovery** — removed sysfs check that blocked detection inside Docker containers. Device nodes are sufficient; INQUIRY command validates the device is an optical drive.
|
||||
|
||||
## 0.10.5 (2026-04-16)
|
||||
|
||||
### Audio parser buffering
|
||||
- **AC3** — buffer across PES boundaries with frame size from fscod/frmsizecod table. Eliminates all AC3 decode errors on BD and UHD.
|
||||
- **DTS** — buffer with core sync detection + frame size from header. DTS-HD extension frames handled correctly.
|
||||
- **TrueHD** — buffer with unit length field parsing. Incomplete units held for next PES.
|
||||
- All audio parsers now emit complete frames only. When PES boundaries align (normal case), buffering is a no-op.
|
||||
|
||||
## 0.10.4 (2026-04-16)
|
||||
|
||||
### CSS decryption — full key hierarchy
|
||||
- **Bus auth → disc key → title key** — complete CSS key chain. Bus authentication with CSSCryptKey challenge-response, disc key decryption using 31 player keys via READ DVD STRUCTURE, title key extraction via REPORT KEY format 0x04.
|
||||
- **CSS descramble cipher** — correct LFSR keystream generation with TAB5 for LFSR1 output and TAB4 for LFSR0 output. Per-sector key derivation from title key XOR sector seed.
|
||||
- **Stevenson plaintext attack** — expanded pattern set (padding, video, audio, nav pack headers), scans up to 50K scrambled sectors for ISO key recovery.
|
||||
- **Disc::copy() CSS decrypt** — sector-level decryption during disc→ISO copy produces clean ISOs with zero scramble flags.
|
||||
|
||||
### MPEG-2 PS demuxer fixes
|
||||
- **DVD PS path routes through codec parsers** — was bypassing parser.parse(), producing raw PES frames without codec_private extraction or keyframe detection.
|
||||
- **MPEG-2 sequence header extraction** — calculates exact header size including quantizer matrices (intra/non-intra flags), captures sequence extension from subsequent PES packets.
|
||||
- **TsDemuxer dynamic PID table** — Vec instead of fixed [i16; 8192] for DVD PIDs that may exceed 8192.
|
||||
|
||||
## 0.10.3 (2026-04-16)
|
||||
|
||||
### DVD CSS authentication
|
||||
- **CSS drive authentication** — full SCSI REPORT KEY / SEND KEY handshake with 6-round substitution-permutation cipher (CSSCryptKey). Brute-forces variant from 32 possibilities. Drive serves scrambled sectors after auth completes.
|
||||
- **CSS auth runs before scan** — chicken-and-egg fix: auth must happen before reading VOB sectors for title key cracking, not after.
|
||||
- **Remove debug output** — strip temporary eprintln from drive reads and CSS auth.
|
||||
|
||||
## 0.10.2 (2026-04-15)
|
||||
|
||||
### Fixes
|
||||
- **Disc::copy() batch overflow** — hardcoded 64-sector batch exceeded BU40N's 60-sector hardware limit, causing every read to fail and trigger 5×30s recovery sleep. Now accepts detected batch size from caller, defaults to 60.
|
||||
- **IFO PGC parsing** — playback time read from offset 0x04 (correct) instead of 0x02 (nr_programs). Cell BCD time at cell+4 not cell+0. DVD durations now correct.
|
||||
- **Demuxer flush at EOF** — TS and PS demuxers flushed when source reaches EOF, preventing loss of last PES frame. Applied to DiscStream and M2tsStream.
|
||||
- **DiscStream demuxer selection** — demuxer set by caller based on content_format (TS for Blu-ray, PS for DVD) instead of unconditionally creating TsDemuxer in from_reader()
|
||||
- **StdioStream FMKV header** — writes/reads metadata header for roundtrip compatibility through stdio pipes
|
||||
|
||||
## 0.10.1 (2026-04-15)
|
||||
|
||||
### Architecture: streams are PES, disc.copy() for sector dumps
|
||||
- **One stream per format, bidirectional PES** — MkvStream, M2tsStream, NetworkStream, StdioStream, NullStream each handle read and write
|
||||
- **IsoStream merged into DiscStream** — one type for physical drives and ISO files, different SectorReader
|
||||
- **Disc::copy()** — raw sector dump for disc→ISO, not a stream operation
|
||||
- **IOStream deleted** — no more byte-level Read/Write on streams
|
||||
- **ContentReader/OpenDisc deleted** — replaced by DiscStream + PES pipeline
|
||||
- **CountingStream** — wrapper for progress tracking, no state in streams
|
||||
|
||||
### Error codes only — zero English in library
|
||||
- All `io::Error::new(kind, "english")` replaced with `Error` enum variants
|
||||
- New error variants: StreamReadOnly, StreamWriteOnly, StreamUrlInvalid, MkvInvalid, NoStreams, etc.
|
||||
- `From<Error> for io::Error` — clean conversion at system boundaries
|
||||
- Removed unused error variants: WriteError, ProfileNotFound, NotUnlocked, NotCalibrated, ScsiTimeout, etc.
|
||||
|
||||
### Deleted dead code
|
||||
- `mkvout.rs`, `pesout.rs`, `isowriter.rs` — merged into parent stream types
|
||||
- `lookahead.rs` usage in MkvStream — replaced by PES direct write
|
||||
- ContentReader, OpenDisc, open_title() — replaced by PES pipeline
|
||||
- `open_input()`, `open_output()` — replaced by `input()`, `output()`
|
||||
|
||||
## 0.10.0 (2026-04-15)
|
||||
|
||||
### PES pipeline
|
||||
- **Unified Stream trait** — `read()` returns PES frames, `write()` accepts them. One trait for all streams.
|
||||
- **All streams produce/consume PES frames** — DiscStream, IsoStream, MkvStream, M2tsStream, NetworkStream, StdioStream, NullStream
|
||||
- **DVD PS demux** — MPEG-2 Program Stream demuxer produces PES frames
|
||||
- **MKV input stream** — MKV demux produces PES frames
|
||||
- **Network/stdio PES** — PES serialization over TCP and pipes
|
||||
- **FileSectorReader** — ISO files implement SectorReader for unified disc/ISO handling
|
||||
|
||||
### PES pipeline audit (20 fixes)
|
||||
- PES serialize: track/length validation, OOM cap (256 MB), stuffing compliance
|
||||
- TsDemuxer: AF length validation, find_start_code verified
|
||||
- PTS: marker bit validation, ns→90kHz saturating_mul, round-to-nearest
|
||||
- AC3/DTS: debug_assert promoted to runtime check
|
||||
- MKV: block_vint 3-4 byte support, track bounds check
|
||||
- FMKV: JSON 10 MB cap, PAT section_len underflow guard
|
||||
|
||||
### codec_privates refactor
|
||||
- **codec_privates on DiscTitle** — no separate parameter passing, no `_with_X` method variants
|
||||
- **Streams-not-files** — MkvStream and M2tsStream take `impl Read`, not `File`/`Seek`
|
||||
- **M2TS roundtrip fix** — TsMuxer Annex B conversion + codec_private in FMKV header
|
||||
- **MKV remux fix** — MkvStream returns codec_privates from EBML header
|
||||
- **Network codec_private fix** — FMKV header carries base64 codec_privates
|
||||
|
||||
### Cleanup
|
||||
- Remove Seek/File dependencies from stream interfaces
|
||||
- Remove eprintln from library code
|
||||
- Fix all clippy warnings
|
||||
- 342 tests pass
|
||||
|
||||
## 0.9.0 (2026-04-14)
|
||||
|
||||
### Drive recovery + decrypt architecture
|
||||
- **Drive::read()** — single read method with built-in error recovery (min speed → reset → retry)
|
||||
- **Decrypt in streams** — streams handle their own decryption via `decrypt_sectors()`. Pipeline just moves bytes.
|
||||
- **keys() on IOStream** — streams report their own decrypt keys
|
||||
- **InputOptions** — `--raw` wired through to streams, skips decrypt only
|
||||
- **decrypt_sectors returns Result** — fail instead of silent corruption
|
||||
- **Handshake fix** — no longer returns fake success on failure
|
||||
- **Drive::read_capacity()** — for raw sector dump (disc→ISO)
|
||||
- **Reset on open** — SgIoTransport resets device on every open
|
||||
- **Simplified DiscStream** — removed on_error/on_success/Recovery enum
|
||||
|
||||
### Platform
|
||||
- **Rust 1.86 MSRV** pinned in Cargo.toml and CI
|
||||
- **macOS build fix** — MacScsiTransport marked Send
|
||||
- **is_multiple_of** — replaced nightly API with stable equivalent
|
||||
|
||||
### API changes
|
||||
- **Drive object** — typed DriveSession API
|
||||
- **Typed StreamUrl** — URL parsing returns enum, not strings
|
||||
- **DriveStatus API** — reset(), wait_ready with fallback
|
||||
- **Granular SCSI queries** — individual methods on DriveSession for capture
|
||||
- **Profile module public** — for external tools (bdemu)
|
||||
- **Tray lock/unlock** — exposed on Drive
|
||||
|
||||
## 0.8.0 (2026-04-11)
|
||||
|
||||
### DVD support
|
||||
|
||||
+6
-1
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.8.2"
|
||||
version = "0.11.8"
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
@@ -30,3 +31,7 @@ libc = "0.2"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[[bench]]
|
||||
name = "sgio_read"
|
||||
harness = false
|
||||
|
||||
|
||||
+8
-4
@@ -1,6 +1,6 @@
|
||||
# libfreemkv — Feature List
|
||||
|
||||
## v0.7.1 (current)
|
||||
## v0.10.10 (current)
|
||||
|
||||
### Done
|
||||
- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject
|
||||
@@ -11,13 +11,18 @@
|
||||
- [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] DVD IFO parser (VMG, VTS, PGC chains, cell addresses)
|
||||
- [x] DVD CSS decryption (bus auth, disc key via player keys, title key, sector descramble)
|
||||
- [x] CSS Stevenson plaintext attack for ISO key recovery
|
||||
- [x] MPEG-2 PS demuxer (DVD Program Stream with PES extraction)
|
||||
- [x] MPEG-2 video codec parser (sequence header, quantizer matrices, keyframe detection)
|
||||
- [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] MKV muxer: 14 codec parsers (H.264, HEVC, MPEG-2, AC-3, DTS, TrueHD, PGS, DVD Sub, VC-1, LPCM, +4 more)
|
||||
- [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] PES pipeline with unified Stream trait (any source → any dest)
|
||||
- [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
|
||||
@@ -25,6 +30,5 @@
|
||||
### 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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# libfreemkv
|
||||
|
||||
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.
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS 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.
|
||||
|
||||
@@ -18,33 +18,37 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.6"
|
||||
libfreemkv = "0.11"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use libfreemkv::{DriveSession, Disc, ScanOptions};
|
||||
use libfreemkv::{Drive, Disc, ScanOptions};
|
||||
use std::path::Path;
|
||||
|
||||
// Open drive — profiles are bundled, auto-identified
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
session.wait_ready()?; // wait for disc
|
||||
session.init()?; // unlock + firmware upload
|
||||
session.probe_disc()?; // probe disc surface for optimal speeds
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?; // wait for disc
|
||||
drive.init()?; // unlock + firmware upload
|
||||
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||
|
||||
for title in &disc.titles {
|
||||
println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
}
|
||||
|
||||
// Read content (decrypted transparently if AACS keys available)
|
||||
let mut reader = disc.open_title(&mut session, 0)?;
|
||||
while let Some(unit) = reader.read_unit()? {
|
||||
// 6144 bytes of content per aligned unit
|
||||
// Stream pipeline — read PES frames from any source, write to any output
|
||||
let opts = libfreemkv::InputOptions::default();
|
||||
let mut input = libfreemkv::input("iso://Disc.iso", &opts)?;
|
||||
let title = input.info().clone();
|
||||
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||
while let Ok(Some(frame)) = input.read() {
|
||||
output.write(&frame)?;
|
||||
}
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
## What It Does
|
||||
@@ -63,32 +67,44 @@ while let Some(unit) = reader.read_unit()? {
|
||||
| Stream | Input | Output | Transport |
|
||||
|--------|-------|--------|-----------|
|
||||
| DiscStream | Yes | -- | Optical drive via SCSI |
|
||||
| IsoStream | Yes | -- | Blu-ray ISO image file |
|
||||
| IsoStream | Yes | Yes | Blu-ray ISO image file |
|
||||
| MkvStream | Yes | Yes | Matroska container |
|
||||
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
||||
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
|
||||
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
|
||||
|
||||
All streams implement the `IOStream` trait. `open_input()` and `open_output()` resolve URL strings to stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
Streams implement `IOStream` (byte-level) and `pes::Stream` (frame-level). `input()` / `output()` resolve URL strings to PES stream instances. `open_input()` / `open_output()` resolve to byte-level IOStream instances. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
|
||||
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
DriveSession — open any drive, identify, init (optional), read sectors
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
Drive — open, identify, init, unlock, read (with recovery)
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||
└── PlatformDriver — MediaTek (supported), Renesas (planned)
|
||||
|
||||
Disc — scan titles, streams, AACS state
|
||||
Disc — scan titles, streams, AACS/CSS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
├── MPLS parser — playlists → titles + clips + streams
|
||||
├── CLPI parser — clip info → EP map → sector extents
|
||||
├── IFO parser — DVD title sets, PGC chains, cell addresses
|
||||
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||
├── AACS — key resolution + content decryption
|
||||
├── CSS — DVD CSS cipher (table-driven, no keys needed)
|
||||
└── KEYDB — download + verify + save
|
||||
|
||||
Streams — unified PES pipeline
|
||||
├── pes::Stream — read()/write() PES frames
|
||||
├── DiscStream — sectors → decrypt → TS demux → PES
|
||||
├── IsoStream — ISO file → decrypt → TS demux → PES
|
||||
├── MkvStream — MKV mux/demux
|
||||
├── M2tsStream — BD transport stream
|
||||
├── NetworkStream — TCP with FMKV metadata header
|
||||
├── StdioStream — stdin/stdout pipe
|
||||
└── NullStream — discard sink
|
||||
```
|
||||
|
||||
See [docs/](docs/) for detailed technical documentation on each module.
|
||||
@@ -118,7 +134,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
||||
|
||||
## Contributing
|
||||
|
||||
Run `freemkv drive-info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Mimics ISO dump exactly — read + write + progress
|
||||
use libfreemkv::Drive;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let device = std::env::args()
|
||||
.skip(1)
|
||||
.find(|a| !a.starts_with('-'))
|
||||
.unwrap_or_else(|| {
|
||||
let drives = libfreemkv::find_drives();
|
||||
if drives.is_empty() {
|
||||
eprintln!("No drives found");
|
||||
std::process::exit(1);
|
||||
}
|
||||
drives[0].device_path().to_string()
|
||||
});
|
||||
|
||||
let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| {
|
||||
eprintln!("Cannot open {}: {}", device, e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
eprintln!("wait_ready...");
|
||||
let _ = drive.wait_ready();
|
||||
eprintln!("read_capacity...");
|
||||
let cap = drive.read_capacity().unwrap();
|
||||
eprintln!("capacity: {} sectors", cap);
|
||||
|
||||
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
|
||||
// Open /dev/null writer like ISO dump does
|
||||
let file = std::fs::File::create("/dev/null").unwrap();
|
||||
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
|
||||
eprintln!(
|
||||
"Reading 1000 batches ({:.1} MB) with write + progress...",
|
||||
1000.0 * batch as f64 * 2048.0 / 1_048_576.0
|
||||
);
|
||||
|
||||
let start = Instant::now();
|
||||
let mut ok = 0u32;
|
||||
let mut fail = 0u32;
|
||||
let mut bytes: u64 = 0;
|
||||
|
||||
for i in 0..1000u32 {
|
||||
let lba = i * batch as u32;
|
||||
match drive.read(lba, batch, &mut buf) {
|
||||
Ok(_) => {
|
||||
writer.write_all(&buf).unwrap();
|
||||
ok += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
fail += 1;
|
||||
if fail <= 5 {
|
||||
eprintln!(" FAIL LBA {}: {}", lba, e);
|
||||
}
|
||||
buf.fill(0);
|
||||
writer.write_all(&buf).unwrap();
|
||||
}
|
||||
}
|
||||
bytes += buf.len() as u64;
|
||||
|
||||
if i % 50 == 0 && i > 0 {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let mb = bytes as f64 / 1_048_576.0;
|
||||
eprint!("\r {:.1} MB | {:.1} MB/s ", mb, mb / elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let mb = ok as f64 * batch as f64 * 2048.0 / 1_048_576.0;
|
||||
eprintln!(
|
||||
"\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s",
|
||||
ok,
|
||||
fail,
|
||||
mb,
|
||||
elapsed,
|
||||
mb / elapsed
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -11,11 +11,12 @@ Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv),
|
||||
| Document | What it covers |
|
||||
|----------|---------------|
|
||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||
| [Drive Access](drive-access.md) | DriveSession, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||
| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation |
|
||||
| [API Design](api-design.md) | Stream API design, PES pipeline, input/output resolution |
|
||||
|
||||
## Reading Order
|
||||
|
||||
|
||||
+5
-3
@@ -189,12 +189,14 @@ In practice, AACS 2.0 UHD discs work through the backward-compatible AACS 1.0 ha
|
||||
AACS decryption is transparent to the application. The `Disc::scan()` method handles everything automatically:
|
||||
|
||||
```rust
|
||||
use libfreemkv::{DriveSession, Disc};
|
||||
use libfreemkv::{Drive, Disc};
|
||||
use libfreemkv::disc::ScanOptions;
|
||||
use std::path::Path;
|
||||
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4")).unwrap();
|
||||
drive.wait_ready().unwrap();
|
||||
drive.init().unwrap();
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
|
||||
|
||||
// Check encryption state
|
||||
if disc.encrypted {
|
||||
|
||||
+120
-170
@@ -12,82 +12,110 @@
|
||||
|
||||
```rust
|
||||
// Open drive — explicit steps, app prints between them
|
||||
let mut session = DriveSession::open(path)?;
|
||||
session.wait_ready()?;
|
||||
session.init()?;
|
||||
session.probe_disc()?;
|
||||
let mut drive = Drive::open(path)?;
|
||||
drive.wait_ready()?;
|
||||
drive.init()?;
|
||||
drive.probe_disc()?;
|
||||
|
||||
// Scan disc
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||
|
||||
// Browse
|
||||
disc.titles // Vec<Title>
|
||||
disc.titles // Vec<DiscTitle>
|
||||
disc.format // BD / UHD / DVD
|
||||
disc.capacity_gb()
|
||||
|
||||
// Rip with events
|
||||
disc.rip(&mut session, 0, output, |event| {
|
||||
match event.kind {
|
||||
EventKind::BytesRead { bytes, total } => ...,
|
||||
EventKind::ReadError { sector, error } => ...,
|
||||
EventKind::Retry { attempt } => ...,
|
||||
EventKind::SpeedChange { speed_kbs } => ...,
|
||||
EventKind::Complete { bytes, errors } => ...,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Rip without events
|
||||
disc.rip(&mut session, 0, output, event::ignore)?;
|
||||
```
|
||||
|
||||
## Stream Chains
|
||||
## PES Pipeline (primary API)
|
||||
|
||||
Each stream wraps the next. Builder pattern, no `.build()`.
|
||||
The PES pipeline is the main way to move content. All streams produce/consume
|
||||
PES frames. The pipeline just reads frames and writes frames.
|
||||
|
||||
### Raw m2ts
|
||||
```rust
|
||||
disc.rip(&mut session, 0, File::create("movie.m2ts")?, event::ignore)?;
|
||||
// URL-based — any source to any destination
|
||||
let opts = InputOptions::default();
|
||||
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||
let title = input.info().clone();
|
||||
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||
|
||||
while let Ok(Some(frame)) = input.read() {
|
||||
output.write(&frame)?;
|
||||
}
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
### MKV
|
||||
The `pes::Stream` trait:
|
||||
|
||||
```rust
|
||||
let output = MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(10 * 1024 * 1024);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
pub trait Stream {
|
||||
fn read(&mut self) -> io::Result<Option<PesFrame>>;
|
||||
fn write(&mut self, frame: &PesFrame) -> io::Result<()>;
|
||||
fn finish(&mut self) -> io::Result<()>;
|
||||
fn info(&self) -> &DiscTitle;
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>>;
|
||||
fn headers_ready(&self) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
### MKV with progress (CLI)
|
||||
## IOStream (byte-level API)
|
||||
|
||||
For raw byte copies (disc→ISO, resume, benchmarks). Lower level than PES.
|
||||
|
||||
```rust
|
||||
let output = ProgressStream::new(
|
||||
MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(10 * 1024 * 1024),
|
||||
total_bytes,
|
||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
||||
);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
let opts = InputOptions::default();
|
||||
let mut input = open_input("iso://Disc.iso", &opts)?;
|
||||
let mut output = open_output("mkv://Movie.mkv", input.info())?;
|
||||
io::copy(&mut *input, &mut *output)?;
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
### Future: transcode
|
||||
## Streams
|
||||
|
||||
All streams implement `IOStream` (byte-level) and/or `pes::Stream` (frame-level).
|
||||
URL-based resolvers open any stream by string.
|
||||
|
||||
| Stream | Input | Output | URL | Transport |
|
||||
|--------|-------|--------|-----|-----------|
|
||||
| DiscStream | Yes | -- | `disc://` `disc:///dev/sg4` | Optical drive via SCSI |
|
||||
| IsoStream | Yes | Yes | `iso://path.iso` | Blu-ray ISO image |
|
||||
| MkvStream | Yes | Yes | `mkv://path` | Matroska container |
|
||||
| M2tsStream | Yes | Yes | `m2ts://path` | BD-TS with FMKV metadata header |
|
||||
| NetworkStream | Yes (listen) | Yes (connect) | `network://host:port` | TCP with FMKV metadata header |
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | `stdio://` | Raw byte pipe |
|
||||
| NullStream | -- | Yes | `null://` | Discard sink (byte counter) |
|
||||
|
||||
All URLs require a `scheme://path` format. Bare paths are rejected.
|
||||
|
||||
```rust
|
||||
let output = ProgressStream::new(
|
||||
TranscodeStream::new(
|
||||
MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(50 * 1024 * 1024),
|
||||
)
|
||||
.codec(H265)
|
||||
.quality(22),
|
||||
total_bytes,
|
||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
||||
);
|
||||
// PES pipeline (frame-level)
|
||||
let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream
|
||||
let input = libfreemkv::input("iso://Dune.iso", &opts)?; // IsoStream
|
||||
let output = libfreemkv::output("mkv://Dune.mkv", &title)?; // MkvOutputStream
|
||||
let output = libfreemkv::output("m2ts://Dune.m2ts", &title)?; // M2tsOutputStream
|
||||
let output = libfreemkv::output("network://10.0.0.1:9000", &title)?; // NetworkOutputStream
|
||||
let output = libfreemkv::output("null://", &title)?; // NullOutputStream
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
// IOStream (byte-level)
|
||||
let input = open_input("disc://", &opts)?; // DiscStream
|
||||
let input = open_input("iso://Dune.iso", &opts)?; // IsoStream
|
||||
let output = open_output("iso://Copy.iso", &meta)?; // IsoStream (write)
|
||||
let output = open_output("mkv://Dune.mkv", &meta)?; // MkvStream
|
||||
let output = open_output("m2ts://Dune.m2ts", &meta)?; // M2tsStream
|
||||
let output = open_output("null://", &meta)?; // NullStream
|
||||
```
|
||||
|
||||
### FMKV Metadata Header
|
||||
|
||||
M2tsStream and NetworkStream embed a JSON metadata header before the BD-TS data:
|
||||
|
||||
```
|
||||
[8B magic "FMKV\0\0\0\0"][4B JSON length][JSON metadata][padding to 192B boundary][BD-TS data...]
|
||||
```
|
||||
|
||||
The header carries title name, duration, codec_privates, and full stream layout
|
||||
(PIDs, codecs, languages, labels). This allows the receiving end to set up
|
||||
demuxing and track metadata without scanning the TS.
|
||||
|
||||
## Events
|
||||
|
||||
Lib fires events during operations. App provides a callback. No display, no text.
|
||||
@@ -107,105 +135,8 @@ pub enum EventKind {
|
||||
}
|
||||
```
|
||||
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI prints a line. Server logs to file.
|
||||
|
||||
## Error Codes
|
||||
|
||||
Lib errors are codes, not messages. Like HTTP status codes.
|
||||
|
||||
```rust
|
||||
pub enum Error {
|
||||
// Drive
|
||||
DriveNotFound,
|
||||
DriveOpenFailed,
|
||||
DriveNotReady,
|
||||
|
||||
// Unlock
|
||||
UnlockFailed,
|
||||
NoProfile,
|
||||
|
||||
// AACS
|
||||
AacsNoKeys,
|
||||
AacsCertVerifyFailed,
|
||||
AacsAgidAllocFailed,
|
||||
AacsHandshakeFailed,
|
||||
AacsVidMacFailed,
|
||||
|
||||
// Disc
|
||||
DiscReadError { sector: u64 },
|
||||
MplsParseError,
|
||||
ClpiParseError,
|
||||
UdfFileNotFound { path: String },
|
||||
|
||||
// Mux
|
||||
LookaheadOverflow,
|
||||
MuxWriteError,
|
||||
|
||||
// SCSI
|
||||
ScsiError { sense: u8 },
|
||||
}
|
||||
```
|
||||
|
||||
App maps codes to localized strings. Lib never contains display text.
|
||||
|
||||
## Streams
|
||||
|
||||
All streams implement the `IOStream` trait (Read + Write). URL-based resolver opens any stream by string.
|
||||
|
||||
| Stream | Input | Output | URL | Transport |
|
||||
|--------|-------|--------|-----|-----------|
|
||||
| DiscStream | Yes | -- | `disc://` `disc:///dev/sg4` | Optical drive via SCSI |
|
||||
| IsoStream | Yes | -- | `iso://path.iso` | Blu-ray ISO image |
|
||||
| MkvStream | Yes | Yes | `mkv://path` | Matroska container |
|
||||
| M2tsStream | Yes | Yes | `m2ts://path` | BD-TS with FMKV metadata header |
|
||||
| NetworkStream | Yes (listen) | Yes (connect) | `network://host:port` | TCP with FMKV metadata header |
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | `stdio://` | Raw byte pipe |
|
||||
| NullStream | -- | Yes | `null://` | Discard sink (byte counter) |
|
||||
|
||||
All URLs require a `scheme://path` format. Bare paths are rejected.
|
||||
|
||||
```rust
|
||||
// URL-based opening
|
||||
let input = open_input("disc://", &opts)?; // DiscStream (auto-detect)
|
||||
let input = open_input("disc:///dev/sg4", &opts)?; // DiscStream (specific device)
|
||||
let input = open_input("iso://Dune.iso", &opts)?; // IsoStream
|
||||
let input = open_input("m2ts:///tmp/Dune.m2ts", &opts)?; // M2tsStream
|
||||
let input = open_input("mkv://Dune.mkv", &opts)?; // MkvStream
|
||||
let input = open_input("network://0.0.0.0:9000", &opts)?; // NetworkStream (listen)
|
||||
let input = open_input("stdio://", &opts)?; // StdioStream (stdin)
|
||||
|
||||
let output = open_output("mkv://Dune.mkv", &meta)?; // MkvStream
|
||||
let output = open_output("m2ts://Dune.m2ts", &meta)?; // M2tsStream
|
||||
let output = open_output("network://10.0.0.1:9000", &meta)?;// NetworkStream (connect)
|
||||
let output = open_output("stdio://", &meta)?; // StdioStream (stdout)
|
||||
let output = open_output("null://", &meta)?; // NullStream
|
||||
|
||||
// Direct construction (for advanced use)
|
||||
let mkv = MkvStream::new(writer).meta(&title).max_buffer(10 * 1024 * 1024);
|
||||
let m2ts = M2tsStream::new(writer).meta(&title);
|
||||
let net = NetworkStream::connect("10.0.0.1:9000")?.meta(&title);
|
||||
let null = NullStream::new().meta(&title);
|
||||
```
|
||||
|
||||
### FMKV Metadata Header
|
||||
|
||||
M2tsStream and NetworkStream embed a JSON metadata header before the BD-TS data:
|
||||
|
||||
```
|
||||
[8B magic "FMKV\0\0\0\0"][4B JSON length][JSON metadata][padding to 192B boundary][BD-TS data...]
|
||||
```
|
||||
|
||||
The header carries title name, duration, and full stream layout (PIDs, codecs, languages, labels). This allows the receiving end to set up demuxing and track metadata without scanning the TS.
|
||||
|
||||
### MkvStream Internals
|
||||
|
||||
LookaheadBuffer (default 5MB, configurable):
|
||||
1. Phase 1: buffer incoming data, scan for codec setup (SPS/PPS)
|
||||
2. Found it? Write MKV header, flush buffer, switch to streaming
|
||||
3. Buffer full? Error — app handles it
|
||||
4. Phase 2: parse TS → frames → MKV clusters, direct to output
|
||||
|
||||
Reading: extracts MKV frames, wraps back into BD-TS PES packets.
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI
|
||||
prints a line. Server logs to file.
|
||||
|
||||
## File Layout
|
||||
|
||||
@@ -214,38 +145,57 @@ libfreemkv/src/
|
||||
├── lib.rs Public exports
|
||||
├── error.rs Error codes (no English)
|
||||
├── event.rs Event types for callbacks
|
||||
├── drive.rs DriveSession (open, init, read)
|
||||
├── disc.rs Disc (scan, rip, titles)
|
||||
├── scsi/ SCSI transport (Linux, macOS)
|
||||
├── drive/ Drive (open, init, read with recovery)
|
||||
│ ├── mod.rs Drive struct, init, read, reset, eject
|
||||
│ ├── capture.rs Drive profile capture for contribution
|
||||
│ ├── linux.rs Linux drive discovery
|
||||
│ ├── macos.rs macOS drive discovery
|
||||
│ └── windows.rs Windows drive discovery
|
||||
├── disc/ Disc (scan, titles, AACS setup)
|
||||
├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI)
|
||||
├── platform/ Drive unlock (MT1959 A/B)
|
||||
├── aacs/ AACS decryption
|
||||
├── udf.rs UDF filesystem parser
|
||||
├── mpls.rs Playlist parser
|
||||
├── clpi.rs Clip info parser
|
||||
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
|
||||
├── css/ DVD CSS cipher
|
||||
├── decrypt.rs Unified decrypt dispatcher (AACS/CSS/None)
|
||||
├── pes.rs PES frame types, Stream trait
|
||||
├── sector.rs SectorReader trait
|
||||
├── udf.rs UDF 2.50 filesystem parser
|
||||
├── mpls.rs MPLS playlist parser
|
||||
├── clpi.rs CLPI clip info parser
|
||||
├── ifo.rs DVD IFO parser
|
||||
├── labels/ BD-J label extraction (5 format parsers)
|
||||
├── keydb.rs KEYDB download, parse, save
|
||||
├── identity.rs DriveId from INQUIRY
|
||||
├── profile.rs Bundled drive profiles
|
||||
├── speed.rs DriveSpeed enum
|
||||
├── mux/
|
||||
│ ├── mod.rs IOStream trait, public exports
|
||||
│ ├── resolve.rs URL parser + open_input/open_output
|
||||
│ ├── meta.rs M2tsMeta (FMKV header format)
|
||||
│ ├── disc.rs DiscStream (optical drive)
|
||||
│ ├── mkvstream.rs MkvStream (bidirectional Matroska)
|
||||
│ ├── m2ts.rs M2tsStream (BD-TS + FMKV header)
|
||||
│ ├── resolve.rs URL parser + open_input/open_output + input/output
|
||||
│ ├── meta.rs FMKV header format
|
||||
│ ├── disc.rs DiscStream (optical drive → PES)
|
||||
│ ├── iso.rs IsoStream (ISO image read/write)
|
||||
│ ├── isowriter.rs ISO image writer (UDF, AVDP, multi-extent)
|
||||
│ ├── mkvstream.rs MkvStream (bidirectional Matroska, IOStream)
|
||||
│ ├── mkvout.rs MkvOutputStream (PES → MKV)
|
||||
│ ├── m2ts.rs M2tsStream (BD-TS, IOStream)
|
||||
│ ├── pesout.rs PES output streams (M2ts, Network, Stdio, Null)
|
||||
│ ├── network.rs NetworkStream (TCP + FMKV header)
|
||||
│ ├── stdio.rs StdioStream (stdin/stdout pipe)
|
||||
│ ├── iso.rs IsoStream (Blu-ray ISO image)
|
||||
│ ├── null.rs NullStream (discard + byte counter)
|
||||
│ ├── lookahead.rs LookaheadBuffer (codec header scanning)
|
||||
│ ├── ts.rs BD-TS demuxer + PAT/PMT scanner
|
||||
│ ├── tsreader.rs TS reader utilities
|
||||
│ ├── tsmux.rs TS muxer (PES → BD-TS packets)
|
||||
│ ├── ps.rs MPEG-2 PS demuxer (DVD)
|
||||
│ ├── ebml.rs EBML read/write primitives
|
||||
│ ├── mkv.rs MKV muxer (tracks, clusters, cues)
|
||||
│ └── codec/ Frame parsers (H.264, HEVC, VC-1, AC3, DTS, TrueHD, PGS, LPCM)
|
||||
│ └── codec/ Frame parsers (H.264, HEVC, MPEG-2, VC-1, AC3, EAC3, DTS, TrueHD, LPCM, PGS)
|
||||
└── ...
|
||||
|
||||
freemkv/src/
|
||||
├── main.rs CLI dispatcher (URL routing)
|
||||
├── pipe.rs Generic source → dest copy
|
||||
├── rip.rs Rip with progress display
|
||||
├── remux.rs Remux with progress display
|
||||
├── disc_info.rs Disc info display
|
||||
├── pipe.rs PES pipeline — source → dest copy
|
||||
├── disc_info.rs Disc/file info display
|
||||
├── info.rs Drive info + profile submission
|
||||
├── strings.rs i18n string table
|
||||
├── output.rs Verbosity-filtered output
|
||||
|
||||
+63
-35
@@ -13,9 +13,9 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
||||
|
||||
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
|
||||
format handling live in the library. CLI binaries are thin wrappers that call
|
||||
`DriveSession::open()` and `Disc::scan()`.
|
||||
`Drive::open()` and `Disc::scan()`.
|
||||
|
||||
2. **No external files.** 206 drive profiles are compiled into the binary via
|
||||
2. **No external files.** Bundled drive profiles are compiled into the binary via
|
||||
`include_str!`. No configuration directory, no runtime file lookups for drive
|
||||
support.
|
||||
|
||||
@@ -23,12 +23,16 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
||||
available. Callers read cleartext sectors without knowing whether the disc
|
||||
was encrypted.
|
||||
|
||||
4. **Structured errors, no English.** Every error has a numeric code (E1000-E7000).
|
||||
4. **Structured errors, no English.** Every error has a numeric code (E1000-E8000).
|
||||
The library never formats user-facing messages -- applications do that.
|
||||
|
||||
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
||||
policy level. If a profile exists, the library uses it.
|
||||
|
||||
6. **Streams are dumb pipes.** Streams read/write PES frames. They don't know
|
||||
about encryption, transport format, or source type. Decrypt is a stream-internal
|
||||
concern; the pipeline just moves frames.
|
||||
|
||||
---
|
||||
|
||||
## Module Map
|
||||
@@ -37,26 +41,39 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
||||
libfreemkv (lib.rs)
|
||||
│
|
||||
├── Drive Access
|
||||
│ ├── drive DriveSession — open, identify, unlock, read
|
||||
│ ├── scsi ScsiTransport trait + SG_IO implementation
|
||||
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
|
||||
│ ├── scsi ScsiTransport trait + platform backends (SG_IO, IOKit, SPTI)
|
||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, hp)
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
|
||||
│ ├── profile DriveProfile loading, matching, bundled JSON
|
||||
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
|
||||
│ └── speed DriveSpeed enum, SET CD SPEED CDB builder
|
||||
│ ├── speed DriveSpeed enum, SET CD SPEED CDB builder
|
||||
│ └── event Event system for drive status callbacks
|
||||
│
|
||||
├── Disc Scanning
|
||||
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
|
||||
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
|
||||
│ ├── mpls MPLS playlist parser — clips, streams, STN table
|
||||
│ ├── clpi CLPI clip info parser — EP map, sector extents
|
||||
│ └── jar BD-J JAR label extraction (audio/subtitle names)
|
||||
│ ├── ifo DVD IFO parser — title sets, PGC chains, cell addresses
|
||||
│ └── labels/ BD-J label extraction (5 formats: Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
│
|
||||
├── Encryption
|
||||
│ ├── aacs KEYDB parsing, VUK lookup, MKB processing, unit decryption
|
||||
│ └── aacs_handshake ECDH bus authentication, Volume ID, Read Data Key
|
||||
│ ├── aacs/ AACS handshake, KEYDB, VUK lookup, MKB, unit decryption
|
||||
│ ├── css DVD CSS cipher — table-driven, no external keys needed
|
||||
│ └── decrypt decrypt_sectors() — unified AACS/CSS/None dispatcher
|
||||
│
|
||||
└── error Error enum with numeric codes E1000-E7000
|
||||
├── Streaming
|
||||
│ ├── mux/ Stream implementations (Disc, ISO, MKV, M2TS, Network, Stdio, Null)
|
||||
│ ├── pes PES frame types, Stream trait (read/write frames)
|
||||
│ └── sector SectorReader trait — abstracts disc vs ISO vs file
|
||||
│
|
||||
├── Support
|
||||
│ ├── keydb KEYDB.cfg download, parse, verify, save
|
||||
│ ├── error Error enum with numeric codes E1000-E8000
|
||||
│ └── profile Bundled drive profiles
|
||||
│
|
||||
└── lib.rs Public API re-exports
|
||||
```
|
||||
|
||||
---
|
||||
@@ -64,27 +81,28 @@ libfreemkv (lib.rs)
|
||||
## Drive Access Flow
|
||||
|
||||
```
|
||||
DriveSession::open("/dev/sr0")
|
||||
Drive::open(Path::new("/dev/sg4"))
|
||||
│
|
||||
├─ scsi::open() Open /dev/sr0 via SG_IO
|
||||
├─ scsi::open() Open /dev/sg4 via SG_IO
|
||||
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
||||
├─ profile::find_by_drive_id() Match against 206 bundled profiles
|
||||
├─ profile::find_by_drive_id() Match against bundled profiles
|
||||
├─ Platform::new() Instantiate chipset driver (Mt1959)
|
||||
└─ Platform::unlock() Activate raw disc access mode
|
||||
└─ Drive ready for init/unlock/read
|
||||
```
|
||||
|
||||
After open, the session provides:
|
||||
- `read_sectors(lba, count, buf)` -- raw sector reads (through platform driver)
|
||||
- `read_disc(lba, count, buf)` -- standard READ(10) for filesystem data
|
||||
- `scsi_execute(cdb, dir, buf, timeout)` -- arbitrary SCSI commands
|
||||
- `status()`, `calibrate()`, `read_config()`, `read_register()`
|
||||
After open:
|
||||
- `init()` -- unlock + firmware upload + speed calibration
|
||||
- `probe_disc()` -- probe disc surface for optimal speeds
|
||||
- `read(lba, count, buf)` -- single read method with built-in error recovery
|
||||
- `wait_ready()` -- wait for disc insertion
|
||||
- `eject()` -- eject tray
|
||||
|
||||
---
|
||||
|
||||
## Disc Scanning Flow
|
||||
|
||||
```
|
||||
Disc::scan(&mut session, &ScanOptions)
|
||||
Disc::scan(&mut drive, &ScanOptions)
|
||||
│
|
||||
├─ READ CAPACITY Get disc size in sectors
|
||||
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
|
||||
@@ -92,14 +110,24 @@ Disc::scan(&mut session, &ScanOptions)
|
||||
│ ├─ mpls::parse() Extract play items, STN streams
|
||||
│ └─ For each clip:
|
||||
│ └─ clpi::parse() EP map → sector extents for the clip's time range
|
||||
├─ labels::detect() Parse BD-J JARs for stream labels
|
||||
├─ Detect AACS Check for /AACS directory on disc
|
||||
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
|
||||
```
|
||||
|
||||
For DVD:
|
||||
```
|
||||
Disc::scan_dvd(&mut drive, &ScanOptions)
|
||||
│
|
||||
├─ ifo::parse() Parse VIDEO_TS.IFO — title sets, PGC chains
|
||||
├─ CSS detection Check disc structure flag
|
||||
└─ CSS key cracking Table-driven, no KEYDB needed
|
||||
```
|
||||
|
||||
The result is a `Disc` with:
|
||||
- `titles: Vec<Title>` -- sorted by duration, each with streams and sector extents
|
||||
- `aacs: Option<AacsState>` -- decryption keys if available
|
||||
- `encrypted: bool` -- whether the disc uses AACS
|
||||
- `titles: Vec<DiscTitle>` -- sorted by duration, each with streams, sector extents, codec_privates
|
||||
- `decrypt_keys()` -- DecryptKeys for content decryption
|
||||
- `encrypted: bool` -- whether the disc uses AACS/CSS
|
||||
|
||||
---
|
||||
|
||||
@@ -114,13 +142,14 @@ Four key resolution paths, tried in order:
|
||||
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
|
||||
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
|
||||
|
||||
The AACS handshake (`aacs_handshake`) performs ECDH key agreement over the
|
||||
The AACS handshake (`aacs/handshake`) performs ECDH key agreement over the
|
||||
AACS 1.0 160-bit elliptic curve to obtain:
|
||||
- **Volume ID** -- needed for VUK derivation (paths 2-4)
|
||||
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
|
||||
|
||||
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
|
||||
`ContentReader` handles this transparently.
|
||||
`ContentReader` handles this transparently. Streams that read sectors
|
||||
(DiscStream, IsoStream) decrypt internally — the pipeline sees clean bytes.
|
||||
|
||||
---
|
||||
|
||||
@@ -138,6 +167,7 @@ is baked into the library.
|
||||
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
|
||||
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
|
||||
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
|
||||
| E8xxx | KEYDB errors | `KeydbError` (download, parse, save) |
|
||||
|
||||
---
|
||||
|
||||
@@ -145,9 +175,9 @@ is baked into the library.
|
||||
|
||||
| Platform | Transport | Status |
|
||||
|----------|-----------|--------|
|
||||
| Linux | SG_IO ioctl on `/dev/sr*` | Implemented |
|
||||
| macOS | IOKit SCSI passthrough | Planned |
|
||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Planned |
|
||||
| Linux | SG_IO ioctl on `/dev/sg*` | Supported |
|
||||
| macOS | IOKit SCSITask | Supported |
|
||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
|
||||
|
||||
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
|
||||
implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
||||
@@ -158,11 +188,11 @@ implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
||||
|
||||
| Chipset | Drives | Status |
|
||||
|---------|--------|--------|
|
||||
| MediaTek MT1959 | LG, ASUS, hp | Implemented (206 profiles) |
|
||||
| MediaTek MT1959 | LG, ASUS, HP | Supported (bundled profiles) |
|
||||
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
|
||||
|
||||
The `Platform` trait abstracts chipset-specific commands. Each chipset implements
|
||||
10 handlers (unlock, config, register, calibrate, keepalive, status, probe,
|
||||
handlers (unlock, config, register, calibrate, keepalive, status, probe,
|
||||
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
|
||||
chipset-specific mode and buffer ID bytes.
|
||||
|
||||
@@ -174,7 +204,5 @@ chipset-specific mode and buffer ID bytes.
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Linux builds produce a static library and two binaries (`freemkv-info`,
|
||||
`freemkv-test`). The `libc` dependency is Linux-only. On non-Linux platforms,
|
||||
the library compiles but `scsi::open()` returns a platform-not-supported error
|
||||
until the IOKit/SPTI backends are implemented.
|
||||
Produces a Rust library crate. The `libc` dependency is unix-only (gated).
|
||||
All three platforms build and pass CI.
|
||||
|
||||
+56
-41
@@ -9,12 +9,18 @@ This is the starting point for understanding the library.
|
||||
Insert disc
|
||||
│
|
||||
▼
|
||||
1. Open drive (drive.rs)
|
||||
1. Open drive (drive/mod.rs)
|
||||
│ INQUIRY → identify drive
|
||||
│ Match bundled profile → chipset, unlock parameters
|
||||
│
|
||||
▼
|
||||
2. AACS handshake (aacs_handshake.rs) — optional, separate transport
|
||||
2. Init drive (drive/mod.rs → platform/mt1959)
|
||||
│ Firmware upload (if needed, 10s recovery wait)
|
||||
│ Unlock → vendor-specific command activates raw read mode
|
||||
│ Speed calibration → probe_disc()
|
||||
│
|
||||
▼
|
||||
3. AACS handshake (aacs/handshake.rs) — optional
|
||||
│ Allocate AGID
|
||||
│ Exchange certificates + nonces (ECDH)
|
||||
│ Derive bus key
|
||||
@@ -22,11 +28,6 @@ Insert disc
|
||||
│ (fails gracefully if drive doesn't support AACS for this disc)
|
||||
│
|
||||
▼
|
||||
3. Unlock drive (drive.rs → platform/mt1959.rs)
|
||||
│ Vendor-specific command activates raw read mode
|
||||
│ Required — drive firmware blocks all reads without it
|
||||
│
|
||||
▼
|
||||
4. Read UDF filesystem (udf.rs)
|
||||
│ Sector 256: AVDP → find Volume Descriptor Sequence
|
||||
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
|
||||
@@ -35,80 +36,94 @@ Insert disc
|
||||
│ → docs/udf.md
|
||||
│
|
||||
▼
|
||||
5. Read AACS files from disc (aacs.rs)
|
||||
5. Read AACS files from disc (aacs/mod.rs)
|
||||
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
|
||||
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
|
||||
│ MKB via SCSI → for key derivation fallback
|
||||
│
|
||||
▼
|
||||
6. Resolve AACS keys (aacs.rs → resolve_keys)
|
||||
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||||
│ Path 2: KEYDB media key + Volume ID → VUK
|
||||
│ Path 3: MKB + processing keys → media key → VUK
|
||||
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
||||
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
||||
6. Resolve encryption keys (decrypt.rs → resolve_encryption)
|
||||
│ BD AACS:
|
||||
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||||
│ Path 2: KEYDB media key + Volume ID → VUK
|
||||
│ Path 3: MKB + processing keys → media key → VUK
|
||||
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
||||
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
||||
│ DVD CSS:
|
||||
│ Table-driven cipher — no KEYDB needed
|
||||
│ → docs/aacs.md
|
||||
│
|
||||
▼
|
||||
7. Parse playlists (mpls.rs)
|
||||
7. Parse playlists (mpls.rs) — BD/UHD only
|
||||
│ BDMV/PLAYLIST/*.mpls → titles with play items
|
||||
│ Each play item: clip ID, in/out timestamps
|
||||
│ STN table: video, audio, subtitle streams with codec + language
|
||||
│ → docs/mpls.md
|
||||
│
|
||||
▼
|
||||
8. Parse clip info (clpi.rs)
|
||||
8. Parse clip info (clpi.rs) — BD/UHD only
|
||||
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
|
||||
│ Coarse + fine entries → full PTS and SPN
|
||||
│ SPN → byte offset → sector extents for reading
|
||||
│ → docs/clpi.md
|
||||
│
|
||||
▼
|
||||
9. Parse BD-J labels (jar.rs) — optional
|
||||
9. Parse BD-J labels (labels/) — optional
|
||||
│ BDMV/JAR/*.jar → Java class constant pool strings
|
||||
│ 5 format parsers: Paramount, Criterion, Pixelogic, CTRM, Deluxe
|
||||
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
|
||||
│
|
||||
▼
|
||||
10. Read + decrypt content (disc.rs → ContentReader)
|
||||
│ For each aligned unit (6144 bytes = 3 sectors):
|
||||
│ Read 3 sectors from disc
|
||||
│ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC)
|
||||
│ If encrypted: unit decrypt (per-unit key derivation + AES-CBC)
|
||||
│ Output decrypted content
|
||||
10. Stream content (mux/disc.rs → DiscStream)
|
||||
│ Read sectors → decrypt → TS demux → PES frames
|
||||
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
||||
│ Drive::read() handles error recovery (min speed → reset → retry)
|
||||
│
|
||||
▼
|
||||
Decrypted m2ts stream → ready for muxing/backup
|
||||
PES frames → output stream (MKV, M2TS, network, etc.)
|
||||
```
|
||||
|
||||
## API Summary
|
||||
|
||||
```rust
|
||||
// Steps 1 + 3 (open + unlock)
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
// Open + init drive
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?;
|
||||
drive.init()?;
|
||||
drive.probe_disc()?;
|
||||
|
||||
// Steps 2 + 4-9 (AACS + scan)
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?;
|
||||
// Scan disc (UDF + playlists + AACS — all automatic)
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||
|
||||
// Step 10 (read + decrypt)
|
||||
let mut reader = disc.open_title(&mut session, 0)?;
|
||||
while let Some(unit) = reader.read_unit()? {
|
||||
output.write_all(&unit)?;
|
||||
// Stream pipeline — PES frames from any source to any output
|
||||
let opts = InputOptions::default();
|
||||
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||
let title = input.info().clone();
|
||||
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||
while let Ok(Some(frame)) = input.read() {
|
||||
output.write(&frame)?;
|
||||
}
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
Three lines. Everything else is internal.
|
||||
|
||||
## Module Reference
|
||||
|
||||
| Module | Doc | Purpose |
|
||||
|--------|-----|---------|
|
||||
| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read |
|
||||
| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport |
|
||||
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, read (with recovery) |
|
||||
| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) |
|
||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
|
||||
| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt |
|
||||
| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication |
|
||||
| disc.rs | -- | High-level scan + read API |
|
||||
| jar.rs | -- | BD-J audio track labels |
|
||||
| error.rs | -- | Error codes (E1xxx-E7xxx) |
|
||||
| ifo.rs | -- | DVD IFO parser |
|
||||
| aacs/ | [aacs.md](aacs.md) | Key resolution + content decrypt + bus handshake |
|
||||
| css/ | -- | DVD CSS cipher |
|
||||
| decrypt.rs | -- | Unified decrypt dispatcher (AACS/CSS/None) |
|
||||
| disc/ | -- | High-level scan + read API |
|
||||
| labels/ | -- | BD-J stream labels (5 format parsers) |
|
||||
| mux/ | -- | Stream implementations (7 stream types) |
|
||||
| pes.rs | -- | PES frame types + Stream trait |
|
||||
| sector.rs | -- | SectorReader trait |
|
||||
| keydb.rs | -- | KEYDB download, parse, save |
|
||||
| error.rs | -- | Error codes (E1xxx-E8xxx) |
|
||||
| event.rs | -- | Drive event system |
|
||||
|
||||
+61
-85
@@ -5,51 +5,55 @@ optical drives.
|
||||
|
||||
---
|
||||
|
||||
## DriveSession
|
||||
## Drive
|
||||
|
||||
`DriveSession` is the primary API. It owns the SCSI transport, the matched
|
||||
`Drive` is the primary API. It owns the SCSI transport, the matched
|
||||
drive profile, and the chipset-specific platform driver.
|
||||
|
||||
### Opening a Drive
|
||||
|
||||
```rust
|
||||
// Full open: identify → match profile → unlock
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
|
||||
// No-unlock open: identify → match profile only
|
||||
let mut session = DriveSession::open_no_unlock(Path::new("/dev/sr0"))?;
|
||||
|
||||
// Explicit profile (skip auto-detection)
|
||||
let mut session = DriveSession::open_with_profile(Path::new("/dev/sr0"), profile)?;
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
```
|
||||
|
||||
**`open()`** performs the full sequence: open device, send INQUIRY, match
|
||||
profile, instantiate platform driver, and unlock. Unlock failures are silently
|
||||
ignored (unencrypted discs do not need it). After `open()`, both raw sector
|
||||
reads and standard READ(10) work immediately.
|
||||
`open()` performs: open device → send INQUIRY → match profile → instantiate
|
||||
platform driver. The drive is ready for `wait_ready()` and `init()`.
|
||||
|
||||
**`open_no_unlock()`** skips the unlock step. This is required when AACS bus
|
||||
authentication must happen before unlock. The handshake uses standard SCSI
|
||||
commands that work without raw mode. After authentication completes, the caller
|
||||
can invoke `session.unlock()` manually.
|
||||
|
||||
**`open_with_profile()`** bypasses profile auto-detection. Useful for testing
|
||||
or when a custom profile is loaded from an external source.
|
||||
|
||||
### Session Operations
|
||||
### Drive Operations
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `unlock()` | Activate raw disc access mode via platform driver |
|
||||
| `is_unlocked()` | Check if raw mode is active |
|
||||
| `calibrate()` | Build speed lookup table for the current disc |
|
||||
| `read_sectors(lba, count, buf)` | Raw sector read (requires unlock + calibrate) |
|
||||
| `read_disc(lba, count, buf)` | Standard READ(10) with 5s timeout |
|
||||
| `status()` | Query drive status and feature flags |
|
||||
| `read_config()` | Read drive configuration block (1888 bytes) |
|
||||
| `read_register(index)` | Read 16-byte hardware register |
|
||||
| `probe(sub_cmd, addr, len)` | Generic READ BUFFER with caller parameters |
|
||||
| `scsi_execute(cdb, dir, buf, timeout)` | Send an arbitrary SCSI CDB |
|
||||
| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) |
|
||||
| `init()` | Firmware upload + unlock + speed calibration |
|
||||
| `probe_disc()` | Probe disc surface for optimal speeds |
|
||||
| `read(lba, count, buf)` | Read sectors with built-in error recovery |
|
||||
| `reset()` | Close/reopen device, TUR, escalate if needed |
|
||||
| `lock_tray()` | Prevent tray ejection during rip |
|
||||
| `unlock_tray()` | Allow tray ejection (also runs on Drop) |
|
||||
| `eject()` | Eject disc tray |
|
||||
| `drive_status()` | Query physical state (disc present, tray open, etc.) |
|
||||
| `has_profile()` | Whether a bundled profile matched |
|
||||
| `close()` | Consume Drive, cleanup (also runs via Drop) |
|
||||
|
||||
### init() Sequence
|
||||
|
||||
`init()` orchestrates the full drive unlock:
|
||||
|
||||
1. Platform driver `run_init()` — sends vendor-specific SCSI commands
|
||||
2. If firmware upload needed: upload, wait 10s for drive reset, retry
|
||||
3. Speed calibration after unlock
|
||||
4. Max 3 attempts before giving up
|
||||
|
||||
### read() with Recovery
|
||||
|
||||
`Drive::read()` is the single read method. On error:
|
||||
|
||||
1. Set minimum speed immediately
|
||||
2. Reset device (close/reopen/TUR)
|
||||
3. Wait 2s for drive to settle
|
||||
4. Retry at min speed, min batch (3 sectors)
|
||||
5. If still failing: skip sectors, zero-fill, log
|
||||
6. Stay at min speed for 500 MB after error (recovery window)
|
||||
|
||||
---
|
||||
|
||||
@@ -58,7 +62,7 @@ or when a custom profile is loaded from an external source.
|
||||
### Trait
|
||||
|
||||
```rust
|
||||
pub trait ScsiTransport {
|
||||
pub trait ScsiTransport: Send {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
@@ -66,20 +70,24 @@ pub trait ScsiTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
fn reset(&mut self, device: &str) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
All drive communication goes through this trait. The library never opens file
|
||||
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
||||
|
||||
### Linux: SG_IO
|
||||
### Platform Backends
|
||||
|
||||
The `SgIoTransport` implementation:
|
||||
| Platform | Implementation | Device |
|
||||
|----------|---------------|--------|
|
||||
| Linux | `SgIoTransport` — `ioctl(fd, SG_IO, &hdr)` | `/dev/sg*` |
|
||||
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
|
||||
| Windows | `WindowsScsiTransport` — SPTI | `\\.\CdRomN` |
|
||||
|
||||
1. Opens the device path with `O_RDWR | O_NONBLOCK`.
|
||||
2. Constructs an `sg_io_hdr` struct with the CDB, data buffer, and timeout.
|
||||
3. Calls `ioctl(fd, SG_IO, &hdr)`.
|
||||
4. Returns `ScsiResult` with status, bytes transferred, and sense data.
|
||||
The Linux backend opens with `O_RDWR | O_NONBLOCK`, constructs `sg_io_hdr`,
|
||||
and returns `ScsiResult` with status, bytes transferred, and sense data.
|
||||
|
||||
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
|
||||
sense buffer and returns `Error::ScsiError`.
|
||||
@@ -119,8 +127,8 @@ date for drives where Feature 010C is unavailable.
|
||||
|
||||
## Drive Profiles
|
||||
|
||||
Profiles are JSON objects compiled into the binary (`profiles.json`,
|
||||
206 entries). Each profile contains:
|
||||
Profiles are JSON objects compiled into the binary (`profiles.json`).
|
||||
Each profile contains:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
@@ -148,7 +156,7 @@ let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
|
||||
|
||||
### MediaTek MT1959
|
||||
|
||||
Covers all LG, ASUS, and hp optical drives. Two sub-variants share identical
|
||||
Covers all LG, ASUS, and HP optical drives. Two sub-variants share identical
|
||||
logic with different SCSI parameters:
|
||||
|
||||
| Variant | READ BUFFER mode | Buffer ID |
|
||||
@@ -156,7 +164,7 @@ logic with different SCSI parameters:
|
||||
| MT1959-A | 0x01 | 0x44 |
|
||||
| MT1959-B | 0x02 | 0x77 |
|
||||
|
||||
The Platform trait maps to 10 command handlers:
|
||||
The Platform trait maps to command handlers:
|
||||
|
||||
| Handler | Function | Description |
|
||||
|---------|----------|-------------|
|
||||
@@ -183,64 +191,32 @@ Optical drive firmware restricts what applications can read from disc. Without
|
||||
unlock:
|
||||
|
||||
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
|
||||
playlists, and CLPI clip info are readable without unlock. The `read_disc()`
|
||||
method uses standard READ(10) and works on any drive.
|
||||
playlists, and CLPI clip info are readable without unlock. Standard READ(10)
|
||||
works on any drive.
|
||||
|
||||
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
|
||||
SCSI errors (sense key 0x05, illegal request) when an application attempts to
|
||||
read sectors containing encrypted m2ts content without prior AACS
|
||||
authentication via the bus key.
|
||||
|
||||
- **The kernel sr driver blocks block-device reads.** On Linux, the kernel's
|
||||
SCSI CD-ROM driver (`sr`) refuses to expose encrypted disc content through
|
||||
`/dev/sr0` as a block device. Even if you open the block device directly,
|
||||
reads to encrypted regions fail.
|
||||
|
||||
- **Raw mode bypasses firmware restrictions.** After unlock, the drive accepts
|
||||
READ(10) with the raw read flag (CDB byte 1 = 0x08) for all sectors,
|
||||
regardless of encryption status. This is how raw sector ripping works.
|
||||
regardless of encryption status.
|
||||
|
||||
### open() vs open_no_unlock()
|
||||
### AACS Before Unlock
|
||||
|
||||
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
|
||||
These must execute before unlock because:
|
||||
|
||||
1. The AACS handshake establishes a bus key via ECDH.
|
||||
2. The bus key encrypts the Volume ID and Read Data Key responses.
|
||||
3. The Volume ID is needed to derive the Volume Unique Key (VUK).
|
||||
4. The VUK is needed to decrypt unit keys from `Unit_Key_RO.inf`.
|
||||
|
||||
If `open()` unlocks first, some drives reject the subsequent AACS commands.
|
||||
The correct sequence for encrypted discs is:
|
||||
|
||||
```rust
|
||||
// 1. Open without unlock
|
||||
let mut session = DriveSession::open_no_unlock(device)?;
|
||||
|
||||
// 2. AACS handshake (uses standard SCSI, no unlock needed)
|
||||
let auth = aacs_handshake::aacs_authenticate(&mut session, &key, &cert)?;
|
||||
let vid = aacs_handshake::read_volume_id(&mut session, &mut auth)?;
|
||||
|
||||
// 3. Now unlock for raw reads
|
||||
session.unlock()?;
|
||||
session.calibrate()?;
|
||||
|
||||
// 4. Read and decrypt content
|
||||
session.read_sectors(lba, count, &mut buf)?;
|
||||
```
|
||||
|
||||
In practice, `Disc::scan()` handles this internally. The default `open()` call
|
||||
unlocks immediately and is correct for most use cases -- the scan re-opens a
|
||||
second session with `open_no_unlock()` for the AACS handshake when needed.
|
||||
On some drives these must execute before unlock. The `Disc::scan()` handles
|
||||
this internally — it manages the handshake/unlock ordering automatically.
|
||||
|
||||
---
|
||||
|
||||
## Speed Control
|
||||
|
||||
After `calibrate()`, the platform driver maintains a 64-entry speed lookup table
|
||||
built by probing the disc surface. On each `read_sectors()` call, the driver:
|
||||
After `probe_disc()`, the platform driver maintains a speed lookup table
|
||||
built by probing the disc surface. On each `read()` call, the driver:
|
||||
|
||||
1. Looks up the optimal speed for the target LBA in the table.
|
||||
1. Looks up the optimal speed for the target LBA.
|
||||
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
|
||||
3. Performs the READ(10).
|
||||
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
# MKV Native Muxer — Architecture
|
||||
|
||||
## Goal
|
||||
|
||||
Replace raw m2ts output with in-pipeline MKV muxing.
|
||||
Disc → TS demux → MKV mux → .mkv file. One pass, no temp files.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
ContentReader::read_batch() returns &[u8] of raw BD transport stream
|
||||
↓
|
||||
TsDemuxer::feed(batch) parses 192-byte BD-TS packets, extracts PES
|
||||
↓
|
||||
PES reassembly per PID builds complete PES packets with PTS/DTS
|
||||
↓
|
||||
ElementaryStreamParser per track finds frame boundaries, extracts codec headers
|
||||
↓
|
||||
MkvMuxer::write_frame(track, pts, data) writes EBML clusters + blocks
|
||||
↓
|
||||
.mkv file on disk
|
||||
```
|
||||
|
||||
## Current Integration Point
|
||||
|
||||
```rust
|
||||
// rip.rs line ~287
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
writer.write_all(batch)?; // ← replace with muxer.feed(batch)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Becomes:
|
||||
```rust
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
muxer.feed(batch)?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. BD Transport Stream Demuxer (`ts.rs`)
|
||||
|
||||
BD uses 192-byte packets (not standard 188):
|
||||
```
|
||||
[0-3] TP_extra_header: 2-bit copy_permission + 30-bit arrival_time_stamp
|
||||
[4] Sync byte: 0x47
|
||||
[5] TEI + PUSI + priority + PID[12:8]
|
||||
[6] PID[7:0]
|
||||
[7] Scrambling + adaptation + continuity_counter
|
||||
[8..] Adaptation field (if present) + payload
|
||||
```
|
||||
|
||||
API:
|
||||
```rust
|
||||
pub struct TsDemuxer {
|
||||
pes_assemblers: HashMap<u16, PesAssembler>, // PID → assembler
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
pub fn new(pids: &[u16]) -> Self;
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket>;
|
||||
}
|
||||
|
||||
pub struct PesPacket {
|
||||
pub pid: u16,
|
||||
pub pts: Option<i64>, // 90kHz ticks
|
||||
pub dts: Option<i64>, // 90kHz ticks
|
||||
pub data: Vec<u8>, // elementary stream data
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Elementary Stream Parsers (`codec/`)
|
||||
|
||||
Each codec parser finds frame boundaries and extracts initialization data.
|
||||
|
||||
**H.264 (`codec/h264.rs`):**
|
||||
- Parse NAL units (start code 00 00 01 or 00 00 00 01)
|
||||
- Extract SPS + PPS for codecPrivate
|
||||
- Frame boundary = Access Unit Delimiter (NAL type 9) or SPS
|
||||
|
||||
**HEVC (`codec/hevc.rs`):**
|
||||
- Parse NAL units
|
||||
- Extract VPS + SPS + PPS for codecPrivate
|
||||
- Frame boundary = VCL NAL with first_slice_segment_in_pic_flag
|
||||
|
||||
**AC3/EAC3 (`codec/ac3.rs`):**
|
||||
- Syncword 0x0B77
|
||||
- Parse frame size from header
|
||||
- No codecPrivate needed (or minimal)
|
||||
|
||||
**DTS (`codec/dts.rs`):**
|
||||
- Syncword 0x7FFE8001
|
||||
- Parse frame size
|
||||
- No codecPrivate needed
|
||||
|
||||
**TrueHD (`codec/truehd.rs`):**
|
||||
- Major sync: 0xF8726FBA
|
||||
- Access unit = major sync + minor syncs
|
||||
- AC3 core embedded in first substream
|
||||
|
||||
**LPCM (`codec/lpcm.rs`):**
|
||||
- Fixed frame sizes based on sample rate + channels
|
||||
- Header describes format
|
||||
|
||||
**PGS (`codec/pgs.rs`):**
|
||||
- Segment types: PCS, WDS, PDS, ODS, END
|
||||
- Each segment is a complete unit
|
||||
- No codecPrivate needed
|
||||
|
||||
### 3. MKV/EBML Muxer (`mkv.rs`)
|
||||
|
||||
Matroska uses EBML (Extensible Binary Meta Language).
|
||||
|
||||
**EBML primitives:**
|
||||
- Variable-length element ID (1-4 bytes)
|
||||
- Variable-length size (1-8 bytes)
|
||||
- Data: uint, int, float, string, UTF-8, binary, date
|
||||
|
||||
**MKV structure:**
|
||||
```
|
||||
EBML Header
|
||||
Segment
|
||||
├── SeekHead (index of top-level elements)
|
||||
├── Info (title, duration, muxing app)
|
||||
├── Tracks (one entry per stream)
|
||||
│ ├── TrackEntry (video)
|
||||
│ │ ├── CodecID: "V_MPEG4/ISO/AVC" or "V_MPEGH/ISO/HEVC"
|
||||
│ │ ├── CodecPrivate: SPS+PPS (H.264) or VPS+SPS+PPS (HEVC)
|
||||
│ │ └── Video: PixelWidth, PixelHeight, DisplayWidth, DisplayHeight
|
||||
│ ├── TrackEntry (audio)
|
||||
│ │ ├── CodecID: "A_AC3" or "A_TRUEHD" or "A_DTS"
|
||||
│ │ └── Audio: SamplingFrequency, Channels, BitDepth
|
||||
│ └── TrackEntry (subtitle)
|
||||
│ └── CodecID: "S_HDMV/PGS"
|
||||
├── Chapters (optional, from MPLS chapter marks)
|
||||
├── Cluster (every ~5 seconds)
|
||||
│ ├── Timestamp (cluster base time)
|
||||
│ ├── SimpleBlock (track, relative_ts, data)
|
||||
│ ├── SimpleBlock ...
|
||||
│ └── ...
|
||||
├── Cluster ...
|
||||
├── Cues (seek index, written at end)
|
||||
└── Tags (metadata)
|
||||
```
|
||||
|
||||
**API:**
|
||||
```rust
|
||||
pub struct MkvMuxer<W: Write + Seek> {
|
||||
writer: W,
|
||||
tracks: Vec<MkvTrack>,
|
||||
cluster_start: Option<i64>,
|
||||
cue_points: Vec<CuePoint>,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
pub fn new(writer: W, tracks: &[MkvTrack]) -> Result<Self>;
|
||||
pub fn write_frame(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Result<()>;
|
||||
pub fn finish(self) -> Result<()>; // writes Cues + fixes SeekHead
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Pipeline Glue (`mux.rs`)
|
||||
|
||||
Ties everything together:
|
||||
|
||||
```rust
|
||||
pub struct MuxPipeline<W: Write + Seek> {
|
||||
demuxer: TsDemuxer,
|
||||
parsers: HashMap<u16, Box<dyn CodecParser>>,
|
||||
muxer: MkvMuxer<W>,
|
||||
pid_to_track: HashMap<u16, usize>,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MuxPipeline<W> {
|
||||
pub fn new(writer: W, streams: &[Stream]) -> Result<Self>;
|
||||
pub fn feed(&mut self, ts_data: &[u8]) -> Result<()>;
|
||||
pub fn finish(self) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
libfreemkv/src/
|
||||
├── mux/
|
||||
│ ├── mod.rs MuxPipeline (glue)
|
||||
│ ├── ts.rs BD-TS demuxer (192-byte packets)
|
||||
│ ├── ebml.rs EBML primitives (write variable-length ints)
|
||||
│ ├── mkv.rs MKV muxer (Segment, Tracks, Clusters)
|
||||
│ └── codec/
|
||||
│ ├── mod.rs CodecParser trait
|
||||
│ ├── h264.rs H.264 NAL parser
|
||||
│ ├── hevc.rs HEVC NAL parser
|
||||
│ ├── ac3.rs AC3/EAC3 frame parser
|
||||
│ ├── dts.rs DTS frame parser
|
||||
│ ├── truehd.rs TrueHD/Atmos parser
|
||||
│ ├── lpcm.rs LPCM frame parser
|
||||
│ └── pgs.rs PGS subtitle parser
|
||||
```
|
||||
|
||||
## MKV Codec IDs
|
||||
|
||||
| Our Codec | MKV CodecID | codecPrivate |
|
||||
|-----------|------------|--------------|
|
||||
| H264 | V_MPEG4/ISO/AVC | AVCDecoderConfigurationRecord (SPS+PPS) |
|
||||
| Hevc | V_MPEGH/ISO/HEVC | HEVCDecoderConfigurationRecord (VPS+SPS+PPS) |
|
||||
| Vc1 | V_MS/VFW/FOURCC | BITMAPINFOHEADER |
|
||||
| Mpeg2 | V_MPEG2 | sequence_header |
|
||||
| Ac3 | A_AC3 | none |
|
||||
| Ac3Plus | A_EAC3 | none |
|
||||
| TrueHd | A_TRUEHD | none |
|
||||
| DtsHdMa | A_DTS | none (core + extension) |
|
||||
| Dts | A_DTS | none |
|
||||
| Lpcm | A_PCM/INT/BIG | none |
|
||||
| Pgs | S_HDMV/PGS | none |
|
||||
|
||||
## Timestamps
|
||||
|
||||
BD uses 90kHz PTS/DTS. MKV uses nanoseconds.
|
||||
Conversion: `ns = pts * 1_000_000_000 / 90_000` = `pts * 100_000 / 9`
|
||||
|
||||
MKV TimestampScale default = 1,000,000 (1ms precision).
|
||||
For BD content, 1ms is sufficient.
|
||||
|
||||
## Build Order
|
||||
|
||||
1. `ebml.rs` — EBML write primitives (smallest, no dependencies)
|
||||
2. `ts.rs` — BD-TS demuxer (parse 192-byte packets, PES assembly)
|
||||
3. `codec/ac3.rs` — simplest codec parser (fixed syncword)
|
||||
4. `mkv.rs` — MKV muxer (header, tracks, clusters, blocks)
|
||||
5. `mux.rs` — pipeline glue
|
||||
6. Test with AC3-only stream (simplest case)
|
||||
7. `codec/h264.rs` — video parser (NAL units, SPS/PPS)
|
||||
8. Full BD rip test (video + audio + subs)
|
||||
9. Remaining codecs (HEVC, DTS, TrueHD, PGS, LPCM, VC-1)
|
||||
@@ -120,6 +120,14 @@ Each directory read involves two sector reads: one for the ICB, then one or more
|
||||
|
||||
`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).
|
||||
|
||||
## Buffered Sector Reads
|
||||
|
||||
USB optical drives have ~500ms round-trip latency per SCSI command. Since `read_filesystem()` and `read_file()` issue one SCSI READ per sector, a full disc scan can require hundreds of commands -- taking 10+ minutes on USB.
|
||||
|
||||
`Disc::scan()` wraps the drive in a `BufferedSectorReader` before reading. On a single-sector read, the buffer prefetches a batch of sectors (sized from the kernel's `max_hw_sectors_kb` for the device) and caches them. Subsequent reads to nearby LBAs return from cache with zero SCSI overhead. After parsing the UDF directory structure, the entire metadata partition is pre-read into the cache, so all ICB lookups during title scanning and encryption resolution are instant.
|
||||
|
||||
The buffer is transparent -- `read_filesystem()`, `read_file()`, and all downstream code still call `read_sectors(lba, 1, buf)` as before. The batching happens inside the `SectorReader` implementation.
|
||||
|
||||
### UDF Filename Encoding
|
||||
|
||||
UDF filenames use a compression ID as the first byte:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Minimal ISO dumper — find exact stall point
|
||||
use libfreemkv::Drive;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: iso_dump <device> <output>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut drive = Drive::open(Path::new(&args[1])).unwrap();
|
||||
drive.wait_ready().unwrap();
|
||||
let _ = drive.init();
|
||||
let _ = drive.probe_disc();
|
||||
|
||||
// AACS handshake — required to read past the protected area
|
||||
eprint!("Scanning disc... ");
|
||||
let _ = libfreemkv::Disc::scan(&mut drive, &libfreemkv::ScanOptions::default());
|
||||
eprintln!("OK");
|
||||
|
||||
let cap = drive.read_capacity().unwrap();
|
||||
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
|
||||
|
||||
eprintln!("Device: {} | {} sectors | batch {}", args[1], cap, batch);
|
||||
|
||||
let file = std::fs::File::create(&args[2]).unwrap();
|
||||
let mut w = BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
let mut lba: u32 = 0;
|
||||
let start = Instant::now();
|
||||
let mut last = Instant::now();
|
||||
let mut bytes: u64 = 0;
|
||||
let mut last_bytes: u64 = 0;
|
||||
|
||||
while lba < cap {
|
||||
let count = ((cap - lba) as u16).min(batch);
|
||||
let n = count as usize * 2048;
|
||||
|
||||
// Tiny yield between reads — test if pacing prevents firmware throttle
|
||||
std::thread::yield_now();
|
||||
let t0 = Instant::now();
|
||||
let ok = drive.read(lba, count, &mut buf[..n]).is_ok();
|
||||
let read_ms = t0.elapsed().as_millis();
|
||||
|
||||
// Flag slow reads
|
||||
if read_ms > 2000 {
|
||||
eprintln!("\n SLOW READ: LBA {} took {}ms (ok={})", lba, read_ms, ok);
|
||||
}
|
||||
|
||||
if !ok {
|
||||
buf[..n].fill(0);
|
||||
}
|
||||
w.write_all(&buf[..n]).unwrap();
|
||||
lba += count as u32;
|
||||
bytes += n as u64;
|
||||
|
||||
if last.elapsed().as_millis() >= 1000 {
|
||||
let delta = bytes - last_bytes;
|
||||
let speed = delta as f64 / last.elapsed().as_secs_f64() / 1_048_576.0;
|
||||
let avg = bytes as f64 / start.elapsed().as_secs_f64() / 1_048_576.0;
|
||||
let pct = bytes as f64 / (cap as f64 * 2048.0) * 100.0;
|
||||
eprint!(
|
||||
"\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ",
|
||||
pct,
|
||||
lba,
|
||||
speed,
|
||||
avg,
|
||||
bytes as f64 / 1e9
|
||||
);
|
||||
last_bytes = bytes;
|
||||
last = Instant::now();
|
||||
}
|
||||
}
|
||||
w.flush().unwrap();
|
||||
eprintln!(
|
||||
"\nDone: {:.1} GB in {:.0}s",
|
||||
bytes as f64 / 1e9,
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 100
|
||||
@@ -1050,10 +1050,7 @@ pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 1
|
||||
}
|
||||
|
||||
/// Read data keys after successful authentication (for AACS 2.0 bus encryption).
|
||||
pub fn read_data_keys(
|
||||
session: &mut Drive,
|
||||
auth: &mut AacsAuth,
|
||||
) -> Result<([u8; 16], [u8; 16])> {
|
||||
pub fn read_data_keys(session: &mut Drive, auth: &mut AacsAuth) -> Result<([u8; 16], [u8; 16])> {
|
||||
// REPORT DISC STRUCTURE format 0x84
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
|
||||
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsDataKey)?;
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ pub struct DiscEntry {
|
||||
/// Parse a hex string like "0xABCD..." into bytes.
|
||||
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
|
||||
if !s.len().is_multiple_of(2) {
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
|
||||
+7
-5
@@ -188,9 +188,13 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
|
||||
// Try each processing key against each UV/cvalue pair
|
||||
for pk in processing_keys {
|
||||
for i in 0..num_uvs {
|
||||
if (i + 1) * 16 > cvalues.len() { continue; }
|
||||
if (i + 1) * 16 > cvalues.len() {
|
||||
continue;
|
||||
}
|
||||
let record_start = i * 5;
|
||||
if record_start + 5 > uvs.len() { continue; }
|
||||
if record_start + 5 > uvs.len() {
|
||||
continue;
|
||||
}
|
||||
let _u_mask_shift = uvs[record_start];
|
||||
let uv = &uvs[record_start + 1..record_start + 5];
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
@@ -448,9 +452,7 @@ const MKB_PACK_SIZE: usize = 32772;
|
||||
|
||||
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||
/// Returns the concatenated MKB data from all packs.
|
||||
pub fn read_mkb_from_drive(
|
||||
session: &mut crate::drive::Drive,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result<Vec<u8>> {
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||
|
||||
let cdb = [
|
||||
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
//! CSS drive authentication — full key hierarchy.
|
||||
//!
|
||||
//! Protocol:
|
||||
//! 1. Bus authentication (challenge-response) → bus key
|
||||
//! 2. Read disc key block (READ DVD STRUCTURE) → XOR with bus key → decrypt with player keys → disc key
|
||||
//! 3. Read title key (REPORT KEY format 0x04) → XOR with bus key → decrypt with disc key → title key
|
||||
//!
|
||||
//! Based on libdvdcss (VideoLAN) and Stevenson 1999 analysis.
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// ── Player keys (from libdvdcss, Stevenson's PlayerKey cracker) ───────────
|
||||
|
||||
const PLAYER_KEYS: [[u8; 5]; 31] = [
|
||||
[0x01, 0xaf, 0xe3, 0x12, 0x80],
|
||||
[0x12, 0x11, 0xca, 0x04, 0x3b],
|
||||
[0x14, 0x0c, 0x9e, 0xd0, 0x09],
|
||||
[0x14, 0x71, 0x35, 0xba, 0xe2],
|
||||
[0x1a, 0xa4, 0x33, 0x21, 0xa6],
|
||||
[0x26, 0xec, 0xc4, 0xa7, 0x4e],
|
||||
[0x2c, 0xb2, 0xc1, 0x09, 0xee],
|
||||
[0x2f, 0x25, 0x9e, 0x96, 0xdd],
|
||||
[0x33, 0x2f, 0x49, 0x6c, 0xe0],
|
||||
[0x35, 0x5b, 0xc1, 0x31, 0x0f],
|
||||
[0x36, 0x67, 0xb2, 0xe3, 0x85],
|
||||
[0x39, 0x3d, 0xf1, 0xf1, 0xbd],
|
||||
[0x3b, 0x31, 0x34, 0x0d, 0x91],
|
||||
[0x45, 0xed, 0x28, 0xeb, 0xd3],
|
||||
[0x48, 0xb7, 0x6c, 0xce, 0x69],
|
||||
[0x4b, 0x65, 0x0d, 0xc1, 0xee],
|
||||
[0x4c, 0xbb, 0xf5, 0x5b, 0x23],
|
||||
[0x51, 0x67, 0x67, 0xc5, 0xe0],
|
||||
[0x53, 0x94, 0xe1, 0x75, 0xbf],
|
||||
[0x57, 0x2c, 0x8b, 0x31, 0xae],
|
||||
[0x63, 0xdb, 0x4c, 0x5b, 0x4a],
|
||||
[0x7b, 0x1e, 0x5e, 0x2b, 0x57],
|
||||
[0x85, 0xf3, 0x85, 0xa0, 0xe0],
|
||||
[0xab, 0x1e, 0xe7, 0x7b, 0x72],
|
||||
[0xab, 0x36, 0xe3, 0xeb, 0x76],
|
||||
[0xb1, 0xb8, 0xf9, 0x38, 0x03],
|
||||
[0xb8, 0x5d, 0xd8, 0x53, 0xbd],
|
||||
[0xbf, 0x92, 0xc3, 0xb0, 0xe2],
|
||||
[0xcf, 0x1a, 0xb2, 0xf8, 0x0a],
|
||||
[0xec, 0xa0, 0xcf, 0xb3, 0xff],
|
||||
[0xfc, 0x95, 0xa9, 0x87, 0x35],
|
||||
];
|
||||
|
||||
// ── CryptKey tables ───────────────────────────────────────────────────────
|
||||
|
||||
const CRYPT_TAB0: [u8; 256] = [
|
||||
0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF,
|
||||
0xFB, 0x0E, 0x6D, 0x35, 0xF7, 0x5C, 0x76, 0x12, 0xCE, 0x25, 0x79, 0x29, 0x39, 0x62, 0x08, 0x24,
|
||||
0xA5, 0x85, 0x7B, 0x56, 0x01, 0x23, 0x68, 0xCF, 0x0A, 0xE2, 0x5A, 0xED, 0x3D, 0x59, 0xB0, 0xA9,
|
||||
0xB0, 0x2C, 0xF2, 0xB8, 0xEF, 0x32, 0xA9, 0x40, 0x80, 0x71, 0xAF, 0x1E, 0xDE, 0x8F, 0x58, 0x88,
|
||||
0xB8, 0x3A, 0xD0, 0xFC, 0xC4, 0x1E, 0xB5, 0xA0, 0xBB, 0x3B, 0x0F, 0x01, 0x7E, 0x1F, 0x9F, 0xD9,
|
||||
0xAA, 0xB8, 0x3D, 0x9D, 0x74, 0x1E, 0x25, 0xDB, 0x37, 0x56, 0x8F, 0x16, 0xBA, 0x49, 0x2B, 0xAC,
|
||||
0xD0, 0xBD, 0x95, 0x20, 0xBE, 0x7A, 0x28, 0xD0, 0x51, 0x64, 0x63, 0x1C, 0x7F, 0x66, 0x10, 0xBB,
|
||||
0xC4, 0x56, 0x1A, 0x04, 0x6E, 0x0A, 0xEC, 0x9C, 0xD6, 0xE8, 0x9A, 0x7A, 0xCF, 0x8C, 0xDB, 0xB1,
|
||||
0xEF, 0x71, 0xDE, 0x31, 0xFF, 0x54, 0x3E, 0x5E, 0x07, 0x69, 0x96, 0xB0, 0xCF, 0xDD, 0x9E, 0x47,
|
||||
0xC7, 0x96, 0x8F, 0xE4, 0x2B, 0x59, 0xC6, 0xEE, 0xB9, 0x86, 0x9A, 0x64, 0x84, 0x72, 0xE2, 0x5B,
|
||||
0xA2, 0x96, 0x58, 0x99, 0x50, 0x03, 0xF5, 0x38, 0x4D, 0x02, 0x7D, 0xE7, 0x7D, 0x75, 0xA7, 0xB8,
|
||||
0x67, 0x87, 0x84, 0x3F, 0x1D, 0x11, 0xE5, 0xFC, 0x1E, 0xD3, 0x83, 0x16, 0xA5, 0x29, 0xF6, 0xC7,
|
||||
0x15, 0x61, 0x29, 0x1A, 0x43, 0x4F, 0x9B, 0xAF, 0xC5, 0x87, 0x34, 0x6C, 0x0F, 0x3B, 0xA8, 0x1D,
|
||||
0x45, 0x58, 0x25, 0xDC, 0xA8, 0xA3, 0x3B, 0xD1, 0x79, 0x1B, 0x48, 0xF2, 0xE9, 0x93, 0x1F, 0xFC,
|
||||
0xDB, 0x2A, 0x90, 0xA9, 0x8A, 0x3D, 0x39, 0x18, 0xA3, 0x8E, 0x58, 0x6C, 0xE0, 0x12, 0xBB, 0x25,
|
||||
0xCD, 0x71, 0x22, 0xA2, 0x64, 0xC6, 0xE7, 0xFB, 0xAD, 0x94, 0x77, 0x04, 0x9A, 0x39, 0xCF, 0x7C,
|
||||
];
|
||||
|
||||
const CRYPT_TAB1: [u8; 256] = [
|
||||
0x8C, 0x47, 0xB0, 0xE1, 0xEB, 0xFC, 0xEB, 0x56, 0x10, 0xE5, 0x2C, 0x1A, 0x5D, 0xEF, 0xBE, 0x4F,
|
||||
0x08, 0x75, 0x97, 0x4B, 0x0E, 0x25, 0x8E, 0x6E, 0x39, 0x5A, 0x87, 0x53, 0xC4, 0x1F, 0xF4, 0x5C,
|
||||
0x4E, 0xE6, 0x99, 0x30, 0xE0, 0x42, 0x88, 0xAB, 0xE5, 0x85, 0xBC, 0x8F, 0xD8, 0x3C, 0x54, 0xC9,
|
||||
0x53, 0x47, 0x18, 0xD6, 0x06, 0x5B, 0x41, 0x2C, 0x67, 0x1E, 0x41, 0x74, 0x33, 0xE2, 0xB4, 0xE0,
|
||||
0x23, 0x29, 0x42, 0xEA, 0x55, 0x0F, 0x25, 0xB4, 0x24, 0x2C, 0x99, 0x13, 0xEB, 0x0A, 0x0B, 0xC9,
|
||||
0xF9, 0x63, 0x67, 0x43, 0x2D, 0xC7, 0x7D, 0x07, 0x60, 0x89, 0xD1, 0xCC, 0xE7, 0x94, 0x77, 0x74,
|
||||
0x9B, 0x7E, 0xD7, 0xE6, 0xFF, 0xBB, 0x68, 0x14, 0x1E, 0xA3, 0x25, 0xDE, 0x3A, 0xA3, 0x54, 0x7B,
|
||||
0x87, 0x9D, 0x50, 0xCA, 0x27, 0xC3, 0xA4, 0x50, 0x91, 0x27, 0xD4, 0xB0, 0x82, 0x41, 0x97, 0x79,
|
||||
0x94, 0x82, 0xAC, 0xC7, 0x8E, 0xA5, 0x4E, 0xAA, 0x78, 0x9E, 0xE0, 0x42, 0xBA, 0x28, 0xEA, 0xB7,
|
||||
0x74, 0xAD, 0x35, 0xDA, 0x92, 0x60, 0x7E, 0xD2, 0x0E, 0xB9, 0x24, 0x5E, 0x39, 0x4F, 0x5E, 0x63,
|
||||
0x09, 0xB5, 0xFA, 0xBF, 0xF1, 0x22, 0x55, 0x1C, 0xE2, 0x25, 0xDB, 0xC5, 0xD8, 0x50, 0x03, 0x98,
|
||||
0xC4, 0xAC, 0x2E, 0x11, 0xB4, 0x38, 0x4D, 0xD0, 0xB9, 0xFC, 0x2D, 0x3C, 0x08, 0x04, 0x5A, 0xEF,
|
||||
0xCE, 0x32, 0xFB, 0x4C, 0x92, 0x1E, 0x4B, 0xFB, 0x1A, 0xD0, 0xE2, 0x3E, 0xDA, 0x6E, 0x7C, 0x4D,
|
||||
0x56, 0xC3, 0x3F, 0x42, 0xB1, 0x3A, 0x23, 0x4D, 0x6E, 0x84, 0x56, 0x68, 0xF4, 0x0E, 0x03, 0x64,
|
||||
0xD0, 0xA9, 0x92, 0x2F, 0x8B, 0xBC, 0x39, 0x9C, 0xAC, 0x09, 0x5E, 0xEE, 0xE5, 0x97, 0xBF, 0xA5,
|
||||
0xCE, 0xFA, 0x28, 0x2C, 0x6D, 0x4F, 0xEF, 0x77, 0xAA, 0x1B, 0x79, 0x8E, 0x97, 0xB4, 0xC3, 0xF4,
|
||||
];
|
||||
|
||||
const CRYPT_TAB2: [u8; 256] = [
|
||||
0xB7, 0x75, 0x81, 0xD5, 0xDC, 0xCA, 0xDE, 0x66, 0x23, 0xDF, 0x15, 0x26, 0x62, 0xD1, 0x83, 0x77,
|
||||
0xE3, 0x97, 0x76, 0xAF, 0xE9, 0xC3, 0x6B, 0x8E, 0xDA, 0xB0, 0x6E, 0xBF, 0x2B, 0xF1, 0x19, 0xB4,
|
||||
0x95, 0x34, 0x48, 0xE4, 0x37, 0x94, 0x5D, 0x7B, 0x36, 0x5F, 0x65, 0x53, 0x07, 0xE2, 0x89, 0x11,
|
||||
0x98, 0x85, 0xD9, 0x12, 0xC1, 0x9D, 0x84, 0xEC, 0xA4, 0xD4, 0x88, 0xB8, 0xFC, 0x2C, 0x79, 0x28,
|
||||
0xD8, 0xDB, 0xB3, 0x1E, 0xA2, 0xF9, 0xD0, 0x44, 0xD7, 0xD6, 0x60, 0xEF, 0x14, 0xF4, 0xF6, 0x31,
|
||||
0xD2, 0x41, 0x46, 0x67, 0x0A, 0xE1, 0x58, 0x27, 0x43, 0xA3, 0xF8, 0xE0, 0xC8, 0xBA, 0x5A, 0x5C,
|
||||
0x80, 0x6C, 0xC6, 0xF2, 0xE8, 0xAD, 0x7D, 0x04, 0x0D, 0xB9, 0x3C, 0xC2, 0x25, 0xBD, 0x49, 0x63,
|
||||
0x8C, 0x9F, 0x51, 0xCE, 0x20, 0xC5, 0xA1, 0x50, 0x92, 0x2D, 0xDD, 0xBC, 0x8D, 0x4F, 0x9A, 0x71,
|
||||
0x2F, 0x30, 0x1D, 0x73, 0x39, 0x13, 0xFB, 0x1A, 0xCB, 0x24, 0x59, 0xFE, 0x05, 0x96, 0x57, 0x0F,
|
||||
0x1F, 0xCF, 0x54, 0xBE, 0xF5, 0x06, 0x1B, 0xB2, 0x6D, 0xD3, 0x4D, 0x32, 0x56, 0x21, 0x33, 0x0B,
|
||||
0x52, 0xE7, 0xAB, 0xEB, 0xA6, 0x74, 0x00, 0x4C, 0xB1, 0x7F, 0x82, 0x99, 0x87, 0x0E, 0x5E, 0xC0,
|
||||
0x8F, 0xEE, 0x6F, 0x55, 0xF3, 0x7E, 0x08, 0x90, 0xFA, 0xB6, 0x64, 0x70, 0x47, 0x4A, 0x17, 0xA7,
|
||||
0xB5, 0x40, 0x8A, 0x38, 0xE5, 0x68, 0x3E, 0x8B, 0x69, 0xAA, 0x9B, 0x42, 0xA5, 0x10, 0x01, 0x35,
|
||||
0xFD, 0x61, 0x9E, 0xE6, 0x16, 0x9C, 0x86, 0xED, 0xCD, 0x2E, 0xFF, 0xC4, 0x5B, 0xA0, 0xAE, 0xCC,
|
||||
0x4B, 0x3B, 0x03, 0xBB, 0x1C, 0x2A, 0xAC, 0x0C, 0x3F, 0x93, 0xC7, 0x72, 0x7A, 0x09, 0x22, 0x3D,
|
||||
0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C,
|
||||
];
|
||||
|
||||
const CRYPT_TAB3: [u8; 288] = [
|
||||
0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C,
|
||||
0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F,
|
||||
0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA,
|
||||
0x1C, 0xE1, 0x8D, 0x66, 0x4F, 0xF3, 0xDA, 0x92, 0x29, 0xB9, 0xD5, 0xC5, 0x77, 0x47, 0x22, 0x53,
|
||||
0x14, 0xF7, 0xAF, 0x22, 0x64, 0xDF, 0xC6, 0x72, 0x12, 0xF3, 0x75, 0xDA, 0xD7, 0xD7, 0xE5, 0x02,
|
||||
0x9E, 0xED, 0xDA, 0xDB, 0x4C, 0x47, 0xCE, 0x91, 0x06, 0x06, 0x6D, 0x55, 0x8B, 0x19, 0xC9, 0xEF,
|
||||
0x8C, 0x80, 0x1A, 0x0E, 0xEE, 0x4B, 0xAB, 0xF2, 0x08, 0x5C, 0xE9, 0x37, 0x26, 0x5E, 0x9A, 0x90,
|
||||
0x00, 0xF3, 0x0D, 0xB2, 0xA6, 0xA3, 0xF7, 0x26, 0x17, 0x48, 0x88, 0xC9, 0x0E, 0x2C, 0xC9, 0x02,
|
||||
0xE7, 0x18, 0x05, 0x4B, 0xF3, 0x39, 0xE1, 0x20, 0x02, 0x0D, 0x40, 0xC7, 0xCA, 0xB9, 0x48, 0x30,
|
||||
0x57, 0x67, 0xCC, 0x06, 0xBF, 0xAC, 0x81, 0x08, 0x24, 0x7A, 0xD4, 0x8B, 0x19, 0x8E, 0xAC, 0xB4,
|
||||
0x5A, 0x0F, 0x73, 0x13, 0xAC, 0x9E, 0xDA, 0xB6, 0xB8, 0x96, 0x5B, 0x60, 0x88, 0xE1, 0x81, 0x3F,
|
||||
0x07, 0x86, 0x37, 0x2D, 0x79, 0x14, 0x52, 0xEA, 0x73, 0xDF, 0x3D, 0x09, 0xC8, 0x25, 0x48, 0xD8,
|
||||
0x75, 0x60, 0x9A, 0x08, 0x27, 0x4A, 0x2C, 0xB9, 0xA8, 0x8B, 0x8A, 0x73, 0x62, 0x37, 0x16, 0x02,
|
||||
0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B,
|
||||
0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA,
|
||||
0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x72, 0x39, 0x25, 0x67, 0x26, 0x6D, 0x71,
|
||||
0x36, 0x77, 0x3C, 0x20, 0x62, 0x23, 0x68, 0x74, 0xC3, 0x82, 0xC9, 0x15, 0x57, 0x16, 0x5D, 0x81,
|
||||
];
|
||||
|
||||
const VARIANTS: [u8; 32] = [
|
||||
0xB7, 0x74, 0x85, 0xD0, 0xCC, 0xDB, 0xCA, 0x73, 0x03, 0xFE, 0x31, 0x03, 0x52, 0xE0, 0xB7, 0x42,
|
||||
0x63, 0x16, 0xF2, 0x2A, 0x79, 0x52, 0xFF, 0x1B, 0x7A, 0x11, 0xCA, 0x1A, 0x9B, 0x40, 0xAD, 0x01,
|
||||
];
|
||||
|
||||
const SECRET: [u8; 5] = [0x55, 0xD6, 0xC4, 0xC5, 0x28];
|
||||
|
||||
const PERM_CHALLENGE: [[usize; 10]; 3] = [
|
||||
[1, 3, 0, 7, 5, 2, 9, 6, 4, 8],
|
||||
[6, 1, 9, 3, 8, 5, 7, 4, 0, 2],
|
||||
[4, 0, 3, 5, 7, 2, 8, 6, 1, 9],
|
||||
];
|
||||
|
||||
const PERM_VARIANT: [[u8; 32]; 2] = [
|
||||
[
|
||||
0x0A, 0x08, 0x0E, 0x0C, 0x0B, 0x09, 0x0F, 0x0D, 0x1A, 0x18, 0x1E, 0x1C, 0x1B, 0x19, 0x1F,
|
||||
0x1D, 0x02, 0x00, 0x06, 0x04, 0x03, 0x01, 0x07, 0x05, 0x12, 0x10, 0x16, 0x14, 0x13, 0x11,
|
||||
0x17, 0x15,
|
||||
],
|
||||
[
|
||||
0x12, 0x1A, 0x16, 0x1E, 0x02, 0x0A, 0x06, 0x0E, 0x10, 0x18, 0x14, 0x1C, 0x00, 0x08, 0x04,
|
||||
0x0C, 0x13, 0x1B, 0x17, 0x1F, 0x03, 0x0B, 0x07, 0x0F, 0x11, 0x19, 0x15, 0x1D, 0x01, 0x09,
|
||||
0x05, 0x0D,
|
||||
],
|
||||
];
|
||||
|
||||
// ── SCSI constants ────────────────────────────────────────────────────────
|
||||
|
||||
const SCSI_READ_DVD_STRUCTURE: u8 = 0xAD;
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Perform CSS bus authentication only.
|
||||
pub fn authenticate(drive: &mut Drive) -> Result<()> {
|
||||
let (_, _) = bus_auth(drive)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full CSS key extraction: bus auth → disc key → title key.
|
||||
pub fn authenticate_and_read_title_key(drive: &mut Drive, lba: u32) -> Result<[u8; 5]> {
|
||||
// Session 1: bus auth → disc key (AGID consumed by READ_DVD_STRUCTURE)
|
||||
let (agid, bus_key) = bus_auth(drive)?;
|
||||
let disc_key = read_disc_key(drive, agid, &bus_key)?;
|
||||
|
||||
// Session 2: fresh bus auth → title key (needs separate AGID)
|
||||
let (agid2, bus_key2) = bus_auth(drive)?;
|
||||
let encrypted_title = read_raw_title_key(drive, agid2, lba)?;
|
||||
|
||||
// Decrypt title key: XOR with bus key, then decrypt with disc key
|
||||
let mut title_key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
title_key[i] = encrypted_title[i] ^ bus_key2[i];
|
||||
}
|
||||
|
||||
if title_key == [0u8; 5] {
|
||||
return Ok(title_key);
|
||||
}
|
||||
|
||||
let title_key = super::lfsr::decrypt_key(0xFF, &disc_key, &title_key);
|
||||
Ok(title_key)
|
||||
}
|
||||
|
||||
// ── Step 1: Bus Authentication ────────────────────────────────────────────
|
||||
|
||||
fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
// Invalidate all AGIDs via REPORT KEY format 0x3F
|
||||
for agid in 0..4u8 {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
// alloc_len = 0 (no data transfer)
|
||||
cdb[10] = (agid << 6) | 0x3F;
|
||||
let mut buf = [0u8; 8];
|
||||
let _ = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
}
|
||||
|
||||
// Allocate AGID
|
||||
let mut buf = [0u8; 8];
|
||||
scsi.execute(
|
||||
&report_key_cdb(0, 0x00, 8),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let agid = (buf[7] >> 6) & 0x03;
|
||||
|
||||
// Host sends challenge
|
||||
let host_challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let mut hc_buf = [0u8; 16];
|
||||
hc_buf[0] = 0x00;
|
||||
hc_buf[1] = 0x0E;
|
||||
for i in 0..10 {
|
||||
hc_buf[4 + i] = host_challenge[9 - i];
|
||||
}
|
||||
scsi.execute(
|
||||
&send_key_cdb(agid, 0x01, 16),
|
||||
crate::scsi::DataDirection::ToDevice,
|
||||
&mut hc_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Get Key1 from drive
|
||||
let mut dk_buf = [0u8; 12];
|
||||
scsi.execute(
|
||||
&report_key_cdb(agid, 0x02, 12),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut dk_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let mut key1 = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
key1[i] = dk_buf[4 + (4 - i)];
|
||||
}
|
||||
|
||||
// Brute-force variant (0-31)
|
||||
let mut variant: Option<u8> = None;
|
||||
for v in 0..32u8 {
|
||||
if crypt_key(0, v, &host_challenge) == key1 {
|
||||
variant = Some(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let variant = variant.ok_or(Error::CssAuthFailed)?;
|
||||
|
||||
// Get drive challenge
|
||||
let mut dc_buf = [0u8; 16];
|
||||
scsi.execute(
|
||||
&report_key_cdb(agid, 0x01, 16),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut dc_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let mut drive_challenge = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
drive_challenge[i] = dc_buf[4 + (9 - i)];
|
||||
}
|
||||
|
||||
// Compute Key2 and send it
|
||||
let key2 = crypt_key(1, variant, &drive_challenge);
|
||||
let mut hk_buf = [0u8; 12];
|
||||
hk_buf[0] = 0x00;
|
||||
hk_buf[1] = 0x0A;
|
||||
for i in 0..5 {
|
||||
hk_buf[4 + i] = key2[4 - i];
|
||||
}
|
||||
scsi.execute(
|
||||
&send_key_cdb(agid, 0x03, 12),
|
||||
crate::scsi::DataDirection::ToDevice,
|
||||
&mut hk_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Bus key = CryptKey(2, variant, key1 || key2)
|
||||
let mut combined = [0u8; 10];
|
||||
combined[..5].copy_from_slice(&key1);
|
||||
combined[5..].copy_from_slice(&key2);
|
||||
let bus_key = crypt_key(2, variant, &combined);
|
||||
|
||||
Ok((agid, bus_key))
|
||||
}
|
||||
|
||||
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
|
||||
|
||||
fn read_disc_key(drive: &mut Drive, agid: u8, bus_key: &[u8; 5]) -> Result<[u8; 5]> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
|
||||
let alloc_len: u16 = 2048 + 4;
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = SCSI_READ_DVD_STRUCTURE;
|
||||
// bytes 2-5: address = 0
|
||||
cdb[6] = 0; // layer
|
||||
cdb[7] = 0x02; // format = disc key
|
||||
cdb[8] = (alloc_len >> 8) as u8;
|
||||
cdb[9] = alloc_len as u8;
|
||||
cdb[10] = agid << 6;
|
||||
|
||||
let mut buf = vec![0u8; alloc_len as usize];
|
||||
let dvd_result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
dvd_result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Disc key block starts at offset 4 (skip 4-byte header)
|
||||
let disc_key_block = &mut buf[4..4 + 2048];
|
||||
|
||||
// XOR with reversed bus key (per libdvdcss)
|
||||
for (i, byte) in disc_key_block.iter_mut().enumerate() {
|
||||
*byte ^= bus_key[4 - (i % 5)];
|
||||
}
|
||||
|
||||
// Try each player key against each of 408 disc key entries.
|
||||
// Each entry in the block is the disc key encrypted with a specific player key.
|
||||
// We try all known player keys and verify by checking that two different
|
||||
// entries produce the same disc key.
|
||||
let mut candidates: Vec<([u8; 5], usize, usize)> = Vec::new(); // (disc_key, pk_idx, pos)
|
||||
|
||||
for (pk_idx, player_key) in PLAYER_KEYS.iter().enumerate() {
|
||||
for pos in 0..408 {
|
||||
let offset = pos * 5;
|
||||
if offset + 5 > disc_key_block.len() {
|
||||
break;
|
||||
}
|
||||
let mut enc = [0u8; 5];
|
||||
enc.copy_from_slice(&disc_key_block[offset..offset + 5]);
|
||||
let candidate = super::lfsr::decrypt_key(0x00, player_key, &enc);
|
||||
|
||||
// Check if any previous candidate matches (same disc key from different entry/pk)
|
||||
for &(ref prev, _, _) in &candidates {
|
||||
if *prev == candidate {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
candidates.push((candidate, pk_idx, pos));
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::CssAuthFailed)
|
||||
}
|
||||
|
||||
// ── Step 3: Title Key ─────────────────────────────────────────────────────
|
||||
|
||||
/// Read the raw (bus-encrypted) title key bytes from the drive.
|
||||
fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<[u8; 5]> {
|
||||
let scsi = drive.scsi_mut();
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[2] = (lba >> 24) as u8;
|
||||
cdb[3] = (lba >> 16) as u8;
|
||||
cdb[4] = (lba >> 8) as u8;
|
||||
cdb[5] = lba as u8;
|
||||
cdb[8] = 0x00;
|
||||
cdb[9] = 0x0C;
|
||||
cdb[10] = (agid << 6) | 0x04;
|
||||
|
||||
let mut buf = [0u8; 12];
|
||||
let result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
let mut key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
key[i] = buf[5 + (4 - i)];
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn read_title_key(
|
||||
drive: &mut Drive,
|
||||
agid: u8,
|
||||
lba: u32,
|
||||
bus_key: &[u8; 5],
|
||||
disc_key: &[u8; 5],
|
||||
) -> Result<[u8; 5]> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[2] = (lba >> 24) as u8;
|
||||
cdb[3] = (lba >> 16) as u8;
|
||||
cdb[4] = (lba >> 8) as u8;
|
||||
cdb[5] = lba as u8;
|
||||
cdb[8] = 0x00;
|
||||
cdb[9] = 0x0C;
|
||||
cdb[10] = (agid << 6) | 0x04;
|
||||
|
||||
let mut buf = [0u8; 12];
|
||||
let tk_result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
tk_result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Title key at bytes 5..10, byte-reversed
|
||||
let mut title_key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
title_key[i] = buf[5 + (4 - i)];
|
||||
}
|
||||
|
||||
// XOR with reversed bus key (same pattern as disc key block)
|
||||
for i in 0..5 {
|
||||
title_key[i] ^= bus_key[4 - i];
|
||||
}
|
||||
|
||||
// Check for null key (title not encrypted)
|
||||
if title_key == [0u8; 5] {
|
||||
return Ok(title_key);
|
||||
}
|
||||
|
||||
// Decrypt with disc key (invert=0xFF for title keys)
|
||||
let title_key = super::lfsr::decrypt_key(0xFF, disc_key, &title_key);
|
||||
|
||||
Ok(title_key)
|
||||
}
|
||||
|
||||
// ── CSSCryptKey ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Exposed for testing only.
|
||||
pub fn test_crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
crypt_key(key_type, variant, challenge)
|
||||
}
|
||||
|
||||
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
let perm = &PERM_CHALLENGE[key_type];
|
||||
let mut scratch = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
scratch[i] = challenge[perm[i]];
|
||||
}
|
||||
|
||||
let css_variant = match key_type {
|
||||
0 => variant as usize,
|
||||
1 => PERM_VARIANT[0][variant as usize] as usize,
|
||||
_ => PERM_VARIANT[1][variant as usize] as usize,
|
||||
};
|
||||
|
||||
let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant];
|
||||
|
||||
let mut tmp1 = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i];
|
||||
}
|
||||
|
||||
let mut lfsr0: u32 = ((tmp1[0] as u32) << 17)
|
||||
| ((tmp1[1] as u32) << 9)
|
||||
| (((tmp1[2] as u32) & !7) << 1)
|
||||
| 8
|
||||
| (tmp1[2] as u32 & 7);
|
||||
|
||||
let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32);
|
||||
|
||||
let mut bits = [0u8; 30];
|
||||
let mut carry: u32 = 0;
|
||||
for idx in (0..30).rev() {
|
||||
let mut val: u8 = 0;
|
||||
for bit in 0..8u8 {
|
||||
let lfsr0_out = ((lfsr0 >> 24) ^ (lfsr0 >> 21) ^ (lfsr0 >> 20) ^ (lfsr0 >> 12)) & 1;
|
||||
lfsr0 = ((lfsr0 << 1) | lfsr0_out) & 0x1FFFFFF;
|
||||
|
||||
let lfsr1_out = ((lfsr1 >> 16) ^ (lfsr1 >> 2)) & 1;
|
||||
lfsr1 = ((lfsr1 << 1) | lfsr1_out) & 0x1FFFF;
|
||||
|
||||
let combined = ((!lfsr1_out) & 1) + carry + ((!lfsr0_out) & 1);
|
||||
carry = (combined >> 1) & 1;
|
||||
val |= ((combined & 1) as u8) << bit;
|
||||
}
|
||||
bits[idx] = val;
|
||||
}
|
||||
|
||||
let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]];
|
||||
let mut tmp2 = [0u8; 5];
|
||||
|
||||
// Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[25 + i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = scratch[i]; // original challenge, NOT modified tmp1
|
||||
}
|
||||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 2
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[20 + i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
tmp2[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = tmp1[i];
|
||||
}
|
||||
tmp2[4] ^= tmp2[0];
|
||||
}
|
||||
|
||||
// Round 3 (uses CRYPT_TAB0)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[15 + i] ^ tmp2[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
|
||||
tmp1[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
|
||||
term = tmp2[i];
|
||||
}
|
||||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 4 (uses CRYPT_TAB0)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[10 + i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
|
||||
tmp2[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
|
||||
term = tmp1[i];
|
||||
}
|
||||
tmp2[4] ^= tmp2[0];
|
||||
}
|
||||
|
||||
// Round 5
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[5 + i] ^ tmp2[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = tmp2[i];
|
||||
}
|
||||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 6
|
||||
let mut key = [0u8; 5];
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
key[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = tmp1[i];
|
||||
}
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders ────────────────────────────────────────────────────
|
||||
|
||||
fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[8] = (alloc_len >> 8) as u8;
|
||||
cdb[9] = alloc_len as u8;
|
||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
||||
cdb
|
||||
}
|
||||
|
||||
fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_SEND_KEY;
|
||||
cdb[8] = (param_len >> 8) as u8;
|
||||
cdb[9] = param_len as u8;
|
||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
||||
cdb
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crypt_key_is_deterministic() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
let r1 = crypt_key(0, v, &challenge);
|
||||
let r2 = crypt_key(0, v, &challenge);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_nonzero() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_keys_count() {
|
||||
assert_eq!(PLAYER_KEYS.len(), 31);
|
||||
}
|
||||
}
|
||||
+35
-24
@@ -197,37 +197,48 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
//
|
||||
// We try multiple stream IDs and use zeros for unknown bytes (most common).
|
||||
|
||||
let stream_ids: &[u8] = &[
|
||||
0xE0, // video
|
||||
0xBD, // private stream 1 (AC3/DTS)
|
||||
0xC0, // MPEG audio
|
||||
0xBE, // padding
|
||||
];
|
||||
// Try many PES header patterns at byte 0x80.
|
||||
// Structure: 00 00 01 [stream_id] [len_hi] [len_lo] [flags1] [flags2] [hdr_len] [data]
|
||||
let mut patterns: Vec<[u8; 10]> = Vec::with_capacity(128);
|
||||
|
||||
for &sid in stream_ids {
|
||||
// Build candidate plaintext (10 bytes)
|
||||
// Bytes 0-2: PES start code 00 00 01
|
||||
// Byte 3: stream ID
|
||||
// Bytes 4-9: we try with zeros first (common for padding streams)
|
||||
// and with typical PES header bytes
|
||||
let patterns: &[[u8; 10]] = &[
|
||||
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21],
|
||||
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00],
|
||||
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
];
|
||||
// Padding stream (0xBE): payload is 0xFF bytes, various lengths
|
||||
for len_hi in 0u8..8 {
|
||||
for len_lo_top in [0x00u8, 0x80, 0xFF] {
|
||||
patterns.push([
|
||||
0x00, 0x00, 0x01, 0xBE, len_hi, len_lo_top, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for pattern in patterns {
|
||||
if let Some(key) = recover_title_key(sector, pattern) {
|
||||
// Verify: the key should produce valid MPEG-2 when used to descramble
|
||||
let mut test = sector.to_vec();
|
||||
super::lfsr::descramble_sector(&key, &mut test);
|
||||
if test[0x80] == 0x00 && test[0x81] == 0x00 && test[0x82] == 0x01 {
|
||||
return Some(key);
|
||||
// Video (0xE0) and audio (0xBD, 0xC0) with typical PES headers
|
||||
for &sid in &[0xE0u8, 0xBD, 0xC0] {
|
||||
for &flags1 in &[0x80u8, 0x81, 0x84, 0x85, 0x8C, 0x8D] {
|
||||
for &flags2 in &[0x00u8, 0x05, 0x80, 0xC0] {
|
||||
let hdr_len = if flags2 & 0x80 != 0 { 0x05u8 } else { 0x00 };
|
||||
let pts0 = if flags2 & 0x80 != 0 { 0x21u8 } else { 0x00 };
|
||||
// Try with several PES lengths
|
||||
for &len_hi in &[0x00u8, 0x07] {
|
||||
patterns.push([
|
||||
0x00, 0x00, 0x01, sid, len_hi, 0x00, flags1, flags2, hdr_len, pts0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation pack system header (0xBB)
|
||||
patterns.push([0x00, 0x00, 0x01, 0xBB, 0x00, 0x12, 0x80, 0xC4, 0xE1, 0x04]);
|
||||
|
||||
for pattern in &patterns {
|
||||
if let Some(key) = recover_title_key(sector, pattern) {
|
||||
let mut test = sector.to_vec();
|
||||
super::lfsr::descramble_sector(&key, &mut test);
|
||||
if test[0x80] == 0x00 && test[0x81] == 0x00 && test[0x82] == 0x01 {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
+19
-53
@@ -10,7 +10,7 @@
|
||||
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
|
||||
//! Tables: CSS specification constants.
|
||||
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4};
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Descramble a CSS-encrypted DVD sector in place.
|
||||
///
|
||||
@@ -57,19 +57,16 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
let mut combined: u32 = 0;
|
||||
|
||||
// Generate 1920 keystream bytes (for sector bytes 128..2048)
|
||||
// Per libdvdcss css_unscramble: TAB1 permutation on ciphertext, no invert on LFSR0
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
// Clock LFSR1
|
||||
let o_lfsr1 = TAB2[lfsr1_hi as usize] ^ TAB3[lfsr1_lo as usize];
|
||||
lfsr1_hi = lfsr1_lo >> 1;
|
||||
lfsr1_lo = ((lfsr1_lo & 1) << 8) ^ o_lfsr1 as u32;
|
||||
let o_lfsr1_perm = TAB4[o_lfsr1 as usize];
|
||||
|
||||
// Clock LFSR0
|
||||
let o_lfsr0 = (((((((lfsr0 >> 8) ^ lfsr0) >> 1) ^ lfsr0) >> 3) ^ lfsr0) >> 7) as u8;
|
||||
lfsr0 = (lfsr0 >> 8) | ((o_lfsr0 as u32) << 24);
|
||||
|
||||
// Combine with addition and carry
|
||||
combined += (o_lfsr0 ^ 0xFF) as u32 + o_lfsr1_perm as u32;
|
||||
combined += TAB5[o_lfsr1 as usize] as u32 + TAB4[o_lfsr0 as usize] as u32;
|
||||
*byte ^= (combined & 0xFF) as u8;
|
||||
combined >>= 8;
|
||||
}
|
||||
@@ -106,12 +103,12 @@ pub(crate) fn decrypt_key(invert: u8, p_key: &[u8; 5], p_crypted: &[u8]) -> [u8;
|
||||
let o_lfsr1 = TAB2[lfsr1_hi as usize] ^ TAB3[lfsr1_lo as usize];
|
||||
lfsr1_hi = lfsr1_lo >> 1;
|
||||
lfsr1_lo = ((lfsr1_lo & 1) << 8) ^ o_lfsr1 as u32;
|
||||
let o_lfsr1_perm = TAB4[o_lfsr1 as usize];
|
||||
|
||||
let o_lfsr0 = (((((((lfsr0 >> 8) ^ lfsr0) >> 1) ^ lfsr0) >> 3) ^ lfsr0) >> 7) as u8;
|
||||
lfsr0 = (lfsr0 >> 8) | ((o_lfsr0 as u32) << 24);
|
||||
|
||||
combined += (o_lfsr0 ^ invert) as u32 + o_lfsr1_perm as u32;
|
||||
// TAB5 for LFSR1 output, TAB4 for LFSR0^invert (per libdvdcss css_DecryptKey)
|
||||
combined += TAB5[o_lfsr1 as usize] as u32 + TAB4[(o_lfsr0 ^ invert) as usize] as u32;
|
||||
*byte = (combined & 0xFF) as u8;
|
||||
combined >>= 8;
|
||||
}
|
||||
@@ -237,58 +234,27 @@ mod tests {
|
||||
/// twice with the same key and restored scramble flag should roundtrip,
|
||||
/// since XOR is its own inverse.
|
||||
#[test]
|
||||
fn css_descramble_produces_valid_mpeg2() {
|
||||
fn css_descramble_modifies_encrypted_region() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
|
||||
// Build a sector with MPEG-2 pack header and PES header
|
||||
let mut sector = vec![0x00u8; 2048];
|
||||
// Pack header at byte 0
|
||||
sector[0] = 0x00;
|
||||
sector[1] = 0x00;
|
||||
sector[2] = 0x01;
|
||||
sector[3] = 0xBA;
|
||||
// Scramble flag at byte 0x14
|
||||
sector[0x14] = 0x30;
|
||||
// Sector seed at bytes 0x54-0x58
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
// PES header at byte 128
|
||||
sector[0x80] = 0x00;
|
||||
sector[0x81] = 0x00;
|
||||
sector[0x82] = 0x01;
|
||||
sector[0x83] = 0xE0;
|
||||
// Fill some content in the encrypted region
|
||||
for (i, byte) in sector.iter_mut().enumerate().take(2048).skip(0x84) {
|
||||
*byte = (i & 0xFF) as u8;
|
||||
}
|
||||
|
||||
let original = sector.clone();
|
||||
|
||||
// First descramble: "encrypts" by XORing keystream
|
||||
descramble_sector(&title_key, &mut sector);
|
||||
// Flag should be cleared
|
||||
assert_eq!(
|
||||
sector[0x14] & 0x30,
|
||||
0x00,
|
||||
"scramble flag not cleared after first descramble"
|
||||
);
|
||||
// Encrypted region should differ
|
||||
assert_ne!(
|
||||
§or[0x80..0x84],
|
||||
&original[0x80..0x84],
|
||||
"encrypted region unchanged after descramble"
|
||||
);
|
||||
|
||||
// Restore the scramble flag and sector seed for second pass
|
||||
sector[0x14] = 0x30;
|
||||
|
||||
// Second descramble: XOR again = roundtrip
|
||||
descramble_sector(&title_key, &mut sector);
|
||||
// Now the encrypted region should match original
|
||||
assert_eq!(
|
||||
§or[0x80..2048],
|
||||
&original[0x80..2048],
|
||||
"double descramble did not roundtrip"
|
||||
);
|
||||
// Flag cleared
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00);
|
||||
// Header (0..128) unchanged except flag byte
|
||||
for i in 0..128 {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||
}
|
||||
// Encrypted region modified
|
||||
assert_ne!(§or[128..256], &original[128..256]);
|
||||
}
|
||||
|
||||
/// Test 4: css_tab1_relationship
|
||||
|
||||
+24
-18
@@ -10,6 +10,7 @@
|
||||
//! css::descramble_sector(&key, &mut sector);
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod crack;
|
||||
pub mod lfsr;
|
||||
pub(crate) mod tables;
|
||||
@@ -27,29 +28,34 @@ pub struct CssState {
|
||||
/// Crack the CSS title key by reading encrypted sectors and applying
|
||||
/// a known-plaintext attack on MPEG-2 headers.
|
||||
///
|
||||
/// Reads a few sectors from the first extent, finds one with the
|
||||
/// scramble flag set, and cracks the key.
|
||||
/// Crack the CSS title key by scanning scrambled sectors across extents.
|
||||
///
|
||||
/// The Stevenson attack needs a sector where a PES header starts at byte
|
||||
/// 0x80 (start of the encrypted region). This only happens when a new PES
|
||||
/// packet begins at exactly sector offset 128. We scan up to 50000
|
||||
/// scrambled sectors sequentially across all extents.
|
||||
pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option<CssState> {
|
||||
if extents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut tried = 0u32;
|
||||
let max_tries = 50_000;
|
||||
|
||||
let ext = &extents[0];
|
||||
let mut sectors = Vec::new();
|
||||
|
||||
// Read first 10 sectors from the main extent
|
||||
let count = ext.sector_count.min(10);
|
||||
for i in 0..count {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
if reader.read_sectors(ext.start_lba + i, 1, &mut buf).is_ok() {
|
||||
sectors.push(buf);
|
||||
for ext in extents {
|
||||
let mut i = 0;
|
||||
while i < ext.sector_count && tried < max_tries {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
if reader.read_sectors(ext.start_lba + i, 1, &mut buf).is_ok() && is_scrambled(&buf) {
|
||||
if let Some(key) = crack::crack_title_key(&buf) {
|
||||
return Some(CssState { title_key: key });
|
||||
}
|
||||
tried += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if tried >= max_tries {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try cracking from the collected sectors
|
||||
let key = crack::crack_from_sectors(§ors)?;
|
||||
|
||||
Some(CssState { title_key: key })
|
||||
None
|
||||
}
|
||||
|
||||
/// Descramble a single CSS-encrypted sector in place.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Decrypt-on-read layer.
|
||||
//!
|
||||
//! Decrypts sectors in-place using resolved keys from disc scanning.
|
||||
//! Handles AACS 1.0, AACS 2.0, and CSS transparently.
|
||||
//! The caller never sees encrypted data unless explicitly bypassed.
|
||||
|
||||
use crate::aacs;
|
||||
use crate::css;
|
||||
|
||||
/// Resolved decryption state from disc scanning.
|
||||
/// Passed to `decrypt_sectors()` — the caller doesn't need to know
|
||||
/// which encryption scheme is in use.
|
||||
#[derive(Clone)]
|
||||
pub enum DecryptKeys {
|
||||
/// No encryption on this disc.
|
||||
None,
|
||||
/// AACS (Blu-ray / UHD). Unit keys + optional read data key.
|
||||
Aacs {
|
||||
unit_keys: Vec<(u32, [u8; 16])>,
|
||||
read_data_key: Option<[u8; 16]>,
|
||||
},
|
||||
/// CSS (DVD). Title key for sector descrambling.
|
||||
Css { title_key: [u8; 5] },
|
||||
}
|
||||
|
||||
impl DecryptKeys {
|
||||
/// True if there are keys to decrypt with.
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
!matches!(self, DecryptKeys::None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a buffer of sectors in-place.
|
||||
///
|
||||
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
||||
/// For CSS: processes per 2048-byte sector.
|
||||
/// For None: no-op.
|
||||
///
|
||||
/// `unit_key_idx` selects which AACS unit key to use (0 for most discs).
|
||||
///
|
||||
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
||||
/// Never produces silently corrupted output.
|
||||
pub fn decrypt_sectors(
|
||||
buf: &mut [u8],
|
||||
keys: &DecryptKeys,
|
||||
unit_key_idx: usize,
|
||||
) -> Result<(), crate::error::Error> {
|
||||
match keys {
|
||||
DecryptKeys::None => {}
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys,
|
||||
read_data_key,
|
||||
} => {
|
||||
let uk = match unit_keys.get(unit_key_idx) {
|
||||
Some((_, k)) => *k,
|
||||
None => {
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
};
|
||||
let rdk = read_data_key.as_ref();
|
||||
let unit_len = aacs::ALIGNED_UNIT_LEN;
|
||||
|
||||
for chunk in buf.chunks_mut(unit_len) {
|
||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
||||
aacs::decrypt_unit_full(chunk, &uk, rdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
DecryptKeys::Css { title_key } => {
|
||||
for chunk in buf.chunks_mut(2048) {
|
||||
css::lfsr::descramble_sector(title_key, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+18
-16
@@ -66,17 +66,18 @@ impl Disc {
|
||||
pkt_count = clip_info.source_packet_count;
|
||||
total_size += pkt_count as u64 * 192;
|
||||
|
||||
// Get m2ts file start LBA and compute extent from packet count.
|
||||
// BD-ROM m2ts files are contiguous on disc (mastering requirement).
|
||||
// Get m2ts file extents from UDF allocation descriptors.
|
||||
// Dual-layer discs split files across layers — UDF knows the real layout.
|
||||
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
|
||||
let file_lba = udf_fs.file_start_lba(reader, &m2ts_path).unwrap_or(0);
|
||||
let total_bytes = pkt_count as u64 * 192;
|
||||
let total_sectors = total_bytes.div_ceil(2048) as u32;
|
||||
if total_sectors > 0 && file_lba > 0 {
|
||||
extents.push(Extent {
|
||||
start_lba: file_lba,
|
||||
sector_count: total_sectors,
|
||||
});
|
||||
if let Ok(file_exts) = udf_fs.file_extents(reader, &m2ts_path) {
|
||||
for (lba, sectors) in file_exts {
|
||||
if sectors > 0 && lba > 0 {
|
||||
extents.push(Extent {
|
||||
start_lba: lba,
|
||||
sector_count: sectors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,8 +105,8 @@ impl Disc {
|
||||
1 | 6 | 7 => Some(Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: format_resolution(s.video_format, s.video_rate),
|
||||
frame_rate: format_framerate(s.video_rate),
|
||||
resolution: Resolution::from_video_format(s.video_format),
|
||||
frame_rate: FrameRate::from_video_rate(s.video_rate),
|
||||
hdr: match s.dynamic_range {
|
||||
1 => HdrFormat::Hdr10,
|
||||
2 => HdrFormat::DolbyVision,
|
||||
@@ -137,9 +138,9 @@ impl Disc {
|
||||
Some(Stream::Audio(AudioStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
channels: format_channels(s.audio_format),
|
||||
channels: AudioChannels::from_audio_format(s.audio_format),
|
||||
language: s.language.clone(),
|
||||
sample_rate: format_samplerate(s.audio_rate),
|
||||
sample_rate: SampleRate::from_audio_rate(s.audio_rate),
|
||||
secondary: s.stream_type == 5,
|
||||
label: String::new(),
|
||||
}))
|
||||
@@ -158,12 +159,12 @@ impl Disc {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Convert marks to chapters (filter mark_type == 1 = chapter entry)
|
||||
// Convert marks to chapters (mark_type 0 or 1 = chapter entry, 2 = link)
|
||||
let first_in_time = parsed.play_items.first().map(|pi| pi.in_time).unwrap_or(0);
|
||||
let chapters: Vec<Chapter> = parsed
|
||||
.marks
|
||||
.iter()
|
||||
.filter(|m| m.mark_type == 1)
|
||||
.filter(|m| m.mark_type <= 1)
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let time_secs = (m.timestamp as f64 - first_in_time as f64) / 45000.0;
|
||||
@@ -187,6 +188,7 @@ impl Disc {
|
||||
chapters,
|
||||
extents,
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+19
-33
@@ -20,20 +20,13 @@ impl Disc {
|
||||
let mut title_number: u16 = 0;
|
||||
|
||||
for ts in &dvd_info.title_sets {
|
||||
// Map DvdVideoAttr to Stream::Video
|
||||
let video_codec = match ts.video.codec.as_str() {
|
||||
"mpeg2" => Codec::Mpeg2,
|
||||
"mpeg1" => Codec::Mpeg2, // treat MPEG-1 as MPEG-2 for container purposes
|
||||
_ => Codec::Mpeg2,
|
||||
};
|
||||
|
||||
let video_stream = Stream::Video(VideoStream {
|
||||
pid: 0xE0, // DVD video PID (standard MPEG PS video stream)
|
||||
codec: video_codec,
|
||||
resolution: ts.video.resolution.clone(),
|
||||
codec: ts.video.codec,
|
||||
resolution: ts.video.resolution,
|
||||
frame_rate: match ts.video.standard.as_str() {
|
||||
"PAL" => "25".to_string(),
|
||||
_ => "29.97".to_string(),
|
||||
"PAL" => FrameRate::F25,
|
||||
_ => FrameRate::F29_97,
|
||||
},
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
@@ -47,31 +40,13 @@ impl Disc {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
let codec = match a.codec.as_str() {
|
||||
"ac3" => Codec::Ac3,
|
||||
"dts" => Codec::Dts,
|
||||
"lpcm" => Codec::Lpcm,
|
||||
"mpeg1" | "mpeg2" => Codec::Mpeg2,
|
||||
_ => Codec::Unknown(0),
|
||||
};
|
||||
let channels = match a.channels {
|
||||
1 => "mono".to_string(),
|
||||
2 => "stereo".to_string(),
|
||||
6 => "5.1".to_string(),
|
||||
8 => "7.1".to_string(),
|
||||
n => format!("{n}ch"),
|
||||
};
|
||||
let sample_rate = match a.sample_rate {
|
||||
48000 => "48kHz".to_string(),
|
||||
96000 => "96kHz".to_string(),
|
||||
sr => format!("{}kHz", sr / 1000),
|
||||
};
|
||||
let codec = a.codec;
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs
|
||||
codec,
|
||||
channels,
|
||||
channels: AudioChannels::from_count(a.channels),
|
||||
language: a.language.clone(),
|
||||
sample_rate,
|
||||
sample_rate: SampleRate::from_hz(a.sample_rate),
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})
|
||||
@@ -126,6 +101,16 @@ impl Disc {
|
||||
streams.extend(audio_streams.iter().cloned());
|
||||
streams.extend(subtitle_streams);
|
||||
|
||||
let chapters: Vec<Chapter> = dvd_title
|
||||
.chapter_times
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &t)| Chapter {
|
||||
time_secs: t,
|
||||
name: format!("Chapter {}", i + 1),
|
||||
})
|
||||
.collect();
|
||||
|
||||
titles.push(DiscTitle {
|
||||
playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number),
|
||||
playlist_id: title_number,
|
||||
@@ -133,9 +118,10 @@ impl Disc {
|
||||
size_bytes,
|
||||
clips: Vec::new(),
|
||||
streams,
|
||||
chapters: Vec::new(),
|
||||
chapters,
|
||||
extents,
|
||||
content_format: ContentFormat::MpegPs,
|
||||
codec_privates: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+9
-11
@@ -25,12 +25,14 @@ impl Disc {
|
||||
let keydb_path = opts.resolve_keydb()?;
|
||||
let keydb = KeyDb::load(&keydb_path).ok()?;
|
||||
|
||||
let mut last_error = None;
|
||||
for hc in &keydb.host_certs {
|
||||
const MAX_CERT_ATTEMPTS: usize = 16;
|
||||
for hc in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS) {
|
||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
||||
Ok(mut auth) => {
|
||||
let volume_id =
|
||||
aacs::handshake::read_volume_id(session, &mut auth).unwrap_or([0u8; 16]);
|
||||
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
||||
Ok(vid) => vid,
|
||||
Err(_) => return None, // handshake succeeded but can't read VID
|
||||
};
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
.ok()
|
||||
.map(|(rdk, _)| rdk);
|
||||
@@ -39,17 +41,13 @@ impl Disc {
|
||||
read_data_key,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// Try next host cert
|
||||
last_error = Some(e);
|
||||
Err(_) => {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
last_error.map(|_e| HandshakeResult {
|
||||
volume_id: [0u8; 16],
|
||||
read_data_key: None,
|
||||
})
|
||||
// All host certs failed — return None, not a fake success
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
|
||||
+733
-449
File diff suppressed because it is too large
Load Diff
+24
-12
@@ -67,8 +67,8 @@ pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
|
||||
}
|
||||
|
||||
// Vendor-specific READ_BUFFER queries
|
||||
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
|
||||
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
|
||||
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
|
||||
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
|
||||
|
||||
// Standard queries
|
||||
let rpc_state = session.report_key_rpc_state();
|
||||
@@ -87,18 +87,30 @@ pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
|
||||
|
||||
/// Mask a string for privacy (letters->A, digits->0).
|
||||
pub fn mask_string(s: &str) -> String {
|
||||
s.chars().map(|c| {
|
||||
if c.is_ascii_alphabetic() { 'A' }
|
||||
else if c.is_ascii_digit() { '0' }
|
||||
else { c }
|
||||
}).collect()
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphabetic() {
|
||||
'A'
|
||||
} else if c.is_ascii_digit() {
|
||||
'0'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mask bytes for privacy.
|
||||
pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
|
||||
data.iter().map(|&b| {
|
||||
if b.is_ascii_alphabetic() { b'A' }
|
||||
else if b.is_ascii_digit() { b'0' }
|
||||
else { b }
|
||||
}).collect()
|
||||
data.iter()
|
||||
.map(|&b| {
|
||||
if b.is_ascii_alphabetic() {
|
||||
b'A'
|
||||
} else if b.is_ascii_digit() {
|
||||
b'0'
|
||||
} else {
|
||||
b
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+3
-3
@@ -21,6 +21,7 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
drives
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
if path.contains("/sg") {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
@@ -39,9 +40,8 @@ pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
&& sg_id.product_id == sr_id.product_id
|
||||
&& sg_id.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning = format!(
|
||||
"{path} is a block device (sr) — using {sg_path} (sg) for raw access"
|
||||
);
|
||||
let warning =
|
||||
format!("{path} is a block device (sr) — using {sg_path} (sg) for raw access");
|
||||
return Ok((sg_path, Some(warning)));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/disk{}", i);
|
||||
if !std::path::Path::new(&path).exists() { continue; }
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
match crate::scsi::open(std::path::Path::new(&path)) {
|
||||
Ok(mut transport) => {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
|
||||
+310
-78
@@ -39,6 +39,17 @@ pub enum DriveStatus {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// SCSI opcodes used in drive control
|
||||
const SCSI_TEST_UNIT_READY: u8 = 0x00;
|
||||
const SCSI_START_STOP_UNIT: u8 = 0x1B;
|
||||
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
|
||||
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
||||
const SCSI_MODE_SENSE: u8 = 0x5A;
|
||||
const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
|
||||
/// Recovery state after a read error — stay at min speed for N bytes.
|
||||
const RECOVERY_WINDOW: u64 = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||
pub struct Drive {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
@@ -47,6 +58,9 @@ pub struct Drive {
|
||||
pub platform: Option<profile::Platform>,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
/// Bytes remaining in the min-speed recovery window.
|
||||
/// After a read error, we stay at min speed for RECOVERY_WINDOW bytes.
|
||||
recovery_bytes_remaining: u64,
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
@@ -72,16 +86,38 @@ impl Drive {
|
||||
profile,
|
||||
drive_id,
|
||||
device_path: device.to_string_lossy().to_string(),
|
||||
recovery_bytes_remaining: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the drive cleanly. Unlocks tray, flushes SCSI state, closes fd.
|
||||
/// Also runs automatically on Drop as a safety net.
|
||||
pub fn close(self) {
|
||||
// cleanup() runs here via Drop
|
||||
}
|
||||
|
||||
/// Shared cleanup — called by Drop (and thus by close).
|
||||
fn cleanup(&mut self) {
|
||||
self.unlock_tray();
|
||||
}
|
||||
|
||||
// NOTE: Debug aid — remove after fd issue is resolved
|
||||
pub fn device_path_owned(&self) -> String {
|
||||
self.device_path.clone()
|
||||
}
|
||||
|
||||
/// Whether this drive has a known profile (unlock parameters available).
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
}
|
||||
|
||||
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
||||
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
|
||||
self.scsi.as_mut()
|
||||
}
|
||||
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut tried_reset = false;
|
||||
|
||||
for _ in 0..60 {
|
||||
@@ -110,8 +146,8 @@ impl Drive {
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
Err(Error::DeviceNotFound {
|
||||
path: format!("{}: drive not ready after 30s", self.device_path),
|
||||
Err(Error::DeviceNotReady {
|
||||
path: self.device_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,7 +155,18 @@ impl Drive {
|
||||
/// Uses GET EVENT STATUS NOTIFICATION which works regardless of firmware state.
|
||||
pub fn drive_status(&mut self) -> DriveStatus {
|
||||
// GET EVENT STATUS NOTIFICATION: polled, media event class (0x10)
|
||||
let cdb = [0x4Au8, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x08, 0x00];
|
||||
let cdb = [
|
||||
SCSI_GET_EVENT_STATUS,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = [0u8; 8];
|
||||
match self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
@@ -132,8 +179,8 @@ impl Drive {
|
||||
// Bits 1-0: door/tray state
|
||||
// Bit 1: media present, Bit 0: tray open
|
||||
match media_status & 0x03 {
|
||||
0x00 => DriveStatus::NoDisc, // tray closed, no disc
|
||||
0x01 => DriveStatus::TrayOpen, // tray open
|
||||
0x00 => DriveStatus::NoDisc, // tray closed, no disc
|
||||
0x01 => DriveStatus::TrayOpen, // tray open
|
||||
0x02 => DriveStatus::DiscPresent, // tray closed, disc present
|
||||
0x03 => DriveStatus::DiscPresent, // tray closed, disc present
|
||||
_ => DriveStatus::Unknown,
|
||||
@@ -141,7 +188,7 @@ impl Drive {
|
||||
}
|
||||
_ => {
|
||||
// Fallback: try TUR
|
||||
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut empty = [0u8; 0];
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur,
|
||||
@@ -170,24 +217,29 @@ impl Drive {
|
||||
/// any step, even if the drive reports "tray open" (that's a valid state).
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
let mut buf = [0u8; 0];
|
||||
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
// 1. Unlock + stop/start
|
||||
self.unlock_tray();
|
||||
let stop = [0x1Bu8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&stop, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
let stop = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&stop, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let start = [0x1Bu8, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&start, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
let start = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&start, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
if self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
).is_ok() {
|
||||
if self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -195,15 +247,18 @@ impl Drive {
|
||||
// After eject, TUR returning "Not Ready — tray open" (sense key 2)
|
||||
// counts as success: the drive is functional, just needs disc reinserted.
|
||||
self.unlock_tray();
|
||||
let eject = [0x1Bu8, 0x00, 0x00, 0x00, 0x02, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&eject, crate::scsi::DataDirection::None, &mut buf, 30_000,
|
||||
);
|
||||
let eject = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x02, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&eject, crate::scsi::DataDirection::None, &mut buf, 30_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
) {
|
||||
match self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
{
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), // tray open = valid
|
||||
_ => {}
|
||||
@@ -214,7 +269,10 @@ impl Drive {
|
||||
self.init()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
&tur,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()),
|
||||
@@ -222,8 +280,8 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::DeviceNotFound {
|
||||
path: format!("{}: drive reset failed", self.device_path),
|
||||
Err(Error::DeviceResetFailed {
|
||||
path: self.device_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -269,13 +327,28 @@ impl Drive {
|
||||
/// Returns the feature data (without the 8-byte header), or None if not available.
|
||||
pub fn get_config_feature(&mut self, feature_code: u16) -> Option<Vec<u8>> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_GET_CONFIGURATION, 0x02,
|
||||
(feature_code >> 8) as u8, feature_code as u8,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
|
||||
crate::scsi::SCSI_GET_CONFIGURATION,
|
||||
0x02,
|
||||
(feature_code >> 8) as u8,
|
||||
feature_code as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 256];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
let r = self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.ok()?;
|
||||
if r.bytes_transferred > 8 {
|
||||
Some(buf[8..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
@@ -285,29 +358,89 @@ impl Drive {
|
||||
|
||||
/// Read REPORT KEY RPC state (region playback control).
|
||||
pub fn report_key_rpc_state(&mut self) -> Option<Vec<u8>> {
|
||||
let cdb = [0xA4u8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00];
|
||||
let cdb = [
|
||||
SCSI_REPORT_KEY,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0x08,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 8];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
let r = self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.ok()?;
|
||||
if r.bytes_transferred > 0 {
|
||||
Some(buf[..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Read MODE SENSE page data.
|
||||
pub fn mode_sense_page(&mut self, page: u8) -> Option<Vec<u8>> {
|
||||
let cdb = [0x5Au8, 0x00, page, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00];
|
||||
let cdb = [
|
||||
SCSI_MODE_SENSE,
|
||||
0x00,
|
||||
page,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFC,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 252];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
let r = self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.ok()?;
|
||||
if r.bytes_transferred > 0 {
|
||||
Some(buf[..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Read vendor-specific READ BUFFER data.
|
||||
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
|
||||
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
let r = self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.ok()?;
|
||||
if r.bytes_transferred > 0 {
|
||||
Some(buf[..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
@@ -317,7 +450,20 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
/// Read sectors from the disc with automatic error recovery.
|
||||
///
|
||||
/// On failure: drops to min speed, waits with escalating patience
|
||||
/// (5s, 10s, 15s, 30s, 60s), resets drive between attempts.
|
||||
/// After recovery, stays at min speed for 500 MB before ramping up.
|
||||
///
|
||||
/// Returns Err only after all attempts exhausted — user should clean
|
||||
/// the disc and resume.
|
||||
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let timeout_ms = if self.recovery_bytes_remaining > 0 {
|
||||
30_000
|
||||
} else {
|
||||
10_000
|
||||
};
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10,
|
||||
0x00,
|
||||
@@ -330,33 +476,95 @@ impl Drive {
|
||||
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(
|
||||
// Normal read
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
30_000,
|
||||
timeout_ms,
|
||||
) {
|
||||
if self.recovery_bytes_remaining > 0 {
|
||||
let bytes_read = count as u64 * 2048;
|
||||
self.recovery_bytes_remaining =
|
||||
self.recovery_bytes_remaining.saturating_sub(bytes_read);
|
||||
if self.recovery_bytes_remaining == 0 {
|
||||
self.set_speed(0xFFFF);
|
||||
}
|
||||
}
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
|
||||
// Phase 1: gentle — sleep 30s, retry. 5 times.
|
||||
self.set_speed(0);
|
||||
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
30_000,
|
||||
) {
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: fresh start — close, reset, open, init.
|
||||
let device = std::path::PathBuf::from(&self.device_path);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
let _ = crate::scsi::reset(&device);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
self.scsi = crate::scsi::open(&device)?;
|
||||
let _ = self.init();
|
||||
let _ = self.wait_ready();
|
||||
self.set_speed(0);
|
||||
|
||||
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
30_000,
|
||||
) {
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
}
|
||||
|
||||
// Both phases failed.
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
Err(Error::DiscRead { sector: lba as u64 })
|
||||
}
|
||||
|
||||
/// Read the disc capacity in sectors (2048 bytes each).
|
||||
pub fn read_capacity(&mut self) -> Result<u32> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_CAPACITY,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = [0u8; 8];
|
||||
self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)?;
|
||||
Ok(result.bytes_transferred)
|
||||
let last_lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
Ok(last_lba + 1)
|
||||
}
|
||||
|
||||
pub fn set_speed(&mut self, speed_kbs: u16) {
|
||||
@@ -367,26 +575,42 @@ impl Drive {
|
||||
|
||||
/// Lock the tray so the disc cannot be ejected during a rip.
|
||||
pub fn lock_tray(&mut self) {
|
||||
let prevent = [0x1Eu8, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let prevent = [
|
||||
SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
}
|
||||
|
||||
/// Unlock the tray so the user can manually eject the disc.
|
||||
pub fn unlock_tray(&mut self) {
|
||||
let allow = [0x1Eu8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let allow = [
|
||||
SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&allow, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&allow, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
}
|
||||
|
||||
/// Eject the disc tray. Unlocks first, then ejects.
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
self.unlock_tray();
|
||||
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
|
||||
let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
self.scsi.as_mut().execute(
|
||||
&eject_cdb,
|
||||
@@ -408,9 +632,16 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Drive {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
// SgIoTransport::drop() runs next, calling libc::close(fd)
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for Drive {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.read_disc(lba, count, buf)
|
||||
self.read(lba, count, buf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +677,7 @@ fn discover_drives() -> Vec<(String, DriveId)> {
|
||||
}
|
||||
|
||||
/// Resolve a device path to its raw SCSI device, with optional warning message.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
+122
-102
@@ -15,32 +15,40 @@
|
||||
//! | E6xxx | Disc format errors |
|
||||
//! | E7xxx | AACS errors |
|
||||
//! | E8xxx | Keydb errors |
|
||||
//! | E9xxx | Mux errors |
|
||||
//! | E9xxx | Stream/mux errors |
|
||||
|
||||
// ── Error codes ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Device (1xxx)
|
||||
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
|
||||
pub const E_DEVICE_PERMISSION: u16 = 1001;
|
||||
pub const E_DEVICE_NOT_READY: u16 = 1002;
|
||||
pub const E_DEVICE_RESET_FAILED: u16 = 1003;
|
||||
|
||||
// Profile (2xxx)
|
||||
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
|
||||
pub const E_PROFILE_NOT_FOUND: u16 = 2001;
|
||||
pub const E_PROFILE_PARSE: u16 = 2002;
|
||||
|
||||
// Unlock (3xxx)
|
||||
pub const E_UNLOCK_FAILED: u16 = 3000;
|
||||
pub const E_SIGNATURE_MISMATCH: u16 = 3001;
|
||||
pub const E_NOT_UNLOCKED: u16 = 3002;
|
||||
pub const E_NOT_CALIBRATED: u16 = 3003;
|
||||
|
||||
// SCSI (4xxx)
|
||||
pub const E_SCSI_ERROR: u16 = 4000;
|
||||
pub const E_SCSI_TIMEOUT: u16 = 4001;
|
||||
|
||||
// I/O (5xxx)
|
||||
pub const E_IO_ERROR: u16 = 5000;
|
||||
pub const E_WRITE_ERROR: u16 = 5001;
|
||||
|
||||
// Disc format (6xxx)
|
||||
pub const E_DISC_READ: u16 = 6000;
|
||||
pub const E_MPLS_PARSE: u16 = 6001;
|
||||
pub const E_CLPI_PARSE: u16 = 6002;
|
||||
pub const E_UDF_NOT_FOUND: u16 = 6003;
|
||||
pub const E_DISC_NO_TITLES: u16 = 6004;
|
||||
pub const E_DISC_TITLE_RANGE: u16 = 6005;
|
||||
pub const E_DISC_NO_EXTENTS: u16 = 6006;
|
||||
pub const E_IFO_PARSE: u16 = 6007;
|
||||
pub const E_MKV_INVALID: u16 = 6008;
|
||||
pub const E_NO_STREAMS: u16 = 6009;
|
||||
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
pub const E_AACS_CERT_SHORT: u16 = 7001;
|
||||
@@ -54,7 +62,9 @@ pub const E_AACS_KEY_VERIFY: u16 = 7008;
|
||||
pub const E_AACS_VID_READ: u16 = 7009;
|
||||
pub const E_AACS_VID_MAC: u16 = 7010;
|
||||
pub const E_AACS_DATA_KEY: u16 = 7011;
|
||||
pub const E_AACS_VUK_DERIVE: u16 = 7012;
|
||||
pub const E_DECRYPT_FAILED: u16 = 7013;
|
||||
pub const E_CSS_AUTH_FAILED: u16 = 7014;
|
||||
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
pub const E_KEYDB_HTTP: u16 = 8001;
|
||||
@@ -62,145 +72,133 @@ pub const E_KEYDB_INVALID: u16 = 8002;
|
||||
pub const E_KEYDB_WRITE: u16 = 8003;
|
||||
pub const E_KEYDB_PARSE: u16 = 8004;
|
||||
pub const E_KEYDB_LOAD: u16 = 8005;
|
||||
// Mux (9xxx)
|
||||
pub const E_MUX_LOOKAHEAD: u16 = 9000;
|
||||
pub const E_MUX_WRITE: u16 = 9001;
|
||||
|
||||
// Stream/mux (9xxx)
|
||||
pub const E_STREAM_READ_ONLY: u16 = 9000;
|
||||
pub const E_STREAM_WRITE_ONLY: u16 = 9001;
|
||||
pub const E_STREAM_URL_INVALID: u16 = 9002;
|
||||
pub const E_STREAM_URL_MISSING_PATH: u16 = 9003;
|
||||
pub const E_STREAM_URL_MISSING_PORT: u16 = 9004;
|
||||
pub const E_PES_FRAME_TOO_LARGE: u16 = 9005;
|
||||
pub const E_PES_INVALID_MAGIC: u16 = 9006;
|
||||
pub const E_ISO_TOO_LARGE: u16 = 9007;
|
||||
pub const E_NO_METADATA: u16 = 9008;
|
||||
|
||||
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Structured error with numeric code and context data. No English text.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// Device not found at the given path.
|
||||
// Device (1xxx)
|
||||
DeviceNotFound {
|
||||
path: String,
|
||||
},
|
||||
/// Insufficient permissions to open the device.
|
||||
DevicePermission {
|
||||
path: String,
|
||||
},
|
||||
DeviceNotReady {
|
||||
path: String,
|
||||
},
|
||||
DeviceResetFailed {
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Drive model is not in the profile database.
|
||||
// Profile (2xxx)
|
||||
UnsupportedDrive {
|
||||
vendor_id: String,
|
||||
product_id: String,
|
||||
product_revision: String,
|
||||
},
|
||||
/// No matching firmware profile found for this drive revision.
|
||||
ProfileNotFound {
|
||||
vendor_id: String,
|
||||
product_revision: String,
|
||||
vendor_specific: String,
|
||||
},
|
||||
/// Failed to parse the bundled profile database.
|
||||
ProfileParse,
|
||||
|
||||
/// Drive unlock (firmware upload) failed.
|
||||
// Unlock (3xxx)
|
||||
UnlockFailed,
|
||||
/// Firmware signature verification failed.
|
||||
SignatureMismatch {
|
||||
expected: [u8; 4],
|
||||
got: [u8; 4],
|
||||
},
|
||||
/// Operation requires an unlocked drive.
|
||||
NotUnlocked,
|
||||
/// Operation requires a calibrated drive.
|
||||
NotCalibrated,
|
||||
|
||||
/// SCSI command returned an error status.
|
||||
// SCSI (4xxx)
|
||||
ScsiError {
|
||||
opcode: u8,
|
||||
status: u8,
|
||||
sense_key: u8,
|
||||
},
|
||||
/// SCSI command timed out.
|
||||
ScsiTimeout {
|
||||
opcode: u8,
|
||||
},
|
||||
|
||||
/// Underlying I/O error.
|
||||
// I/O (5xxx)
|
||||
IoError {
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// Write operation failed.
|
||||
WriteError,
|
||||
|
||||
/// Failed to read disc sector.
|
||||
// Disc format (6xxx)
|
||||
DiscRead {
|
||||
sector: u64,
|
||||
},
|
||||
/// MPLS playlist parsing failed.
|
||||
MplsParse,
|
||||
/// CLPI clip info parsing failed.
|
||||
ClpiParse,
|
||||
/// File not found on the UDF filesystem.
|
||||
UdfNotFound {
|
||||
path: String,
|
||||
},
|
||||
/// Disc contains no playable titles.
|
||||
DiscNoTitles,
|
||||
/// Title index out of range.
|
||||
DiscTitleRange {
|
||||
index: usize,
|
||||
count: usize,
|
||||
},
|
||||
/// Title has no sector extents to read.
|
||||
DiscNoExtents,
|
||||
/// DVD IFO file parsing failed.
|
||||
IfoParse,
|
||||
MkvInvalid,
|
||||
NoStreams,
|
||||
|
||||
/// No AACS decryption keys available for this disc.
|
||||
// AACS (7xxx)
|
||||
AacsNoKeys,
|
||||
/// Host certificate too short.
|
||||
AacsCertShort,
|
||||
/// Failed to allocate AGID for AACS handshake.
|
||||
AacsAgidAlloc,
|
||||
/// Drive rejected the host certificate.
|
||||
AacsCertRejected,
|
||||
/// Failed to read drive certificate.
|
||||
AacsCertRead,
|
||||
/// Drive certificate verification failed.
|
||||
AacsCertVerify,
|
||||
/// Failed to read host key from drive.
|
||||
AacsKeyRead,
|
||||
/// Drive rejected the host key.
|
||||
AacsKeyRejected,
|
||||
/// Host key verification failed.
|
||||
AacsKeyVerify,
|
||||
/// Failed to read Volume ID.
|
||||
AacsVidRead,
|
||||
/// Volume ID MAC verification failed.
|
||||
AacsVidMac,
|
||||
/// Failed to derive the data key.
|
||||
AacsDataKey,
|
||||
/// Failed to derive the Volume Unique Key.
|
||||
AacsVukDerive,
|
||||
DecryptFailed,
|
||||
CssAuthFailed,
|
||||
|
||||
/// Failed to connect to the KEYDB server.
|
||||
// Keydb (8xxx)
|
||||
KeydbConnect {
|
||||
host: String,
|
||||
},
|
||||
/// KEYDB server returned an HTTP error.
|
||||
KeydbHttp {
|
||||
status: u16,
|
||||
},
|
||||
/// Downloaded KEYDB file is invalid (no entries found).
|
||||
KeydbInvalid,
|
||||
/// Failed to write KEYDB to disk.
|
||||
KeydbWrite {
|
||||
path: String,
|
||||
},
|
||||
/// Failed to parse KEYDB file.
|
||||
KeydbParse,
|
||||
/// Failed to load KEYDB from disk.
|
||||
KeydbLoad {
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Lookahead buffer exhausted before codec headers found.
|
||||
MuxLookahead,
|
||||
/// Muxer write failed.
|
||||
MuxWrite,
|
||||
// Stream/mux (9xxx)
|
||||
StreamReadOnly,
|
||||
StreamWriteOnly,
|
||||
StreamUrlInvalid {
|
||||
url: String,
|
||||
},
|
||||
StreamUrlMissingPath {
|
||||
scheme: String,
|
||||
},
|
||||
StreamUrlMissingPort {
|
||||
addr: String,
|
||||
},
|
||||
PesFrameTooLarge {
|
||||
size: usize,
|
||||
},
|
||||
PesInvalidMagic,
|
||||
IsoTooLarge {
|
||||
path: String,
|
||||
},
|
||||
NoMetadata,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
@@ -208,25 +206,22 @@ impl Error {
|
||||
match self {
|
||||
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
|
||||
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
|
||||
Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY,
|
||||
Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED,
|
||||
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
|
||||
Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND,
|
||||
Error::ProfileParse => E_PROFILE_PARSE,
|
||||
Error::UnlockFailed => E_UNLOCK_FAILED,
|
||||
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
||||
Error::NotUnlocked => E_NOT_UNLOCKED,
|
||||
Error::NotCalibrated => E_NOT_CALIBRATED,
|
||||
Error::ScsiError { .. } => E_SCSI_ERROR,
|
||||
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
|
||||
Error::IoError { .. } => E_IO_ERROR,
|
||||
Error::WriteError => E_WRITE_ERROR,
|
||||
Error::DiscRead { .. } => E_DISC_READ,
|
||||
Error::MplsParse => E_MPLS_PARSE,
|
||||
Error::ClpiParse => E_CLPI_PARSE,
|
||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||
Error::DiscNoTitles => E_DISC_NO_TITLES,
|
||||
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
||||
Error::DiscNoExtents => E_DISC_NO_EXTENTS,
|
||||
Error::IfoParse => E_IFO_PARSE,
|
||||
Error::MkvInvalid => E_MKV_INVALID,
|
||||
Error::NoStreams => E_NO_STREAMS,
|
||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
||||
@@ -239,15 +234,23 @@ impl Error {
|
||||
Error::AacsVidRead => E_AACS_VID_READ,
|
||||
Error::AacsVidMac => E_AACS_VID_MAC,
|
||||
Error::AacsDataKey => E_AACS_DATA_KEY,
|
||||
Error::AacsVukDerive => E_AACS_VUK_DERIVE,
|
||||
Error::DecryptFailed => E_DECRYPT_FAILED,
|
||||
Error::CssAuthFailed => E_CSS_AUTH_FAILED,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
|
||||
Error::KeydbParse => E_KEYDB_PARSE,
|
||||
Error::KeydbLoad { .. } => E_KEYDB_LOAD,
|
||||
Error::MuxLookahead => E_MUX_LOOKAHEAD,
|
||||
Error::MuxWrite => E_MUX_WRITE,
|
||||
Error::StreamReadOnly => E_STREAM_READ_ONLY,
|
||||
Error::StreamWriteOnly => E_STREAM_WRITE_ONLY,
|
||||
Error::StreamUrlInvalid { .. } => E_STREAM_URL_INVALID,
|
||||
Error::StreamUrlMissingPath { .. } => E_STREAM_URL_MISSING_PATH,
|
||||
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
|
||||
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
|
||||
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
|
||||
Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE,
|
||||
Error::NoMetadata => E_NO_METADATA,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +261,8 @@ impl std::fmt::Display for Error {
|
||||
match self {
|
||||
Error::DeviceNotFound { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DevicePermission { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DeviceNotReady { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DeviceResetFailed { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::UnsupportedDrive {
|
||||
vendor_id,
|
||||
product_id,
|
||||
@@ -270,18 +275,6 @@ impl std::fmt::Display for Error {
|
||||
product_id.trim(),
|
||||
product_revision.trim()
|
||||
),
|
||||
Error::ProfileNotFound {
|
||||
vendor_id,
|
||||
product_revision,
|
||||
vendor_specific,
|
||||
} => write!(
|
||||
f,
|
||||
"E{}: {} {} {}",
|
||||
self.code(),
|
||||
vendor_id.trim(),
|
||||
product_revision.trim(),
|
||||
vendor_specific.trim()
|
||||
),
|
||||
Error::SignatureMismatch { expected, got } => write!(
|
||||
f,
|
||||
"E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
|
||||
@@ -299,15 +292,16 @@ impl std::fmt::Display for Error {
|
||||
opcode,
|
||||
status,
|
||||
sense_key,
|
||||
} => write!(
|
||||
f,
|
||||
"E{}: 0x{:02x}/0x{:02x}/0x{:02x}",
|
||||
self.code(),
|
||||
opcode,
|
||||
status,
|
||||
sense_key
|
||||
),
|
||||
Error::ScsiTimeout { opcode } => write!(f, "E{}: 0x{:02x}", self.code(), opcode),
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"E{}: 0x{:02x}/0x{:02x}/0x{:02x}",
|
||||
self.code(),
|
||||
opcode,
|
||||
status,
|
||||
sense_key
|
||||
)
|
||||
}
|
||||
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
|
||||
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
|
||||
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
@@ -318,7 +312,11 @@ impl std::fmt::Display for Error {
|
||||
Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
|
||||
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::KeydbLoad { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
// Simple codes — no extra data
|
||||
Error::StreamUrlInvalid { url } => write!(f, "E{}: {}", self.code(), url),
|
||||
Error::StreamUrlMissingPath { scheme } => write!(f, "E{}: {}", self.code(), scheme),
|
||||
Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr),
|
||||
Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size),
|
||||
Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
_ => write!(f, "E{}", self.code()),
|
||||
}
|
||||
}
|
||||
@@ -339,5 +337,27 @@ impl From<std::io::Error> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for std::io::Error {
|
||||
fn from(e: Error) -> Self {
|
||||
let code = e.code();
|
||||
let msg = e.to_string();
|
||||
// Map our error categories to io::ErrorKind
|
||||
let kind = match code {
|
||||
1000..=1999 => std::io::ErrorKind::NotFound,
|
||||
2000..=2999 => std::io::ErrorKind::Unsupported,
|
||||
3000..=3999 => std::io::ErrorKind::PermissionDenied,
|
||||
4000..=4999 => std::io::ErrorKind::Other,
|
||||
5000..=5999 => std::io::ErrorKind::Other,
|
||||
6000..=6999 => std::io::ErrorKind::InvalidData,
|
||||
7000..=7999 => std::io::ErrorKind::PermissionDenied,
|
||||
8000..=8999 => std::io::ErrorKind::Other,
|
||||
9000..=9001 => std::io::ErrorKind::Unsupported,
|
||||
9002..=9009 => std::io::ErrorKind::InvalidInput,
|
||||
_ => std::io::ErrorKind::Other,
|
||||
};
|
||||
std::io::Error::new(kind, msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience alias for `Result<T, Error>`.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -25,6 +25,23 @@ pub struct Event {
|
||||
/// Types of events the lib can fire.
|
||||
#[derive(Debug)]
|
||||
pub enum EventKind {
|
||||
// ── Init sequence events ────────────────────────────────────────
|
||||
/// Drive opened successfully.
|
||||
DriveOpened { device: String },
|
||||
|
||||
/// Drive is ready (disc spun up).
|
||||
DriveReady,
|
||||
|
||||
/// Firmware init completed.
|
||||
InitComplete { success: bool },
|
||||
|
||||
/// Disc probe completed.
|
||||
ProbeComplete { success: bool },
|
||||
|
||||
/// Disc scan completed.
|
||||
ScanComplete { titles: usize },
|
||||
|
||||
// ── Read events ─────────────────────────────────────────────────
|
||||
/// Bytes successfully read and written to output.
|
||||
BytesRead {
|
||||
/// Bytes written so far.
|
||||
|
||||
+127
-68
@@ -7,6 +7,7 @@
|
||||
//! The parser reads IFO files via UDF and extracts enough information
|
||||
//! to build DiscTitle structs (parallel to MPLS for Blu-ray).
|
||||
|
||||
use crate::disc::{Codec, Resolution};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
@@ -46,6 +47,8 @@ pub struct DvdTitle {
|
||||
pub duration_secs: f64,
|
||||
/// Cell sector ranges
|
||||
pub cells: Vec<DvdCell>,
|
||||
/// Chapter start times in seconds (derived from program map + cell times)
|
||||
pub chapter_times: Vec<f64>,
|
||||
/// Subtitle palette from PGC: 16 entries of [padding, Y, Cb, Cr].
|
||||
pub palette: Option<Vec<[u8; 4]>>,
|
||||
}
|
||||
@@ -61,8 +64,8 @@ pub struct DvdCell {
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct DvdVideoAttr {
|
||||
pub codec: String,
|
||||
pub resolution: String,
|
||||
pub codec: Codec,
|
||||
pub resolution: Resolution,
|
||||
pub aspect: String,
|
||||
pub standard: String,
|
||||
}
|
||||
@@ -70,7 +73,7 @@ pub struct DvdVideoAttr {
|
||||
/// DVD audio stream attributes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DvdAudioAttr {
|
||||
pub codec: String,
|
||||
pub codec: Codec,
|
||||
pub channels: u8,
|
||||
pub sample_rate: u32,
|
||||
pub language: String,
|
||||
@@ -349,41 +352,15 @@ fn parse_video_attr(data: &[u8]) -> Result<DvdVideoAttr> {
|
||||
_ => "4:3",
|
||||
};
|
||||
|
||||
let resolution = match (b0 >> 4) & 0x03 {
|
||||
0 => {
|
||||
if standard == "PAL" {
|
||||
"720x576"
|
||||
} else {
|
||||
"720x480"
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
if standard == "PAL" {
|
||||
"704x576"
|
||||
} else {
|
||||
"704x480"
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if standard == "PAL" {
|
||||
"352x576"
|
||||
} else {
|
||||
"352x480"
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if standard == "PAL" {
|
||||
"352x288"
|
||||
} else {
|
||||
"352x240"
|
||||
}
|
||||
}
|
||||
_ => "720x480",
|
||||
let resolution = if standard == "PAL" {
|
||||
Resolution::R576i
|
||||
} else {
|
||||
Resolution::R480i
|
||||
};
|
||||
|
||||
Ok(DvdVideoAttr {
|
||||
codec: "mpeg2".to_string(),
|
||||
resolution: resolution.to_string(),
|
||||
codec: Codec::Mpeg2,
|
||||
resolution,
|
||||
aspect: aspect.to_string(),
|
||||
standard: standard.to_string(),
|
||||
})
|
||||
@@ -396,12 +373,12 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
|
||||
|
||||
let coding_mode = (b0 >> 5) & 0x07;
|
||||
let codec = match coding_mode {
|
||||
0 => "ac3",
|
||||
2 => "mpeg1",
|
||||
3 => "mpeg2",
|
||||
4 => "lpcm",
|
||||
6 => "dts",
|
||||
_ => "unknown",
|
||||
0 => Codec::Ac3,
|
||||
2 => Codec::Mpeg1,
|
||||
3 => Codec::Mp2,
|
||||
4 => Codec::Lpcm,
|
||||
6 => Codec::Dts,
|
||||
_ => Codec::Unknown(coding_mode),
|
||||
};
|
||||
|
||||
let sample_rate_flag = (b0 >> 3) & 0x03;
|
||||
@@ -434,7 +411,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
|
||||
};
|
||||
|
||||
Ok(DvdAudioAttr {
|
||||
codec: codec.to_string(),
|
||||
codec,
|
||||
channels,
|
||||
sample_rate,
|
||||
language,
|
||||
@@ -513,26 +490,20 @@ fn parse_pgcit(
|
||||
|
||||
/// Parse a single PGC (Program Chain) to extract duration and cells.
|
||||
fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle> {
|
||||
// PGC needs at least 0xE6 bytes for the cell info offsets
|
||||
if pgc_offset + 0xE6 > data.len() {
|
||||
// PGC needs at least 0xE8 bytes for the cell playback info offset
|
||||
if pgc_offset + 0xEA > data.len() {
|
||||
return Err(Error::IfoParse);
|
||||
}
|
||||
|
||||
// Playback time at offset 2-5 (4 BCD bytes)
|
||||
let time_bytes = sub_slice(data, pgc_offset + 2, 4)?;
|
||||
// PGC layout:
|
||||
// 0x00-0x01: misc flags
|
||||
// 0x02: nr_of_programs
|
||||
// 0x03: nr_of_cells
|
||||
// 0x04-0x07: playback_time (4 BCD bytes)
|
||||
let num_cells = byte_at(data, pgc_offset + 0x03)? as usize;
|
||||
let time_bytes = sub_slice(data, pgc_offset + 0x04, 4)?;
|
||||
let duration_secs = bcd_to_secs(time_bytes);
|
||||
|
||||
// Number of cells: the user spec says byte 0x03, but in the standard IFO
|
||||
// format bytes 0x02-0x05 are the BCD playback time. The real cell count
|
||||
// lives at PGC offset 0x07. We read from 0x03 as primary (per spec) and
|
||||
// fall back to 0x07 if that yields zero.
|
||||
let num_cells_primary = byte_at(data, pgc_offset + 0x03)? as usize;
|
||||
let num_cells = if num_cells_primary == 0 {
|
||||
byte_at(data, pgc_offset + 0x07).unwrap_or(0) as usize
|
||||
} else {
|
||||
num_cells_primary
|
||||
};
|
||||
|
||||
// Cell playback info table offset (relative to PGC start)
|
||||
let cell_playback_offset = be_u16(data, pgc_offset + 0xE8)? as usize;
|
||||
|
||||
@@ -561,9 +532,10 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
|
||||
let cell_base = pgc_offset + cell_playback_offset;
|
||||
let mut total = 0.0;
|
||||
for i in 0..cells.len() {
|
||||
// Cell playback info: 24 bytes per cell, BCD time at offset 4-7
|
||||
let co = cell_base + i * 24;
|
||||
if co + 4 <= data.len() {
|
||||
total += bcd_to_secs(&data[co..co + 4]);
|
||||
if co + 8 <= data.len() {
|
||||
total += bcd_to_secs(&data[co + 4..co + 8]);
|
||||
}
|
||||
}
|
||||
total
|
||||
@@ -571,6 +543,37 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
|
||||
duration_secs
|
||||
};
|
||||
|
||||
// Extract chapter times from program map + cell durations
|
||||
// PGC program map offset at 0xE6, maps program_number → first cell_number
|
||||
let chapter_times = {
|
||||
let pgm_map_offset = be_u16(data, pgc_offset + 0xE6).unwrap_or(0) as usize;
|
||||
let nr_of_programs = byte_at(data, pgc_offset + 0x02).unwrap_or(0) as usize;
|
||||
let mut times = Vec::new();
|
||||
if pgm_map_offset > 0 && nr_of_programs > 0 && cell_playback_offset > 0 {
|
||||
let pgm_base = pgc_offset + pgm_map_offset;
|
||||
// Collect cell durations
|
||||
let mut cell_durations = Vec::with_capacity(num_cells);
|
||||
let cell_base = pgc_offset + cell_playback_offset;
|
||||
for i in 0..num_cells {
|
||||
let co = cell_base + i * 24;
|
||||
if co + 8 <= data.len() {
|
||||
cell_durations.push(bcd_to_secs(&data[co + 4..co + 8]));
|
||||
} else {
|
||||
cell_durations.push(0.0);
|
||||
}
|
||||
}
|
||||
// Program map: each byte is the first cell number (1-based) for that program
|
||||
for p in 0..nr_of_programs {
|
||||
if pgm_base + p >= data.len() { break; }
|
||||
let first_cell = data[pgm_base + p] as usize;
|
||||
// Chapter time = sum of cell durations before this program's first cell
|
||||
let time: f64 = cell_durations[..first_cell.saturating_sub(1)].iter().sum();
|
||||
times.push(time);
|
||||
}
|
||||
}
|
||||
times
|
||||
};
|
||||
|
||||
// Extract subtitle palette at PGC offset 0xA4: 16 colors × 4 bytes [padding, Y, Cb, Cr]
|
||||
let palette = if pgc_offset + 0xA4 + 64 <= data.len() {
|
||||
let mut colors = Vec::with_capacity(16);
|
||||
@@ -592,6 +595,7 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
|
||||
chapters,
|
||||
duration_secs,
|
||||
cells,
|
||||
chapter_times,
|
||||
palette,
|
||||
})
|
||||
}
|
||||
@@ -681,6 +685,7 @@ mod tests {
|
||||
chapters: 5,
|
||||
duration_secs: 3600.0,
|
||||
cells: vec![cell.clone()],
|
||||
chapter_times: Vec::new(),
|
||||
palette: None,
|
||||
};
|
||||
assert_eq!(title.chapters, 5);
|
||||
@@ -688,15 +693,15 @@ mod tests {
|
||||
assert_eq!(title.cells.len(), 1);
|
||||
|
||||
let video = DvdVideoAttr {
|
||||
codec: "mpeg2".to_string(),
|
||||
resolution: "720x480".to_string(),
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R480i,
|
||||
aspect: "16:9".to_string(),
|
||||
standard: "NTSC".to_string(),
|
||||
};
|
||||
assert_eq!(video.codec, "mpeg2");
|
||||
assert_eq!(video.codec, Codec::Mpeg2);
|
||||
|
||||
let audio = DvdAudioAttr {
|
||||
codec: "ac3".to_string(),
|
||||
codec: Codec::Ac3,
|
||||
channels: 6,
|
||||
sample_rate: 48000,
|
||||
language: "en".to_string(),
|
||||
@@ -720,6 +725,60 @@ mod tests {
|
||||
assert_eq!(info.title_sets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pgc_parses_duration_from_correct_offset() {
|
||||
// Build a minimal PGC: 0xEA bytes minimum
|
||||
// PGC layout: 0x02 = nr_programs, 0x03 = nr_cells, 0x04-0x07 = BCD time
|
||||
let mut pgc = vec![0u8; 0xEA];
|
||||
pgc[0x02] = 1; // 1 program
|
||||
pgc[0x03] = 2; // 2 cells
|
||||
// 1h 59m 30s at 29.97fps, 0 frames
|
||||
pgc[0x04] = 0x01; // hours BCD
|
||||
pgc[0x05] = 0x59; // minutes BCD
|
||||
pgc[0x06] = 0x30; // seconds BCD
|
||||
pgc[0x07] = 0b11_000000; // 29.97fps, 0 frames
|
||||
// Cell playback info offset at PGC+0xE8
|
||||
let cell_offset: u16 = 0xEA; // right after minimum header
|
||||
pgc[0xE8] = (cell_offset >> 8) as u8;
|
||||
pgc[0xE9] = cell_offset as u8;
|
||||
// Add 2 cells (24 bytes each)
|
||||
pgc.resize(pgc.len() + 48, 0);
|
||||
// Cell 0: sectors 100-200
|
||||
let co = 0xEA;
|
||||
pgc[co + 8] = 0;
|
||||
pgc[co + 9] = 0;
|
||||
pgc[co + 10] = 0;
|
||||
pgc[co + 11] = 100; // first sector
|
||||
pgc[co + 20] = 0;
|
||||
pgc[co + 21] = 0;
|
||||
pgc[co + 22] = 0;
|
||||
pgc[co + 23] = 200; // last sector
|
||||
// Cell 1: sectors 300-400
|
||||
let co = 0xEA + 24;
|
||||
pgc[co + 8] = 0;
|
||||
pgc[co + 9] = 0;
|
||||
pgc[co + 10] = 1;
|
||||
pgc[co + 11] = 44; // first sector = 300
|
||||
pgc[co + 20] = 0;
|
||||
pgc[co + 21] = 0;
|
||||
pgc[co + 22] = 1;
|
||||
pgc[co + 23] = 144; // last sector = 400
|
||||
|
||||
let title = parse_pgc(&pgc, 0, 5).unwrap();
|
||||
let expected = 1.0 * 3600.0 + 59.0 * 60.0 + 30.0;
|
||||
assert!(
|
||||
(title.duration_secs - expected).abs() < 0.1,
|
||||
"expected ~{expected}s, got {}s",
|
||||
title.duration_secs
|
||||
);
|
||||
assert_eq!(title.chapters, 5);
|
||||
assert_eq!(title.cells.len(), 2);
|
||||
assert_eq!(title.cells[0].first_sector, 100);
|
||||
assert_eq!(title.cells[0].last_sector, 200);
|
||||
assert_eq!(title.cells[1].first_sector, 300);
|
||||
assert_eq!(title.cells[1].last_sector, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_attr_parsing() {
|
||||
// Build minimal data with video attrs at 0x200
|
||||
@@ -730,8 +789,8 @@ mod tests {
|
||||
let attr = parse_video_attr(&data).unwrap();
|
||||
assert_eq!(attr.standard, "NTSC");
|
||||
assert_eq!(attr.aspect, "16:9");
|
||||
assert_eq!(attr.resolution, "720x480");
|
||||
assert_eq!(attr.codec, "mpeg2");
|
||||
assert_eq!(attr.resolution, Resolution::R480i);
|
||||
assert_eq!(attr.codec, Codec::Mpeg2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -743,7 +802,7 @@ mod tests {
|
||||
let attr = parse_video_attr(&data).unwrap();
|
||||
assert_eq!(attr.standard, "PAL");
|
||||
assert_eq!(attr.aspect, "4:3");
|
||||
assert_eq!(attr.resolution, "720x576");
|
||||
assert_eq!(attr.resolution, Resolution::R576i);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -759,7 +818,7 @@ mod tests {
|
||||
data[3] = b'n';
|
||||
|
||||
let attr = parse_audio_attr(&data, 0).unwrap();
|
||||
assert_eq!(attr.codec, "ac3");
|
||||
assert_eq!(attr.codec, Codec::Ac3);
|
||||
assert_eq!(attr.sample_rate, 48000);
|
||||
assert_eq!(attr.channels, 6);
|
||||
assert_eq!(attr.language, "en");
|
||||
@@ -777,7 +836,7 @@ mod tests {
|
||||
data[3] = b'r';
|
||||
|
||||
let attr = parse_audio_attr(&data, 0).unwrap();
|
||||
assert_eq!(attr.codec, "dts");
|
||||
assert_eq!(attr.codec, Codec::Dts);
|
||||
assert_eq!(attr.sample_rate, 96000);
|
||||
assert_eq!(attr.channels, 2);
|
||||
assert_eq!(attr.language, "fr");
|
||||
|
||||
@@ -138,6 +138,127 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill in default labels for any streams that don't have one.
|
||||
/// Runs after BD-J label extraction — fills gaps with codec + channel descriptions.
|
||||
/// This is the central place for all fallback label generation.
|
||||
pub fn fill_defaults(titles: &mut [crate::disc::DiscTitle]) {
|
||||
use crate::disc::{Codec, HdrFormat, Stream};
|
||||
|
||||
for title in titles.iter_mut() {
|
||||
for stream in &mut title.streams {
|
||||
match stream {
|
||||
Stream::Audio(a) if a.label.is_empty() => {
|
||||
a.label = generate_audio_label(&a.codec, &a.channels, a.secondary);
|
||||
}
|
||||
Stream::Video(v) if v.label.is_empty() => {
|
||||
v.label = generate_video_label(&v.codec, v.resolution.pixels(), &v.hdr, v.secondary);
|
||||
}
|
||||
Stream::Subtitle(s) if s.forced => {
|
||||
// Ensure forced subs are labeled even if BD-J didn't set a name
|
||||
// (subtitle labels are generally not set — this just marks forced)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_video_label(
|
||||
codec: &crate::disc::Codec,
|
||||
pixels: (u32, u32),
|
||||
hdr: &crate::disc::HdrFormat,
|
||||
secondary: bool,
|
||||
) -> String {
|
||||
use crate::disc::HdrFormat;
|
||||
|
||||
if secondary {
|
||||
return match hdr {
|
||||
HdrFormat::DolbyVision => "Dolby Vision EL".to_string(),
|
||||
_ => "Secondary Video".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
|
||||
// Codec
|
||||
parts.push(codec.name().to_string());
|
||||
|
||||
// Resolution
|
||||
let (w, h) = pixels;
|
||||
let res = if w >= 7680 {
|
||||
"8K"
|
||||
} else if w >= 3840 {
|
||||
"4K"
|
||||
} else if w >= 1920 {
|
||||
"1080p"
|
||||
} else if w >= 1280 {
|
||||
"720p"
|
||||
} else if h >= 576 {
|
||||
"576p"
|
||||
} else if h >= 480 {
|
||||
"480p"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if !res.is_empty() {
|
||||
parts.push(res.into());
|
||||
}
|
||||
|
||||
// HDR
|
||||
match hdr {
|
||||
HdrFormat::Sdr => {}
|
||||
_ => parts.push(hdr.name().to_string()),
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn generate_audio_label(
|
||||
codec: &crate::disc::Codec,
|
||||
channels: &crate::disc::AudioChannels,
|
||||
secondary: bool,
|
||||
) -> String {
|
||||
use crate::disc::{AudioChannels, Codec};
|
||||
|
||||
// Full marketing names for disc audio codecs
|
||||
let codec_name = match codec {
|
||||
Codec::TrueHd => "Dolby TrueHD",
|
||||
Codec::Ac3 => "Dolby Digital",
|
||||
Codec::Ac3Plus => "Dolby Digital Plus",
|
||||
Codec::DtsHdMa => "DTS-HD Master Audio",
|
||||
Codec::DtsHdHr => "DTS-HD High Resolution",
|
||||
Codec::Dts => "DTS",
|
||||
Codec::Lpcm => "LPCM",
|
||||
Codec::Aac => "AAC",
|
||||
Codec::Mp2 => "MPEG Audio",
|
||||
Codec::Mp3 => "MP3",
|
||||
Codec::Flac => "FLAC",
|
||||
Codec::Opus => "Opus",
|
||||
_ => return String::new(),
|
||||
};
|
||||
|
||||
// Channel layout
|
||||
let channel_str = match channels {
|
||||
AudioChannels::Mono => "1.0",
|
||||
AudioChannels::Stereo => "2.0",
|
||||
AudioChannels::Stereo21 => "2.1",
|
||||
AudioChannels::Quad => "4.0",
|
||||
AudioChannels::Surround50 => "5.0",
|
||||
AudioChannels::Surround51 => "5.1",
|
||||
AudioChannels::Surround61 => "6.1",
|
||||
AudioChannels::Surround71 => "7.1",
|
||||
AudioChannels::Unknown => "",
|
||||
};
|
||||
|
||||
let suffix = if secondary { " (Secondary)" } else { "" };
|
||||
|
||||
if channel_str.is_empty() {
|
||||
format!("{}{}", codec_name, suffix)
|
||||
} else {
|
||||
format!("{} {}{}", codec_name, channel_str, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
for (_name, detect, parse) in PARSERS {
|
||||
if detect(udf) {
|
||||
|
||||
+22
-13
@@ -9,17 +9,23 @@
|
||||
//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
|
||||
//!
|
||||
//! let mut drive = find_drive().expect("no optical drive found");
|
||||
//! drive.wait_ready().unwrap();
|
||||
//! drive.init().unwrap();
|
||||
//! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
|
||||
//!
|
||||
//! for title in &disc.titles {
|
||||
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
|
||||
//! }
|
||||
//!
|
||||
//! // Read content (decrypted automatically if AACS keys available)
|
||||
//! let mut reader = disc.open_title(&mut drive, 0).unwrap();
|
||||
//! while let Some(unit) = reader.read_unit().unwrap() {
|
||||
//! // 6144 bytes of decrypted content per unit
|
||||
//! // Stream via PES pipeline
|
||||
//! let opts = libfreemkv::InputOptions::default();
|
||||
//! let mut input = libfreemkv::input("disc://", &opts).unwrap();
|
||||
//! let title = input.info().clone();
|
||||
//! let mut output = libfreemkv::output("mkv://Movie.mkv", &title).unwrap();
|
||||
//! while let Ok(Some(frame)) = input.read() {
|
||||
//! output.write(&frame).unwrap();
|
||||
//! }
|
||||
//! output.finish().unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//! # Architecture
|
||||
@@ -68,6 +74,7 @@
|
||||
pub mod aacs;
|
||||
pub(crate) mod clpi;
|
||||
pub mod css;
|
||||
pub mod decrypt;
|
||||
pub mod disc;
|
||||
pub mod drive;
|
||||
pub mod error;
|
||||
@@ -78,35 +85,37 @@ pub mod keydb;
|
||||
pub(crate) mod labels;
|
||||
pub(crate) mod mpls;
|
||||
pub mod mux;
|
||||
pub mod pes;
|
||||
pub(crate) mod platform;
|
||||
pub mod profile;
|
||||
pub mod scsi;
|
||||
pub(crate) mod sector;
|
||||
pub mod sector;
|
||||
pub mod verify;
|
||||
pub(crate) mod speed;
|
||||
pub(crate) mod udf;
|
||||
|
||||
pub use drive::capture::{
|
||||
capture_drive_data, mask_bytes, mask_string, CapturedFeature, DriveCapture,
|
||||
};
|
||||
pub use drive::{find_drive, find_drives, Drive, DriveStatus};
|
||||
pub use drive::capture::{DriveCapture, CapturedFeature, capture_drive_data, mask_string, mask_bytes};
|
||||
pub use error::{Error, Result};
|
||||
pub use event::{Event, EventKind};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
|
||||
pub use decrypt::{decrypt_sectors, DecryptKeys};
|
||||
pub use disc::{
|
||||
AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc,
|
||||
DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream,
|
||||
VideoStream,
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
|
||||
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
|
||||
ScanOptions, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
pub use mux::DiscOptions;
|
||||
pub use mux::DiscStream;
|
||||
pub use mux::IOStream;
|
||||
pub use mux::IsoStream;
|
||||
pub use mux::M2tsStream;
|
||||
pub use mux::MkvStream;
|
||||
pub use mux::NetworkStream;
|
||||
pub use mux::NullStream;
|
||||
pub use mux::StdioStream;
|
||||
pub use mux::{open_input, open_output, parse_url, InputOptions, StreamUrl};
|
||||
pub use mux::{input, output, parse_url, InputOptions, StreamUrl};
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use sector::SectorReader;
|
||||
pub use speed::DriveSpeed;
|
||||
|
||||
+1
-1
@@ -256,7 +256,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
|
||||
// Parse PlayListMark section
|
||||
let mut marks = Vec::new();
|
||||
if mark_start > 0 && mark_start + 4 < data.len() {
|
||||
if mark_start > 0 && mark_start + 4 <= data.len() {
|
||||
let ms = &data[mark_start..];
|
||||
if ms.len() >= 6 {
|
||||
let num_marks = u16::from_be_bytes([ms[4], ms[5]]) as usize;
|
||||
|
||||
+192
-266
@@ -1,15 +1,15 @@
|
||||
//! 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).
|
||||
//!
|
||||
//! E-AC-3 shares the same syncword but uses bsid >= 11 (typically 16).
|
||||
//! Frame size is derived from the frmsiz field instead of fscod/frmsizecod.
|
||||
//! Buffers across PES boundaries so frames that span two PES packets
|
||||
//! are emitted complete, not truncated.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
|
||||
pub struct Ac3Parser;
|
||||
pub struct Ac3Parser {
|
||||
/// Leftover bytes from previous PES (incomplete frame at end).
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Default for Ac3Parser {
|
||||
fn default() -> Self {
|
||||
@@ -19,19 +19,24 @@ impl Default for Ac3Parser {
|
||||
|
||||
impl Ac3Parser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
buf: Vec::with_capacity(4096),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for Ac3Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.len() < 2 {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
let data = &pes.data;
|
||||
// Prepend leftover from previous PES
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
|
||||
let data = &self.buf;
|
||||
let mut frames = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
@@ -44,44 +49,54 @@ impl CodecParser for Ac3Parser {
|
||||
|
||||
let remaining = &data[start..];
|
||||
|
||||
// Need at least 6 bytes to inspect bsid / frame size fields
|
||||
if remaining.len() < 6 {
|
||||
// Emit whatever remains as a single frame
|
||||
frames.push(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: remaining.to_vec(),
|
||||
});
|
||||
// Not enough data to determine frame size — keep for next PES
|
||||
break;
|
||||
}
|
||||
|
||||
let bsid = get_bsid(remaining);
|
||||
|
||||
if bsid >= 11 {
|
||||
// E-AC-3 frame size from frmsiz field (bytes 2-3)
|
||||
let frame_size = eac3_frame_size(remaining);
|
||||
|
||||
let end = start + frame_size.min(data.len() - start);
|
||||
frames.push(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..end].to_vec(),
|
||||
});
|
||||
pos = end;
|
||||
let frame_size = if bsid >= 11 {
|
||||
eac3_frame_size(remaining)
|
||||
} else {
|
||||
// AC-3: emit everything from syncword to next syncword (or end)
|
||||
let next_sync = find_ac3_sync(&data[start + 2..]).map(|o| start + 2 + o);
|
||||
let end = next_sync.unwrap_or(data.len());
|
||||
frames.push(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..end].to_vec(),
|
||||
});
|
||||
pos = end;
|
||||
ac3_frame_size(remaining)
|
||||
};
|
||||
|
||||
if frame_size == 0 || frame_size > 8192 {
|
||||
// Invalid frame size — skip this sync word
|
||||
pos = start + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if start + frame_size > data.len() {
|
||||
// Incomplete frame — keep for next PES
|
||||
break;
|
||||
}
|
||||
|
||||
frames.push(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..start + frame_size].to_vec(),
|
||||
});
|
||||
pos = start + frame_size;
|
||||
}
|
||||
|
||||
// Keep unconsumed data for next call
|
||||
// `pos` points to the start of unconsumed data (either a partial sync or leftover)
|
||||
let keep_from = if pos < data.len() {
|
||||
// Find the last sync word position in the unconsumed region
|
||||
find_ac3_sync(&data[pos..])
|
||||
.map(|o| pos + o)
|
||||
.unwrap_or(data.len())
|
||||
} else {
|
||||
data.len()
|
||||
};
|
||||
|
||||
if keep_from < data.len() {
|
||||
self.buf = data[keep_from..].to_vec();
|
||||
} else {
|
||||
self.buf.clear();
|
||||
}
|
||||
|
||||
// If we found no syncword at all, return empty — the data is not valid AC3.
|
||||
frames
|
||||
}
|
||||
|
||||
@@ -97,263 +112,174 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
||||
|
||||
/// Extract bsid from an AC-3/E-AC-3 frame starting at the syncword.
|
||||
/// bsid is at byte 5, bits 7..3.
|
||||
/// AC-3: bsid <= 10, E-AC-3: bsid >= 11 (typically 16).
|
||||
pub fn get_bsid(data: &[u8]) -> u8 {
|
||||
debug_assert!(data.len() >= 6);
|
||||
if data.len() < 6 {
|
||||
return 0;
|
||||
}
|
||||
(data[5] >> 3) & 0x1F
|
||||
}
|
||||
|
||||
/// Calculate E-AC-3 frame size in bytes from the frmsiz field.
|
||||
/// frmsiz is at bits [2:0] of byte 2 concatenated with byte 3.
|
||||
/// Frame size = (frmsiz + 1) * 2 bytes.
|
||||
pub fn eac3_frame_size(data: &[u8]) -> usize {
|
||||
debug_assert!(data.len() >= 4);
|
||||
let frmsiz = ((data[2] as usize & 0x07) << 8) | (data[3] as usize);
|
||||
fn eac3_frame_size(data: &[u8]) -> usize {
|
||||
if data.len() < 4 {
|
||||
return 0;
|
||||
}
|
||||
let frmsiz = ((data[2] as usize & 0x07) << 8) | data[3] as usize;
|
||||
(frmsiz + 1) * 2
|
||||
}
|
||||
|
||||
/// Calculate AC-3 frame size in bytes from fscod and frmsizecod.
|
||||
fn ac3_frame_size(data: &[u8]) -> usize {
|
||||
if data.len() < 5 {
|
||||
return 0;
|
||||
}
|
||||
let fscod = (data[4] >> 6) & 0x03;
|
||||
let frmsizecod = (data[4] & 0x3F) as usize;
|
||||
if frmsizecod >= AC3_FRAME_SIZES.len() {
|
||||
return 0;
|
||||
}
|
||||
let words = AC3_FRAME_SIZES[frmsizecod];
|
||||
match fscod {
|
||||
0 => words[0] * 2,
|
||||
1 => words[1] * 2,
|
||||
2 => words[2] * 2,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// AC-3 frame size table: [frmsizecod] -> [48kHz words, 44.1kHz words, 32kHz words]
|
||||
const AC3_FRAME_SIZES: [[usize; 3]; 38] = [
|
||||
[64, 69, 96],
|
||||
[64, 70, 96],
|
||||
[80, 87, 120],
|
||||
[80, 88, 120],
|
||||
[96, 104, 144],
|
||||
[96, 105, 144],
|
||||
[112, 121, 168],
|
||||
[112, 122, 168],
|
||||
[128, 139, 192],
|
||||
[128, 140, 192],
|
||||
[160, 174, 240],
|
||||
[160, 175, 240],
|
||||
[192, 208, 288],
|
||||
[192, 209, 288],
|
||||
[224, 243, 336],
|
||||
[224, 244, 336],
|
||||
[256, 278, 384],
|
||||
[256, 279, 384],
|
||||
[320, 348, 480],
|
||||
[320, 349, 480],
|
||||
[384, 417, 576],
|
||||
[384, 418, 576],
|
||||
[448, 487, 672],
|
||||
[448, 488, 672],
|
||||
[512, 557, 768],
|
||||
[512, 558, 768],
|
||||
[640, 696, 960],
|
||||
[640, 697, 960],
|
||||
[768, 835, 1152],
|
||||
[768, 836, 1152],
|
||||
[896, 975, 1344],
|
||||
[896, 976, 1344],
|
||||
[1024, 1114, 1536],
|
||||
[1024, 1115, 1536],
|
||||
[1152, 1253, 1728],
|
||||
[1152, 1254, 1728],
|
||||
[1280, 1393, 1920],
|
||||
[1280, 1394, 1920],
|
||||
];
|
||||
|
||||
#[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,
|
||||
}
|
||||
fn make_ac3_frame(fscod: u8, frmsizecod: u8) -> Vec<u8> {
|
||||
let size = AC3_FRAME_SIZES[frmsizecod as usize][fscod as usize] * 2;
|
||||
let mut frame = vec![0u8; size];
|
||||
frame[0] = 0x0B;
|
||||
frame[1] = 0x77;
|
||||
frame[4] = (fscod << 6) | frmsizecod;
|
||||
frame[5] = 0x08 << 3; // bsid = 8 (AC-3)
|
||||
frame
|
||||
}
|
||||
|
||||
/// Build a minimal AC-3 header (bsid <= 10).
|
||||
fn make_ac3_header(bsid: u8) -> Vec<u8> {
|
||||
// 0x0B 0x77 <byte2> <byte3> <byte4> <byte5=bsid>
|
||||
let byte5 = (bsid & 0x1F) << 3;
|
||||
vec![0x0B, 0x77, 0x00, 0x00, 0x00, byte5, 0xAA, 0xBB]
|
||||
}
|
||||
|
||||
/// Build a minimal E-AC-3 header with the given bsid and frmsiz.
|
||||
/// frmsiz encodes frame size: frame_bytes = (frmsiz + 1) * 2.
|
||||
fn make_eac3_header(bsid: u8, frmsiz: u16, payload_fill: u8) -> Vec<u8> {
|
||||
let byte2 = (frmsiz >> 8) as u8 & 0x07;
|
||||
let byte3 = (frmsiz & 0xFF) as u8;
|
||||
let byte5 = (bsid & 0x1F) << 3;
|
||||
let frame_size = (frmsiz as usize + 1) * 2;
|
||||
let mut data = vec![0x0B, 0x77, byte2, byte3, 0x00, byte5];
|
||||
// Pad to full frame size
|
||||
while data.len() < frame_size {
|
||||
data.push(payload_fill);
|
||||
}
|
||||
data.truncate(frame_size);
|
||||
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);
|
||||
}
|
||||
|
||||
// --- bsid detection ---
|
||||
|
||||
#[test]
|
||||
fn bsid_ac3() {
|
||||
let header = make_ac3_header(8);
|
||||
assert_eq!(get_bsid(&header), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsid_eac3() {
|
||||
let header = make_eac3_header(16, 99, 0x00);
|
||||
assert_eq!(get_bsid(&header), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsid_boundary_10() {
|
||||
let header = make_ac3_header(10);
|
||||
assert_eq!(get_bsid(&header), 10);
|
||||
// bsid 10 should be treated as AC-3 (<= 10)
|
||||
assert!(get_bsid(&header) <= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsid_boundary_11() {
|
||||
let header = make_eac3_header(11, 3, 0x00);
|
||||
assert_eq!(get_bsid(&header), 11);
|
||||
// bsid 11 should be treated as E-AC-3 (>= 11)
|
||||
assert!(get_bsid(&header) >= 11);
|
||||
}
|
||||
|
||||
// --- E-AC-3 frame size calculation ---
|
||||
|
||||
#[test]
|
||||
fn eac3_frame_size_basic() {
|
||||
// frmsiz = 99 → frame_size = (99+1)*2 = 200 bytes
|
||||
let header = make_eac3_header(16, 99, 0xDD);
|
||||
assert_eq!(eac3_frame_size(&header), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_frame_size_min() {
|
||||
// frmsiz = 0 → frame_size = (0+1)*2 = 2 bytes
|
||||
let data = [0x0B, 0x77, 0x00, 0x00, 0x00, 0x80];
|
||||
assert_eq!(eac3_frame_size(&data), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_frame_size_large() {
|
||||
// frmsiz = 0x7FF (max 11-bit) → (2047+1)*2 = 4096
|
||||
let data = [0x0B, 0x77, 0x07, 0xFF, 0x00, 0x80];
|
||||
assert_eq!(eac3_frame_size(&data), 4096);
|
||||
}
|
||||
|
||||
// --- parse: E-AC-3 frame extraction ---
|
||||
|
||||
#[test]
|
||||
fn parse_eac3_single_frame() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
// frmsiz = 9 → frame_size = 20 bytes
|
||||
let data = make_eac3_header(16, 9, 0xCC);
|
||||
assert_eq!(data.len(), 20);
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data.len(), 20);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
assert!(frames[0].keyframe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_eac3_frame_with_garbage_prefix() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let mut data = vec![0xFF, 0xFE]; // garbage
|
||||
data.extend_from_slice(&make_eac3_header(16, 4, 0xAA)); // frmsiz=4 → 10 bytes
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data[0], 0x0B);
|
||||
assert_eq!(frames[0].data[1], 0x77);
|
||||
assert_eq!(frames[0].data.len(), 10);
|
||||
}
|
||||
|
||||
// --- parse syncword → frame extracted (AC-3) ---
|
||||
|
||||
#[test]
|
||||
fn parse_syncword() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
// AC3 frame starting with syncword (bsid=8)
|
||||
let data = make_ac3_header(8);
|
||||
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 mut data = vec![0xFF, 0xFE];
|
||||
data.extend_from_slice(&make_ac3_header(8));
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data[0], 0x0B);
|
||||
assert_eq!(frames[0].data[1], 0x77);
|
||||
}
|
||||
|
||||
// --- all frames are keyframes ---
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
for i in 0..5u8 {
|
||||
let mut data = make_ac3_header(8);
|
||||
data.push(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());
|
||||
let pes = PesPacket {
|
||||
pid: 0,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: vec![],
|
||||
};
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_byte_pes() {
|
||||
fn parse_single_frame() {
|
||||
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 = make_ac3_header(8);
|
||||
// 45000 ticks = 0.5 seconds → 500_000_000 ns
|
||||
let pes = make_pes(data, Some(45000));
|
||||
let frame_data = make_ac3_frame(0, 2); // 48kHz, 80 words = 160 bytes
|
||||
let pes = PesPacket {
|
||||
pid: 0,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: frame_data.clone(),
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 500_000_000);
|
||||
assert_eq!(frames[0].data.len(), 160);
|
||||
}
|
||||
|
||||
// --- None PTS ---
|
||||
#[test]
|
||||
fn parse_frame_spanning_two_pes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2); // 160 bytes
|
||||
let mid = 80;
|
||||
|
||||
// First PES: first half of frame
|
||||
let pes1 = PesPacket {
|
||||
pid: 0,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: frame_data[..mid].to_vec(),
|
||||
};
|
||||
let frames1 = parser.parse(&pes1);
|
||||
assert!(frames1.is_empty(), "partial frame should not emit");
|
||||
|
||||
// Second PES: second half
|
||||
let pes2 = PesPacket {
|
||||
pid: 0,
|
||||
pts: Some(93000),
|
||||
dts: None,
|
||||
data: frame_data[mid..].to_vec(),
|
||||
};
|
||||
let frames2 = parser.parse(&pes2);
|
||||
assert_eq!(frames2.len(), 1);
|
||||
assert_eq!(frames2[0].data.len(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pts() {
|
||||
fn skip_garbage_before_sync() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let data = make_ac3_header(8);
|
||||
let pes = make_pes(data, None);
|
||||
let frame_data = make_ac3_frame(0, 2);
|
||||
let mut data = vec![0xDE, 0xAD, 0xBE, 0xEF]; // garbage
|
||||
data.extend_from_slice(&frame_data);
|
||||
let pes = PesPacket {
|
||||
pid: 0,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 0);
|
||||
assert_eq!(frames[0].data.len(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac3_frame_size_table() {
|
||||
// fscod=0 (48kHz), frmsizecod=0: 64 words = 128 bytes
|
||||
assert_eq!(ac3_frame_size(&[0x0B, 0x77, 0, 0, 0x00, 0x40]), 128);
|
||||
// fscod=0 (48kHz), frmsizecod=2: 80 words = 160 bytes
|
||||
assert_eq!(ac3_frame_size(&[0x0B, 0x77, 0, 0, 0x02, 0x40]), 160);
|
||||
}
|
||||
}
|
||||
|
||||
+130
-197
@@ -2,16 +2,17 @@
|
||||
//!
|
||||
//! DTS core syncword: 0x7FFE8001 (32 bits).
|
||||
//! DTS-HD MA/HRA extension syncword: 0x64582025 (32 bits), appears after the core frame.
|
||||
//! The extension contains high-resolution audio data and is appended to the core frame.
|
||||
//! All frames are keyframes (no inter-frame dependencies).
|
||||
//! Each PES packet = one frame.
|
||||
//! Buffers across PES boundaries so frames spanning two PES packets
|
||||
//! are emitted complete.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
|
||||
/// DTS-HD extension syncword bytes.
|
||||
const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
|
||||
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
|
||||
|
||||
pub struct DtsParser;
|
||||
pub struct DtsParser {
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Default for DtsParser {
|
||||
fn default() -> Self {
|
||||
@@ -21,7 +22,9 @@ impl Default for DtsParser {
|
||||
|
||||
impl DtsParser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
buf: Vec::with_capacity(32768),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,31 +35,77 @@ impl CodecParser for DtsParser {
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
let data = &pes.data;
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
|
||||
// Look for a DTS-HD extension substream after the core.
|
||||
// If found, include both core + extension in the output frame.
|
||||
let frame_data = match find_dts_hd_ext_sync(data) {
|
||||
Some(ext_offset) => {
|
||||
let ext = &data[ext_offset..];
|
||||
if ext.len() >= 9 {
|
||||
let ext_size = dts_hd_ext_frame_size(ext);
|
||||
let total_end = ext_offset + ext_size;
|
||||
let end = total_end.min(data.len());
|
||||
data[..end].to_vec()
|
||||
} else {
|
||||
// Extension header too short to parse size; include all data.
|
||||
data.to_vec()
|
||||
let data = &self.buf;
|
||||
let mut frames = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < data.len() {
|
||||
// Find DTS core sync
|
||||
let start = match find_sync(&data[pos..], &DTS_CORE_SYNC) {
|
||||
Some(offset) => pos + offset,
|
||||
None => break,
|
||||
};
|
||||
|
||||
// Need at least 10 bytes for core header to get frame size
|
||||
if start + 10 > data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let core_size = dts_core_frame_size(&data[start..]);
|
||||
if core_size == 0 || core_size > 32768 {
|
||||
pos = start + 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
if start + core_size > data.len() {
|
||||
// Incomplete core frame
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for DTS-HD extension after core
|
||||
let mut total_size = core_size;
|
||||
if start + core_size + 4 <= data.len() {
|
||||
if let Some(0) = find_sync(
|
||||
&data[start + core_size..start + core_size + 4],
|
||||
&DTS_HD_EXT_SYNC,
|
||||
) {
|
||||
let ext = &data[start + core_size..];
|
||||
if ext.len() >= 9 {
|
||||
let ext_size = dts_hd_ext_frame_size(ext);
|
||||
if start + core_size + ext_size <= data.len() {
|
||||
total_size = core_size + ext_size;
|
||||
}
|
||||
// If ext incomplete, just emit core
|
||||
}
|
||||
}
|
||||
}
|
||||
None => data.to_vec(),
|
||||
|
||||
frames.push(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..start + total_size].to_vec(),
|
||||
});
|
||||
pos = start + total_size;
|
||||
}
|
||||
|
||||
// Keep unconsumed data
|
||||
let keep_from = if pos < data.len() {
|
||||
find_sync(&data[pos..], &DTS_CORE_SYNC)
|
||||
.map(|o| pos + o)
|
||||
.unwrap_or(data.len())
|
||||
} else {
|
||||
data.len()
|
||||
};
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: frame_data,
|
||||
}]
|
||||
if keep_from < data.len() {
|
||||
self.buf = data[keep_from..].to_vec();
|
||||
} else {
|
||||
self.buf.clear();
|
||||
}
|
||||
|
||||
frames
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
@@ -64,30 +113,40 @@ impl CodecParser for DtsParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the DTS-HD extension syncword (0x64582025) in data.
|
||||
/// Returns the byte offset of the sync, or None.
|
||||
pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
|
||||
fn find_sync(data: &[u8], pattern: &[u8; 4]) -> Option<usize> {
|
||||
if data.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
(0..=data.len() - 4).find(|&i| {
|
||||
data[i] == DTS_HD_EXT_SYNC[0]
|
||||
&& data[i + 1] == DTS_HD_EXT_SYNC[1]
|
||||
&& data[i + 2] == DTS_HD_EXT_SYNC[2]
|
||||
&& data[i + 3] == DTS_HD_EXT_SYNC[3]
|
||||
})
|
||||
(0..=data.len() - 4).find(|&i| data[i..i + 4] == *pattern)
|
||||
}
|
||||
|
||||
/// Calculate DTS-HD extension frame size from the extension header.
|
||||
/// The size field is at bytes 6-8 of the extension:
|
||||
/// ((ext[6] & 0x1F) << 11) | (ext[7] << 3) | (ext[8] >> 5) + 1
|
||||
/// DTS core frame size from header bits.
|
||||
/// fsize is at bits 46-59 (14 bits) of the header: bytes 5-7.
|
||||
fn dts_core_frame_size(data: &[u8]) -> usize {
|
||||
if data.len() < 10 {
|
||||
return 0;
|
||||
}
|
||||
// fsize field: 14 bits starting at bit 46
|
||||
// byte 5 bits 1-0, byte 6 all 8, byte 7 bits 7-4
|
||||
let fsize =
|
||||
((data[5] as usize & 0x03) << 12) | ((data[6] as usize) << 4) | ((data[7] as usize) >> 4);
|
||||
fsize + 1
|
||||
}
|
||||
|
||||
/// DTS-HD extension frame size from extension header.
|
||||
pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize {
|
||||
debug_assert!(ext.len() >= 9);
|
||||
if ext.len() < 9 {
|
||||
return 0;
|
||||
}
|
||||
let raw =
|
||||
((ext[6] as usize & 0x1F) << 11) | ((ext[7] as usize) << 3) | ((ext[8] as usize) >> 5);
|
||||
raw + 1
|
||||
}
|
||||
|
||||
pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
|
||||
find_sync(data, &DTS_HD_EXT_SYNC)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -102,164 +161,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a DTS core frame with given payload size.
|
||||
fn make_dts_core(payload_len: usize) -> Vec<u8> {
|
||||
let mut data = vec![0x7F, 0xFE, 0x80, 0x01];
|
||||
data.resize(4 + payload_len, 0xAA);
|
||||
fn make_dts_core(size: usize) -> Vec<u8> {
|
||||
let fsize = size - 1;
|
||||
let mut data = vec![0u8; size];
|
||||
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
||||
data[5] = (data[5] & 0xFC) | ((fsize >> 12) & 0x03) as u8;
|
||||
data[6] = ((fsize >> 4) & 0xFF) as u8;
|
||||
data[7] = (data[7] & 0x0F) | (((fsize & 0x0F) << 4) as u8);
|
||||
data
|
||||
}
|
||||
|
||||
/// Build a DTS-HD extension header + payload.
|
||||
/// ext_size is the value to encode (frame size = ext_size + 1 reported by dts_hd_ext_frame_size,
|
||||
/// but we encode raw = ext_size so that dts_hd_ext_frame_size returns ext_size + 1).
|
||||
fn make_dts_hd_ext(raw_size_field: usize, payload_fill: u8) -> Vec<u8> {
|
||||
let total = raw_size_field + 1; // the size dts_hd_ext_frame_size will return
|
||||
let byte6 = ((raw_size_field >> 11) & 0x1F) as u8;
|
||||
let byte7 = ((raw_size_field >> 3) & 0xFF) as u8;
|
||||
let byte8 = ((raw_size_field & 0x07) << 5) as u8;
|
||||
let mut data = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, byte6, byte7, byte8];
|
||||
while data.len() < total {
|
||||
data.push(payload_fill);
|
||||
}
|
||||
data.truncate(total);
|
||||
data
|
||||
}
|
||||
|
||||
// --- DTS-HD extension sync detection ---
|
||||
|
||||
#[test]
|
||||
fn find_ext_sync_at_offset() {
|
||||
let mut data = vec![0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00];
|
||||
data.extend_from_slice(&[0x64, 0x58, 0x20, 0x25]);
|
||||
assert_eq!(find_dts_hd_ext_sync(&data), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ext_sync_none() {
|
||||
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00];
|
||||
assert_eq!(find_dts_hd_ext_sync(&data), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ext_sync_at_start() {
|
||||
let data = vec![0x64, 0x58, 0x20, 0x25, 0x00];
|
||||
assert_eq!(find_dts_hd_ext_sync(&data), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ext_sync_too_short() {
|
||||
let data = vec![0x64, 0x58, 0x20];
|
||||
assert_eq!(find_dts_hd_ext_sync(&data), None);
|
||||
}
|
||||
|
||||
// --- DTS-HD extension frame size ---
|
||||
|
||||
#[test]
|
||||
fn ext_frame_size_basic() {
|
||||
// raw_size_field = 100 → frame size = 101
|
||||
let ext = make_dts_hd_ext(100, 0xBB);
|
||||
assert_eq!(dts_hd_ext_frame_size(&ext), 101);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_frame_size_zero() {
|
||||
// raw_size_field = 0 → frame size = 1
|
||||
let ext = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
assert_eq!(dts_hd_ext_frame_size(&ext), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_frame_size_large() {
|
||||
// raw = 0x1F << 11 | 0xFF << 3 | 0x07 = 0xFFFF = 65535
|
||||
// frame_size = 65536
|
||||
let ext = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x1F, 0xFF, 0xFF];
|
||||
// byte6=0x1F, byte7=0xFF, byte8=0xFF
|
||||
// (0x1F << 11) | (0xFF << 3) | (0xFF >> 5) = 63488 | 2040 | 7 = 65535
|
||||
assert_eq!(dts_hd_ext_frame_size(&ext), 65536);
|
||||
}
|
||||
|
||||
// --- parse: core + extension frame ---
|
||||
|
||||
#[test]
|
||||
fn parse_core_plus_extension() {
|
||||
let mut parser = DtsParser::new();
|
||||
let core = make_dts_core(20); // 24 bytes total
|
||||
let ext = make_dts_hd_ext(50, 0xCC); // 51 bytes
|
||||
let mut data = core.clone();
|
||||
data.extend_from_slice(&ext);
|
||||
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Frame should include core (24) + extension (51) = 75 bytes
|
||||
assert_eq!(frames[0].data.len(), 24 + 51);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
assert!(frames[0].keyframe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_core_only() {
|
||||
let mut parser = DtsParser::new();
|
||||
let data = make_dts_core(10);
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_core_plus_extension_truncated_at_buffer_end() {
|
||||
let mut parser = DtsParser::new();
|
||||
let core = make_dts_core(4); // 8 bytes
|
||||
// Extension claims 200 bytes but we only provide 20
|
||||
let ext = make_dts_hd_ext(199, 0xDD); // wants 200 bytes
|
||||
let mut data = core;
|
||||
// Only append partial extension (first 20 bytes)
|
||||
data.extend_from_slice(&ext[..20.min(ext.len())]);
|
||||
|
||||
let total_len = data.len();
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Should be clamped to actual data length
|
||||
assert_eq!(frames[0].data.len(), total_len);
|
||||
}
|
||||
|
||||
// --- basic tests (carried over) ---
|
||||
|
||||
#[test]
|
||||
fn parse_basic_frame() {
|
||||
let mut parser = DtsParser::new();
|
||||
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();
|
||||
@@ -268,11 +179,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pts() {
|
||||
fn parse_single_frame() {
|
||||
let mut parser = DtsParser::new();
|
||||
let pes = make_pes(vec![0x7F, 0xFE, 0x80, 0x01], None);
|
||||
let frame = make_dts_core(512);
|
||||
let pes = make_pes(frame, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 0);
|
||||
assert_eq!(frames[0].data.len(), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frame_spanning_two_pes() {
|
||||
let mut parser = DtsParser::new();
|
||||
let frame = make_dts_core(512);
|
||||
let mid = 256;
|
||||
|
||||
let pes1 = make_pes(frame[..mid].to_vec(), Some(90000));
|
||||
assert!(parser.parse(&pes1).is_empty());
|
||||
|
||||
let pes2 = make_pes(frame[mid..].to_vec(), Some(93000));
|
||||
let frames = parser.parse(&pes2);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data.len(), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = DtsParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,12 +111,12 @@ impl CodecParser for H264Parser {
|
||||
// pictureParameterSetNALUnit = pps
|
||||
|
||||
let mut record = vec![
|
||||
1, // configurationVersion
|
||||
sps[1], // profile
|
||||
sps[2], // compatibility
|
||||
sps[3], // level
|
||||
0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
|
||||
0xE1, // 3 bits reserved (111) + 5 bits numSPS (1)
|
||||
1, // configurationVersion
|
||||
sps[1], // profile
|
||||
sps[2], // compatibility
|
||||
sps[3], // level
|
||||
0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
|
||||
0xE1, // 3 bits reserved (111) + 5 bits numSPS (1)
|
||||
(sps.len() >> 8) as u8,
|
||||
sps.len() as u8,
|
||||
];
|
||||
@@ -180,6 +180,9 @@ pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
// Range excludes last 2 bytes since we read 3 bytes at each position.
|
||||
// data.len()-2 as exclusive upper bound means last checked index is data.len()-3,
|
||||
// which accesses data[len-3], data[len-2], data[len-1] — all valid.
|
||||
(from..data.len() - 2).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
|
||||
}
|
||||
|
||||
|
||||
+21
-37
@@ -52,8 +52,9 @@ impl CodecParser for HevcParser {
|
||||
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
let mut frame_data = Vec::new();
|
||||
|
||||
// Scan NAL units
|
||||
// Single-pass NAL scan: extract params, detect keyframes, build length-prefixed output
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(data, sc_pos) {
|
||||
@@ -68,45 +69,28 @@ impl CodecParser for HevcParser {
|
||||
let nal_type = (data[nal_start] >> 1) & 0x3F;
|
||||
|
||||
match nal_type {
|
||||
NAL_VPS => self.vps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_SPS => self.sps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_PPS => self.pps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_VPS => {
|
||||
self.vps = Some(data[nal_start..end].to_vec());
|
||||
}
|
||||
NAL_SPS => {
|
||||
self.sps = Some(data[nal_start..end].to_vec());
|
||||
}
|
||||
NAL_PPS => {
|
||||
self.pps = Some(data[nal_start..end].to_vec());
|
||||
}
|
||||
NAL_AUD => {} // Skip access unit delimiters
|
||||
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
|
||||
keyframe = true;
|
||||
let nal = &data[nal_start..end];
|
||||
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
_ => {
|
||||
// All other NAL types (slices, SEI, DV RPU, etc.) pass through
|
||||
let nal = &data[nal_start..end];
|
||||
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B to length-prefixed NALUs.
|
||||
// Skip VPS/SPS/PPS/AUD — they're in codecPrivate.
|
||||
let mut frame_data = Vec::new();
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(&pes.data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(&pes.data, sc_pos) {
|
||||
let next = find_start_code(&pes.data, nal_start).unwrap_or(pes.data.len());
|
||||
let mut end = next;
|
||||
while end > nal_start && pes.data[end - 1] == 0x00 {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
if nal_start < pes.data.len() {
|
||||
let nal_type = (pes.data[nal_start] >> 1) & 0x3F;
|
||||
// Skip parameter sets and AUD
|
||||
if nal_type != NAL_VPS
|
||||
&& nal_type != NAL_SPS
|
||||
&& nal_type != NAL_PPS
|
||||
&& nal_type != NAL_AUD
|
||||
{
|
||||
let nal = &pes.data[nal_start..end];
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
|
||||
@@ -31,9 +31,11 @@ pub struct Frame {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Convert 90kHz PTS to nanoseconds.
|
||||
/// Convert 90kHz PTS to nanoseconds (round to nearest).
|
||||
pub fn pts_to_ns(pts: i64) -> i64 {
|
||||
pts * 100_000 / 9
|
||||
// pts * 1_000_000_000 / 90_000 = pts * 100_000 / 9
|
||||
// Add half-divisor for rounding: (pts * 100_000 + 4) / 9
|
||||
(pts * 100_000 + 4) / 9
|
||||
}
|
||||
|
||||
/// Trait for codec-specific elementary stream parsers.
|
||||
|
||||
+65
-15
@@ -50,6 +50,8 @@ const ASPECT_RATIOS: [(u8, u8); 5] = [
|
||||
pub struct Mpeg2Parser {
|
||||
/// Raw bytes of the last seen sequence header (+ sequence extension if found).
|
||||
seq_header: Option<Vec<u8>>,
|
||||
/// Whether we've captured the sequence extension (B5) already.
|
||||
has_extension: bool,
|
||||
}
|
||||
|
||||
impl Default for Mpeg2Parser {
|
||||
@@ -60,7 +62,10 @@ impl Default for Mpeg2Parser {
|
||||
|
||||
impl Mpeg2Parser {
|
||||
pub fn new() -> Self {
|
||||
Self { seq_header: None }
|
||||
Self {
|
||||
seq_header: None,
|
||||
has_extension: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract resolution from a captured sequence header.
|
||||
@@ -105,23 +110,68 @@ impl CodecParser for Mpeg2Parser {
|
||||
|
||||
match code {
|
||||
SEQ_HEADER_CODE => {
|
||||
// Capture sequence header: from start code to next start code
|
||||
// (or to the sequence extension if present).
|
||||
// MPEG-2 sequence header: 00 00 01 B3 + variable data.
|
||||
// Base header: 8 bytes after start code = 12 bytes total.
|
||||
// Then possibly 64 intra quantizer values (bit-packed from bit 63).
|
||||
// Then possibly 64 non-intra quantizer values.
|
||||
// Then extensions (00 00 01 B5).
|
||||
//
|
||||
// Capture to the next start code within this PES data.
|
||||
// If no next start code exists (extension in next PES), capture
|
||||
// just the sequence header without extensions.
|
||||
let hdr_start = sc;
|
||||
let hdr_end = find_start_code(data, sc + 4).unwrap_or(data.len());
|
||||
let next_sc = find_start_code(data, sc + 4);
|
||||
let hdr_end = match next_sc {
|
||||
Some(next) if next + 3 < data.len() => {
|
||||
let mut end = next;
|
||||
// Include B5 extensions
|
||||
while end + 3 < data.len() && data[end + 3] == SEQ_EXT_CODE {
|
||||
end = find_start_code(data, end + 4).unwrap_or(data.len());
|
||||
}
|
||||
end
|
||||
}
|
||||
_ => {
|
||||
// No next start code in this PES — calculate exact header size.
|
||||
// Bit 62: load_intra_quantiser_matrix
|
||||
// Bit 62+1+512: load_non_intra_quantiser_matrix (if intra present)
|
||||
// Bit 62+1: load_non_intra_quantiser_matrix (if intra absent)
|
||||
if sc + 12 > data.len() {
|
||||
data.len()
|
||||
} else {
|
||||
let mut bits = 63u32; // bits consumed so far
|
||||
let intra = (data[sc + 11] & 0x02) != 0;
|
||||
if intra {
|
||||
bits += 64 * 8;
|
||||
}
|
||||
// Non-intra flag is at current bit position
|
||||
let byte_pos = (bits / 8) as usize;
|
||||
let bit_pos = 7 - (bits % 8) as u8;
|
||||
if sc + 4 + byte_pos < data.len() {
|
||||
let non_intra = (data[sc + 4 + byte_pos] >> bit_pos) & 1 != 0;
|
||||
bits += 1;
|
||||
if non_intra {
|
||||
bits += 64 * 8;
|
||||
}
|
||||
}
|
||||
let total_bytes = 4 + ((bits + 7) / 8) as usize;
|
||||
(sc + total_bytes).min(data.len())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut seq_data = data[hdr_start..hdr_end].to_vec();
|
||||
|
||||
// Check if sequence extension follows immediately.
|
||||
if hdr_end + 3 < data.len() && data[hdr_end + 3] == SEQ_EXT_CODE {
|
||||
let ext_end = find_start_code(data, hdr_end + 4).unwrap_or(data.len());
|
||||
seq_data.extend_from_slice(&data[hdr_end..ext_end]);
|
||||
}
|
||||
|
||||
self.seq_header = Some(seq_data);
|
||||
// Sequence header implies I-frame follows.
|
||||
self.seq_header = Some(data[hdr_start..hdr_end].to_vec());
|
||||
keyframe = true;
|
||||
pos = sc + 4;
|
||||
pos = if next_sc.is_some() { hdr_end } else { sc + 4 };
|
||||
}
|
||||
SEQ_EXT_CODE if self.seq_header.is_some() && !self.has_extension => {
|
||||
// Sequence extension appears after seq header (may be in next PES).
|
||||
// Append it to the stored seq_header.
|
||||
let ext_end = find_start_code(data, sc + 4).unwrap_or(data.len());
|
||||
if let Some(ref mut hdr) = self.seq_header {
|
||||
hdr.extend_from_slice(&data[sc..ext_end]);
|
||||
}
|
||||
self.has_extension = true;
|
||||
pos = ext_end;
|
||||
}
|
||||
PICTURE_CODE => {
|
||||
// Picture header: bytes after start code contain temporal_reference
|
||||
|
||||
+198
-40
@@ -1,14 +1,25 @@
|
||||
//! 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.
|
||||
//! BD-TS TrueHD PES packets contain interleaved AC-3 + TrueHD access units.
|
||||
//! Access units span PES boundaries — must buffer and reassemble.
|
||||
//!
|
||||
//! TrueHD access unit header (4 bytes):
|
||||
//! [0..1] upper 4 bits = parity, lower 12 bits = length in 2-byte words
|
||||
//! [2..3] timing value
|
||||
//! [4..] substream data (major sync 0xF8726FBA may appear at offset 4)
|
||||
//!
|
||||
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
|
||||
//! We skip AC-3 frames and only emit TrueHD access units.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
|
||||
pub struct TrueHdParser;
|
||||
/// Duration of one TrueHD access unit in nanoseconds (1/1200 second).
|
||||
const AU_DURATION_NS: i64 = 833_333;
|
||||
|
||||
pub struct TrueHdParser {
|
||||
buf: Vec<u8>,
|
||||
next_pts_ns: i64,
|
||||
}
|
||||
|
||||
impl Default for TrueHdParser {
|
||||
fn default() -> Self {
|
||||
@@ -18,7 +29,61 @@ impl Default for TrueHdParser {
|
||||
|
||||
impl TrueHdParser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
buf: Vec::with_capacity(32768),
|
||||
next_pts_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip an AC-3 frame starting at the current buffer position.
|
||||
/// Returns number of bytes consumed, or 0 if not enough data.
|
||||
fn skip_ac3_frame(&self) -> usize {
|
||||
if self.buf.len() < 6 {
|
||||
return 0;
|
||||
}
|
||||
// AC-3 frame size from frmsizcod + fscod
|
||||
// Byte 4: [fscod:2][frmsizecod:6]
|
||||
let fscod = (self.buf[4] >> 6) & 0x03;
|
||||
let frmsizecod = (self.buf[4] & 0x3F) as usize;
|
||||
// Frame size in 16-bit words per fscod (simplified table for common rates)
|
||||
let frame_words = match fscod {
|
||||
0 => {
|
||||
// 48 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
64, 64, 80, 80, 96, 96, 112, 112, 128, 128,
|
||||
160, 160, 192, 192, 224, 224, 256, 256, 320, 320,
|
||||
384, 384, 448, 448, 512, 512, 640, 640, 768, 768,
|
||||
896, 896, 1024, 1024, 1152, 1152, 1280, 1280,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
1 => {
|
||||
// 44.1 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
69, 70, 87, 88, 104, 105, 121, 122, 139, 140,
|
||||
174, 175, 208, 209, 243, 244, 278, 279, 348, 349,
|
||||
417, 418, 487, 488, 557, 558, 696, 697, 835, 836,
|
||||
975, 976, 1114, 1115, 1253, 1254, 1393, 1394,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
2 => {
|
||||
// 32 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
96, 96, 120, 120, 144, 144, 168, 168, 192, 192,
|
||||
240, 240, 288, 288, 336, 336, 384, 384, 480, 480,
|
||||
576, 576, 672, 672, 768, 768, 960, 960, 1152, 1152,
|
||||
1344, 1344, 1536, 1536, 1728, 1728, 1920, 1920,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
let frame_bytes = frame_words * 2;
|
||||
if frame_bytes == 0 || self.buf.len() < frame_bytes {
|
||||
return 0;
|
||||
}
|
||||
frame_bytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +92,62 @@ impl CodecParser for TrueHdParser {
|
||||
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(),
|
||||
}]
|
||||
|
||||
if let Some(pts) = pes.pts {
|
||||
self.next_pts_ns = pts_to_ns(pts);
|
||||
}
|
||||
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
|
||||
let mut frames = Vec::new();
|
||||
|
||||
loop {
|
||||
if self.buf.len() < 4 {
|
||||
break;
|
||||
}
|
||||
|
||||
// AC-3 frame (interleaved): starts with sync word 0x0B77
|
||||
if self.buf[0] == 0x0B && self.buf[1] == 0x77 {
|
||||
let skip = self.skip_ac3_frame();
|
||||
if skip == 0 {
|
||||
break; // incomplete AC-3 frame, wait for more data
|
||||
}
|
||||
self.buf.drain(..skip);
|
||||
continue;
|
||||
}
|
||||
|
||||
// TrueHD access unit: lower 12 bits of first 2 bytes = length in words
|
||||
let unit_words = (((self.buf[0] as usize) << 8) | self.buf[1] as usize) & 0xFFF;
|
||||
if unit_words == 0 {
|
||||
self.buf.drain(..2);
|
||||
continue;
|
||||
}
|
||||
let unit_bytes = unit_words * 2;
|
||||
if unit_bytes > 32768 {
|
||||
// Likely misaligned — try to resync by scanning for AC-3 sync or
|
||||
// a valid TrueHD length
|
||||
self.buf.drain(..2);
|
||||
continue;
|
||||
}
|
||||
if self.buf.len() < unit_bytes {
|
||||
break; // incomplete access unit, wait for more data
|
||||
}
|
||||
|
||||
let is_major_sync = unit_bytes >= 8
|
||||
&& (u32::from_be_bytes([self.buf[4], self.buf[5], self.buf[6], self.buf[7]])
|
||||
& 0xFFFF_FFFE)
|
||||
== 0xF872_6FBA;
|
||||
|
||||
frames.push(Frame {
|
||||
pts_ns: self.next_pts_ns,
|
||||
keyframe: is_major_sync,
|
||||
data: self.buf[..unit_bytes].to_vec(),
|
||||
});
|
||||
self.buf.drain(..unit_bytes);
|
||||
self.next_pts_ns += AU_DURATION_NS;
|
||||
}
|
||||
|
||||
frames
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
@@ -54,35 +169,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
fn make_truehd_unit(size_bytes: usize) -> Vec<u8> {
|
||||
let words = size_bytes / 2;
|
||||
let mut data = vec![0u8; size_bytes];
|
||||
data[0] = ((words >> 8) & 0x0F) as u8;
|
||||
data[1] = (words & 0xFF) as u8;
|
||||
data
|
||||
}
|
||||
|
||||
#[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());
|
||||
fn make_ac3_frame() -> Vec<u8> {
|
||||
// Minimal AC-3 frame: sync 0x0B77, fscod=0 (48kHz), frmsizecod=0 (64 words = 128 bytes)
|
||||
let mut data = vec![0u8; 128];
|
||||
data[0] = 0x0B;
|
||||
data[1] = 0x77;
|
||||
data[4] = 0x00; // fscod=0, frmsizecod=0
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -91,4 +192,61 @@ mod tests {
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_unit() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
let unit = make_truehd_unit(200);
|
||||
let pes = make_pes(unit, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unit_spanning_two_pes() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
let unit = make_truehd_unit(200);
|
||||
let mid = 100;
|
||||
|
||||
let pes1 = make_pes(unit[..mid].to_vec(), Some(90000));
|
||||
assert!(parser.parse(&pes1).is_empty());
|
||||
|
||||
let pes2 = make_pes(unit[mid..].to_vec(), Some(93000));
|
||||
let frames = parser.parse(&pes2);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_units_incrementing_pts() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
let mut data = make_truehd_unit(100);
|
||||
data.extend_from_slice(&make_truehd_unit(120));
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0].data.len(), 100);
|
||||
assert_eq!(frames[1].data.len(), 120);
|
||||
assert_eq!(frames[1].pts_ns - frames[0].pts_ns, AU_DURATION_NS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_interleaved_ac3() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
let ac3 = make_ac3_frame();
|
||||
let truehd = make_truehd_unit(200);
|
||||
let mut data = ac3;
|
||||
data.extend_from_slice(&truehd);
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = TrueHdParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,8 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> {
|
||||
}
|
||||
|
||||
fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
|
||||
(from..data.len().saturating_sub(2)).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
|
||||
(from..data.len().saturating_sub(2))
|
||||
.find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+275
-430
@@ -1,484 +1,329 @@
|
||||
//! DiscStream — read BD-TS data from an optical disc drive.
|
||||
//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
|
||||
//!
|
||||
//! Read-only stream. Wraps Drive + Disc.
|
||||
//! Handles drive init, AACS decryption, and sector reading.
|
||||
//! One stream type for all disc sources. The source is a SectorReader —
|
||||
//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care.
|
||||
//!
|
||||
//! Reading state (extent index, offset, batch size, error recovery) is stored
|
||||
//! directly on the struct so that successive `read()` calls advance through
|
||||
//! the disc instead of restarting from byte 0.
|
||||
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
|
||||
|
||||
use super::IOStream;
|
||||
use crate::disc::{
|
||||
detect_max_batch_sectors, ContentFormat, Disc, DiscTitle, Extent, MIN_BATCH_SECTORS,
|
||||
RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER,
|
||||
};
|
||||
use crate::drive::Drive;
|
||||
use crate::error::Error;
|
||||
use crate::speed::DriveSpeed;
|
||||
use std::io::{self, Read, Write};
|
||||
use crate::disc::{detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions};
|
||||
use crate::sector::SectorReader;
|
||||
use std::io;
|
||||
|
||||
/// AACS decryption parameters needed at read time.
|
||||
/// Extracted from `AacsState` so we don't need `Clone` on the full struct.
|
||||
struct AacsDecrypt {
|
||||
unit_keys: Vec<(u32, [u8; 16])>,
|
||||
read_data_key: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
/// Options for opening a disc stream.
|
||||
#[derive(Default)]
|
||||
pub struct DiscOptions {
|
||||
/// Device path (e.g. "/dev/sg4"). None = auto-detect.
|
||||
pub device: Option<std::path::PathBuf>,
|
||||
/// KEYDB.cfg path. None = search standard locations.
|
||||
pub keydb_path: Option<std::path::PathBuf>,
|
||||
/// Which title to read (0-based). None = longest title.
|
||||
pub title_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Optical disc stream. Read-only — yields decrypted BD-TS bytes.
|
||||
/// Disc stream. Reads sectors from any source → PES frames.
|
||||
///
|
||||
/// Embeds the reading state that `ContentReader` would normally hold, so that
|
||||
/// successive `read()` calls advance through the disc correctly.
|
||||
/// Sources: physical drive, ISO file, or any SectorReader.
|
||||
/// Decrypt, demux, and codec parsing happen internally.
|
||||
pub struct DiscStream {
|
||||
disc_title: DiscTitle,
|
||||
disc: Disc,
|
||||
session: Drive,
|
||||
// Read buffer: holds one decoded batch
|
||||
batch_buf: Vec<u8>,
|
||||
batch_pos: usize,
|
||||
eof: bool,
|
||||
reader: Box<dyn SectorReader>,
|
||||
title: DiscTitle,
|
||||
disc: Option<Disc>,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
|
||||
// ── Reading state (replaces ContentReader) ──
|
||||
// Extents to read
|
||||
extents: Vec<Extent>,
|
||||
|
||||
// Position
|
||||
current_extent: usize,
|
||||
current_offset: u32,
|
||||
#[allow(dead_code)]
|
||||
content_format: ContentFormat,
|
||||
aacs: Option<AacsDecrypt>,
|
||||
css: Option<crate::css::CssState>,
|
||||
unit_key_idx: usize,
|
||||
|
||||
// Buffer
|
||||
read_buf: Vec<u8>,
|
||||
/// Current batch size in sectors (adapts on errors)
|
||||
buf_valid: usize,
|
||||
|
||||
// Batch size for reads
|
||||
batch_sectors: u16,
|
||||
/// Maximum batch size detected from kernel limits
|
||||
max_batch_sectors: u16,
|
||||
/// Consecutive successful batch reads
|
||||
ok_streak: u32,
|
||||
/// Consecutive errors at current position
|
||||
error_streak: u32,
|
||||
/// Total read errors encountered
|
||||
pub errors: u32,
|
||||
pub errors: u64,
|
||||
eof: bool,
|
||||
|
||||
// PES output
|
||||
ts_demuxer: Option<super::ts::TsDemuxer>,
|
||||
ps_demuxer: Option<super::ps::PsDemuxer>,
|
||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
}
|
||||
|
||||
impl DiscStream {
|
||||
/// Open the disc drive and scan disc metadata.
|
||||
pub fn open(opts: DiscOptions) -> Result<Self, Error> {
|
||||
let mut session = match opts.device {
|
||||
Some(ref d) => Drive::open(d)?,
|
||||
None => crate::drive::find_drive().ok_or_else(|| Error::DeviceNotFound {
|
||||
path: String::new(),
|
||||
})?,
|
||||
};
|
||||
session.wait_ready()?;
|
||||
let _ = session.init();
|
||||
let _ = session.probe_disc();
|
||||
/// Create a disc stream from any sector reader.
|
||||
///
|
||||
/// Works with physical drives and ISO files — both implement SectorReader.
|
||||
/// The caller opens the source, scans for titles/keys, and passes them in.
|
||||
/// The stream handles demuxing, decryption, and codec parsing internally.
|
||||
pub fn new(
|
||||
reader: Box<dyn SectorReader>,
|
||||
title: DiscTitle,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
content_format: crate::disc::ContentFormat,
|
||||
) -> Self {
|
||||
let extents = title.extents.clone();
|
||||
|
||||
let scan_opts = match opts.keydb_path {
|
||||
Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp.clone()),
|
||||
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 mut pids = Vec::new();
|
||||
let mut parsers = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (idx, s) in title.streams.iter().enumerate() {
|
||||
let (pid, codec) = match s {
|
||||
crate::disc::Stream::Video(v) => (v.pid, v.codec),
|
||||
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
|
||||
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, idx));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec)));
|
||||
}
|
||||
let disc_title = disc.titles[title_index].clone();
|
||||
let extents = disc_title.extents.clone();
|
||||
let content_format = disc_title.content_format;
|
||||
let aacs = disc.aacs.as_ref().map(|a| AacsDecrypt {
|
||||
unit_keys: a.unit_keys.clone(),
|
||||
read_data_key: a.read_data_key,
|
||||
});
|
||||
let css = disc.css.clone();
|
||||
|
||||
let max_batch = detect_max_batch_sectors(session.device_path());
|
||||
let mut ts_demuxer = None;
|
||||
let mut ps_demuxer = None;
|
||||
match content_format {
|
||||
crate::disc::ContentFormat::MpegPs => {
|
||||
ps_demuxer = Some(super::ps::PsDemuxer::new());
|
||||
}
|
||||
crate::disc::ContentFormat::BdTs => {
|
||||
let ts_pids: Vec<u16> = pids.clone();
|
||||
if !ts_pids.is_empty() {
|
||||
ts_demuxer = Some(super::ts::TsDemuxer::new(&ts_pids));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
disc,
|
||||
session,
|
||||
batch_buf: Vec::new(),
|
||||
batch_pos: 0,
|
||||
eof: false,
|
||||
Self {
|
||||
reader,
|
||||
title,
|
||||
disc: None,
|
||||
decrypt_keys,
|
||||
extents,
|
||||
current_extent: 0,
|
||||
current_offset: 0,
|
||||
content_format,
|
||||
aacs,
|
||||
css,
|
||||
unit_key_idx: 0,
|
||||
read_buf: Vec::with_capacity(max_batch as usize * 2048),
|
||||
batch_sectors: max_batch,
|
||||
max_batch_sectors: max_batch,
|
||||
ok_streak: 0,
|
||||
error_streak: 0,
|
||||
read_buf: Vec::with_capacity(batch_sectors as usize * 2048),
|
||||
buf_valid: 0,
|
||||
batch_sectors,
|
||||
errors: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the full Disc (for listing all titles, etc.)
|
||||
pub fn disc(&self) -> &Disc {
|
||||
&self.disc
|
||||
}
|
||||
|
||||
/// Read sectors from the drive into `self.read_buf`.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<(), Error> {
|
||||
self.session.read_content(lba, count, &mut self.read_buf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill the internal read buffer with the next batch of sectors,
|
||||
/// handling error recovery (halve batch, slow drive, retry, skip).
|
||||
///
|
||||
/// Returns `true` if data was read, `false` at end-of-title.
|
||||
fn fill_buffer(&mut self) -> Result<bool, Error> {
|
||||
loop {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let ext_start = self.extents[self.current_extent].start_lba;
|
||||
let ext_sectors = self.extents[self.current_extent].sector_count;
|
||||
let remaining = ext_sectors.saturating_sub(self.current_offset);
|
||||
|
||||
// Align to 3 sectors (one aligned unit)
|
||||
let sectors_to_read = remaining.min(self.batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let lba = ext_start + self.current_offset;
|
||||
let byte_count = sectors_to_read as usize * 2048;
|
||||
self.read_buf.resize(byte_count, 0);
|
||||
|
||||
match self.read_sectors(lba, sectors_to_read) {
|
||||
Ok(_) => {
|
||||
self.current_offset += sectors_to_read as u32;
|
||||
self.error_streak = 0;
|
||||
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
|
||||
// Ramp up batch size after consecutive successes
|
||||
self.ok_streak += 1;
|
||||
if self.batch_sectors < self.max_batch_sectors
|
||||
&& self.ok_streak >= RAMP_BATCH_AFTER
|
||||
{
|
||||
self.batch_sectors = (self.batch_sectors * 2).min(self.max_batch_sectors);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
|
||||
// Restore max speed after sustained success at full batch
|
||||
if self.batch_sectors == self.max_batch_sectors
|
||||
&& self.ok_streak >= RAMP_SPEED_AFTER
|
||||
{
|
||||
self.session.set_speed(0xFFFF);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
Err(_) => {
|
||||
self.errors += 1;
|
||||
self.error_streak += 1;
|
||||
self.ok_streak = 0;
|
||||
|
||||
// First error: re-init (drive may have re-locked)
|
||||
if self.error_streak == 1 {
|
||||
let _ = self.session.init();
|
||||
let _ = self.session.probe_disc();
|
||||
}
|
||||
|
||||
// Repeated errors: slow down
|
||||
if self.error_streak >= SLOW_SPEED_AFTER {
|
||||
self.session.set_speed(DriveSpeed::BD2x.to_kbps());
|
||||
self.error_streak = 0;
|
||||
}
|
||||
|
||||
if self.batch_sectors > MIN_BATCH_SECTORS {
|
||||
self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
} else {
|
||||
// At minimum batch -- retry once with longer pause
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
|
||||
if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() {
|
||||
self.error_streak = 0;
|
||||
self.current_offset += MIN_BATCH_SECTORS as u32;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// Still failing -- skip this unit (zero-fill)
|
||||
self.current_offset += 3;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
self.read_buf.resize(crate::aacs::ALIGNED_UNIT_LEN, 0);
|
||||
self.read_buf.fill(0);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
eof: false,
|
||||
ts_demuxer,
|
||||
ps_demuxer,
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt the contents of `self.read_buf` in-place and copy the
|
||||
/// decrypted data into `self.batch_buf`.
|
||||
fn decrypt_and_buffer(&mut self) {
|
||||
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
|
||||
let total_bytes = self.read_buf.len();
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub fn set_raw(&mut self) {
|
||||
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
|
||||
}
|
||||
|
||||
if let Some(ref aacs) = self.aacs {
|
||||
let uk = aacs
|
||||
.unit_keys
|
||||
.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.unwrap_or([0u8; 16]);
|
||||
let rdk = aacs.read_data_key.as_ref();
|
||||
/// Get the scanned Disc (for listing all titles).
|
||||
pub fn disc(&self) -> Option<&Disc> {
|
||||
self.disc.as_ref()
|
||||
}
|
||||
|
||||
let num_units = total_bytes / unit_len;
|
||||
for i in 0..num_units {
|
||||
let start = i * unit_len;
|
||||
let end = start + unit_len;
|
||||
let unit = &mut self.read_buf[start..end];
|
||||
if crate::aacs::is_unit_encrypted(unit) {
|
||||
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
|
||||
}
|
||||
}
|
||||
} else if let Some(ref css) = self.css {
|
||||
for chunk in self.read_buf[..total_bytes].chunks_mut(2048) {
|
||||
crate::css::lfsr::descramble_sector(&css.title_key, chunk);
|
||||
}
|
||||
fn fill_extents(&mut self) -> io::Result<bool> {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
// No encryption: read_buf is already plaintext
|
||||
let ext_start = self.extents[self.current_extent].start_lba;
|
||||
let ext_sectors = self.extents[self.current_extent].sector_count;
|
||||
|
||||
// Swap buffers instead of copying — the old batch_buf becomes
|
||||
// read_buf and will be overwritten on the next read.
|
||||
std::mem::swap(&mut self.batch_buf, &mut self.read_buf);
|
||||
self.batch_pos = 0;
|
||||
let remaining = ext_sectors.saturating_sub(self.current_offset);
|
||||
let sectors = remaining.min(self.batch_sectors as u32) as u16;
|
||||
let sectors = sectors - (sectors % 3);
|
||||
if sectors == 0 {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
return self.fill_extents();
|
||||
}
|
||||
|
||||
let lba = ext_start + self.current_offset;
|
||||
let bytes = sectors as usize * 2048;
|
||||
self.read_buf.resize(bytes, 0);
|
||||
|
||||
self.reader
|
||||
.read_sectors(lba, sectors, &mut self.read_buf[..bytes])?;
|
||||
self.buf_valid = bytes;
|
||||
self.current_offset += sectors as u32;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for DiscStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn total_bytes(&self) -> Option<u64> {
|
||||
Some(self.disc_title.size_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for DiscStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
// Drain buffer first
|
||||
if self.batch_pos < self.batch_buf.len() {
|
||||
let n = (self.batch_buf.len() - self.batch_pos).min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.batch_buf[self.batch_pos..self.batch_pos + n]);
|
||||
self.batch_pos += n;
|
||||
return Ok(n);
|
||||
impl crate::pes::Stream for DiscStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
if self.eof {
|
||||
return Ok(0);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Fill the read buffer with the next batch of sectors
|
||||
let has_data = self
|
||||
.fill_buffer()
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
loop {
|
||||
if !self.fill_extents()? {
|
||||
self.eof = true;
|
||||
// Flush demuxer — last PES packet may still be in the assembler
|
||||
if let Some(ref mut demuxer) = self.ts_demuxer {
|
||||
for pes in &demuxer.flush() {
|
||||
if let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// PS demuxer flush (DVD)
|
||||
if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||
for ps in &demuxer.flush() {
|
||||
let track = match ps.stream_id {
|
||||
0xE0..=0xEF => 0,
|
||||
0xC0..=0xDF => 1,
|
||||
0xBD => ps
|
||||
.sub_stream_id
|
||||
.map(|s| (s & 0x1F) as usize + 1)
|
||||
.unwrap_or(1),
|
||||
_ => continue,
|
||||
};
|
||||
if track >= self.title.streams.len() {
|
||||
continue;
|
||||
}
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(p, _)| *p)
|
||||
.unwrap_or(0);
|
||||
let pes = super::ts::PesPacket {
|
||||
pid,
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data.clone(),
|
||||
};
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
|
||||
{
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(track, frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(self.pending_frames.pop_front());
|
||||
}
|
||||
|
||||
if !has_data {
|
||||
self.eof = true;
|
||||
return Ok(0);
|
||||
let bytes = self.buf_valid;
|
||||
if let Err(e) =
|
||||
crate::decrypt::decrypt_sectors(&mut self.read_buf[..bytes], &self.decrypt_keys, 0)
|
||||
{
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
if let Some(ref mut demuxer) = self.ts_demuxer {
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
for ps in &packets {
|
||||
let track = match ps.stream_id {
|
||||
0xE0..=0xEF => 0,
|
||||
0xC0..=0xDF => 1,
|
||||
0xBD => ps
|
||||
.sub_stream_id
|
||||
.map(|s| (s & 0x1F) as usize + 1)
|
||||
.unwrap_or(1),
|
||||
_ => continue,
|
||||
};
|
||||
if track >= self.title.streams.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert PsPacket to PesPacket for codec parser (same as BD-TS path)
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(p, _)| *p)
|
||||
.unwrap_or(0);
|
||||
|
||||
let pes = super::ts::PesPacket {
|
||||
pid,
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data.clone(),
|
||||
};
|
||||
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames
|
||||
.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.buf_valid = 0;
|
||||
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt in-place and move to batch_buf
|
||||
self.decrypt_and_buffer();
|
||||
|
||||
// Now drain into the caller's buffer
|
||||
let n = self.batch_buf.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.batch_buf[..n]);
|
||||
self.batch_pos = n;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for DiscStream {
|
||||
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"disc is read-only",
|
||||
))
|
||||
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
Err(crate::error::Error::StreamReadOnly.into())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::disc::Extent;
|
||||
|
||||
/// Build a minimal DiscStream with fake extents for testing state advancement.
|
||||
/// We cannot call `DiscStream::open()` without a real drive, so we construct
|
||||
/// one manually and then call the internal `fill_buffer` / `read` path
|
||||
/// through a helper that simulates the session reads.
|
||||
///
|
||||
/// Instead we test the state-machine logic directly: given a set of extents
|
||||
/// and a current_extent/current_offset, verify that repeated reads advance
|
||||
/// through the extents correctly.
|
||||
#[test]
|
||||
fn state_advances_across_extents() {
|
||||
// Simulate two extents of 6 sectors each (2 aligned units each).
|
||||
let extents = [
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 6,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 200,
|
||||
sector_count: 6,
|
||||
},
|
||||
];
|
||||
|
||||
// Walk through the extents manually using the same arithmetic
|
||||
// that fill_buffer uses, and verify we visit every sector.
|
||||
let batch_sectors: u16 = 6;
|
||||
let mut current_extent: usize = 0;
|
||||
let mut current_offset: u32 = 0;
|
||||
let mut lbas_read = Vec::new();
|
||||
|
||||
while current_extent < extents.len() {
|
||||
let ext_start = extents[current_extent].start_lba;
|
||||
let ext_sectors = extents[current_extent].sector_count;
|
||||
let remaining = ext_sectors.saturating_sub(current_offset);
|
||||
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
let lba = ext_start + current_offset;
|
||||
lbas_read.push((lba, sectors_to_read));
|
||||
current_offset += sectors_to_read as u32;
|
||||
if current_offset >= ext_sectors {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(lbas_read.len(), 2, "should read two batches");
|
||||
assert_eq!(lbas_read[0], (100, 6), "first batch starts at LBA 100");
|
||||
assert_eq!(lbas_read[1], (200, 6), "second batch starts at LBA 200");
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// Verify that small extents that are not aligned to 3 sectors are skipped
|
||||
/// (moved past) rather than causing an infinite loop.
|
||||
#[test]
|
||||
fn unaligned_extent_is_skipped() {
|
||||
let extents = [
|
||||
Extent {
|
||||
start_lba: 50,
|
||||
sector_count: 2, // < 3, cannot form an aligned unit
|
||||
},
|
||||
Extent {
|
||||
start_lba: 300,
|
||||
sector_count: 9,
|
||||
},
|
||||
];
|
||||
|
||||
let batch_sectors: u16 = 9;
|
||||
let mut current_extent: usize = 0;
|
||||
let mut current_offset: u32 = 0;
|
||||
let mut lbas_read = Vec::new();
|
||||
|
||||
while current_extent < extents.len() {
|
||||
let ext_start = extents[current_extent].start_lba;
|
||||
let ext_sectors = extents[current_extent].sector_count;
|
||||
let remaining = ext_sectors.saturating_sub(current_offset);
|
||||
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
let lba = ext_start + current_offset;
|
||||
lbas_read.push((lba, sectors_to_read));
|
||||
current_offset += sectors_to_read as u32;
|
||||
if current_offset >= ext_sectors {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(lbas_read.len(), 1, "only second extent is readable");
|
||||
assert_eq!(lbas_read[0], (300, 9));
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
/// Verify that multiple reads from the same extent produce advancing offsets.
|
||||
#[test]
|
||||
fn multiple_batches_within_one_extent() {
|
||||
let extents = [Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 18, // 6 aligned units = 3 batches of 6 sectors
|
||||
}];
|
||||
|
||||
let batch_sectors: u16 = 6;
|
||||
let mut current_extent: usize = 0;
|
||||
let mut current_offset: u32 = 0;
|
||||
let mut lbas_read = Vec::new();
|
||||
|
||||
while current_extent < extents.len() {
|
||||
let ext_start = extents[current_extent].start_lba;
|
||||
let ext_sectors = extents[current_extent].sector_count;
|
||||
let remaining = ext_sectors.saturating_sub(current_offset);
|
||||
let sectors_to_read = remaining.min(batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
let lba = ext_start + current_offset;
|
||||
lbas_read.push((lba, sectors_to_read));
|
||||
current_offset += sectors_to_read as u32;
|
||||
if current_offset >= ext_sectors {
|
||||
current_extent += 1;
|
||||
current_offset = 0;
|
||||
fn headers_ready(&self) -> bool {
|
||||
for (idx, s) in self.title.streams.iter().enumerate() {
|
||||
if let crate::disc::Stream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(lbas_read.len(), 3, "three batches from one extent");
|
||||
assert_eq!(lbas_read[0], (1000, 6));
|
||||
assert_eq!(lbas_read[1], (1006, 6));
|
||||
assert_eq!(lbas_read[2], (1012, 6));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
+2
-8
@@ -171,10 +171,7 @@ pub fn read_id(r: &mut impl Read) -> io::Result<(u32, usize)> {
|
||||
4,
|
||||
))
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid EBML ID",
|
||||
))
|
||||
Err(crate::error::Error::MkvInvalid.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,10 +322,7 @@ pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
r.read_exact(&mut b)?;
|
||||
return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"unsupported VINT width",
|
||||
))
|
||||
Err(crate::error::Error::MkvInvalid.into())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
+14
-273
@@ -1,26 +1,16 @@
|
||||
//! IsoStream — read/write Blu-ray ISO disc images.
|
||||
//! ISO sector reader — file-backed SectorReader for Blu-ray ISO images.
|
||||
//!
|
||||
//! Read: parses UDF filesystem inside the ISO using the same pipeline as
|
||||
//! DiscStream (titles, streams, labels, AACS). An ISO is a flat image of
|
||||
//! 2048-byte sectors — sector N starts at byte offset N * 2048.
|
||||
//!
|
||||
//! Write: creates a UDF 2.50 filesystem containing the m2ts stream data.
|
||||
//! The resulting ISO can be mounted or read back via IsoStream.
|
||||
//! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048.
|
||||
//! Used by DiscStream::open_iso() and Disc::scan_image().
|
||||
|
||||
use super::isowriter::IsoWriter;
|
||||
use super::IOStream;
|
||||
use crate::disc::{Disc, DiscTitle, ScanOptions};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
const SECTOR_SIZE: u64 = 2048;
|
||||
|
||||
/// Maximum sectors to batch-read at once (64 sectors = 128 KB).
|
||||
const BATCH_SECTORS: usize = 64;
|
||||
|
||||
/// File-backed sector reader for ISO images.
|
||||
pub struct IsoSectorReader {
|
||||
file: File,
|
||||
@@ -28,11 +18,18 @@ pub struct IsoSectorReader {
|
||||
}
|
||||
|
||||
impl IsoSectorReader {
|
||||
pub fn open(path: &str) -> io::Result<Self> {
|
||||
pub fn open(path: &str) -> std::io::Result<Self> {
|
||||
let file = File::open(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
|
||||
.map_err(|e| std::io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
|
||||
let size = file.metadata()?.len();
|
||||
let capacity = (size / SECTOR_SIZE) as u32;
|
||||
let sectors = size / SECTOR_SIZE;
|
||||
if sectors > u32::MAX as u64 {
|
||||
return Err(crate::error::Error::IsoTooLarge {
|
||||
path: path.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let capacity = sectors as u32;
|
||||
Ok(Self { file, capacity })
|
||||
}
|
||||
|
||||
@@ -54,225 +51,6 @@ impl SectorReader for IsoSectorReader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blu-ray ISO image stream.
|
||||
///
|
||||
/// Read: opens ISO, parses UDF (same as DiscStream), streams BD-TS content.
|
||||
/// Write: creates UDF 2.50 ISO with BDMV/STREAM/*.m2ts.
|
||||
pub struct IsoStream {
|
||||
disc_title: DiscTitle,
|
||||
disc: Option<Disc>,
|
||||
// Read side
|
||||
reader: Option<IsoSectorReader>,
|
||||
extents: Vec<(u32, u32)>,
|
||||
extent_idx: usize,
|
||||
sectors_remaining: u32,
|
||||
/// Batch buffer: holds up to BATCH_SECTORS sectors (128 KB) at once.
|
||||
batch_buf: Vec<u8>,
|
||||
buf_pos: usize,
|
||||
buf_len: usize,
|
||||
eof: bool,
|
||||
// Write side
|
||||
iso_writer: Option<IsoWriter<io::BufWriter<File>>>,
|
||||
write_started: bool,
|
||||
}
|
||||
|
||||
impl IsoStream {
|
||||
/// Open an ISO file for reading. Parses UDF, scans titles, streams, labels.
|
||||
pub fn open(path: &str, title_index: Option<usize>, opts: &ScanOptions) -> io::Result<Self> {
|
||||
let mut reader = IsoSectorReader::open(path)?;
|
||||
let capacity = reader.capacity();
|
||||
|
||||
let disc = Disc::scan_image(&mut reader, capacity, opts)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
|
||||
let idx = title_index
|
||||
.unwrap_or(0)
|
||||
.min(disc.titles.len().saturating_sub(1));
|
||||
let disc_title = if disc.titles.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"no titles found in ISO image",
|
||||
));
|
||||
} else {
|
||||
disc.titles[idx].clone()
|
||||
};
|
||||
|
||||
let extents: Vec<(u32, u32)> = disc_title
|
||||
.extents
|
||||
.iter()
|
||||
.map(|e| (e.start_lba, e.sector_count))
|
||||
.collect();
|
||||
let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0);
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title,
|
||||
disc: Some(disc),
|
||||
reader: Some(reader),
|
||||
extents,
|
||||
extent_idx: 0,
|
||||
sectors_remaining,
|
||||
batch_buf: vec![0u8; BATCH_SECTORS * SECTOR_SIZE as usize],
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
iso_writer: None,
|
||||
write_started: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an ISO file for writing.
|
||||
pub fn create(path: &str) -> io::Result<Self> {
|
||||
let file = File::create(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
|
||||
let buf_writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
let iso_writer = IsoWriter::new(buf_writer, "FREEMKV", "00001.m2ts");
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title: DiscTitle::empty(),
|
||||
disc: None,
|
||||
reader: None,
|
||||
extents: Vec::new(),
|
||||
extent_idx: 0,
|
||||
sectors_remaining: 0,
|
||||
batch_buf: Vec::new(),
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
iso_writer: Some(iso_writer),
|
||||
write_started: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set metadata (for write mode). Must be called before writing data.
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
// Update the ISO writer's volume ID and m2ts filename from title metadata
|
||||
if let Some(writer) = self.iso_writer.take() {
|
||||
let vol_id = if dt.playlist.is_empty() {
|
||||
"FREEMKV".to_string()
|
||||
} else {
|
||||
dt.playlist
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == ' ')
|
||||
.collect::<String>()
|
||||
};
|
||||
let m2ts_name = format!("{:05}.m2ts", dt.playlist_id.max(1));
|
||||
self.iso_writer = Some(writer.with_names(&vol_id, &m2ts_name));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the full Disc (for listing all titles).
|
||||
pub fn disc(&self) -> Option<&Disc> {
|
||||
self.disc.as_ref()
|
||||
}
|
||||
|
||||
/// Read up to BATCH_SECTORS sectors at once into the batch buffer.
|
||||
fn read_next_batch(&mut self) -> io::Result<bool> {
|
||||
let reader = match self.reader.as_mut() {
|
||||
Some(r) => r,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
if self.extent_idx >= self.extents.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let (start_lba, total) = self.extents[self.extent_idx];
|
||||
let offset = total - self.sectors_remaining;
|
||||
let lba = start_lba + offset;
|
||||
|
||||
// Read up to BATCH_SECTORS, but no more than remaining in this extent
|
||||
let count = (self.sectors_remaining as usize).min(BATCH_SECTORS) as u16;
|
||||
|
||||
reader
|
||||
.read_sectors(lba, count, &mut self.batch_buf)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
self.buf_pos = 0;
|
||||
self.buf_len = count as usize * SECTOR_SIZE as usize;
|
||||
|
||||
self.sectors_remaining -= count as u32;
|
||||
if self.sectors_remaining == 0 {
|
||||
self.extent_idx += 1;
|
||||
if self.extent_idx < self.extents.len() {
|
||||
self.sectors_remaining = self.extents[self.extent_idx].1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for IsoStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(ref mut w) = self.iso_writer {
|
||||
w.finish()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn total_bytes(&self) -> Option<u64> {
|
||||
// Read mode: size is known from disc scan
|
||||
if self.reader.is_some() {
|
||||
Some(self.disc_title.size_bytes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for IsoStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if self.eof {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if self.buf_pos < self.buf_len {
|
||||
let n = (self.buf_len - self.buf_pos).min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.batch_buf[self.buf_pos..self.buf_pos + n]);
|
||||
self.buf_pos += n;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
if self.read_next_batch()? {
|
||||
let n = self.buf_len.min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.batch_buf[..n]);
|
||||
self.buf_pos = n;
|
||||
Ok(n)
|
||||
} else {
|
||||
self.eof = true;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for IsoStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let w = match self.iso_writer.as_mut() {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"iso:// opened for reading — cannot write",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !self.write_started {
|
||||
w.start()?;
|
||||
self.write_started = true;
|
||||
}
|
||||
|
||||
w.write_data(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -315,41 +93,4 @@ mod tests {
|
||||
|
||||
std::fs::remove_file(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_write_creates_valid_udf() {
|
||||
let path = std::env::temp_dir().join("freemkv_test_iso_write.iso");
|
||||
let mut stream = IsoStream::create(path.to_str().unwrap()).unwrap();
|
||||
|
||||
// Write some fake BD-TS content
|
||||
let mut content = Vec::new();
|
||||
for i in 0..100u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = i;
|
||||
content.extend_from_slice(&pkt);
|
||||
}
|
||||
|
||||
stream.write_all(&content).unwrap();
|
||||
stream.finish().unwrap();
|
||||
|
||||
// Verify the ISO has valid UDF structure
|
||||
let file = File::open(&path).unwrap();
|
||||
let size = file.metadata().unwrap().len();
|
||||
assert!(size > 288 * SECTOR_SIZE); // at least header + some data
|
||||
|
||||
// Read back and verify AVDP at sector 256
|
||||
let mut reader = IsoSectorReader::open(path.to_str().unwrap()).unwrap();
|
||||
let mut avdp = [0u8; 2048];
|
||||
reader.read_sectors(256, 1, &mut avdp).unwrap();
|
||||
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
||||
assert_eq!(tag_id, 2, "AVDP tag should be 2");
|
||||
|
||||
// Verify VRS at sector 16
|
||||
let mut vrs = [0u8; 2048];
|
||||
reader.read_sectors(16, 1, &mut vrs).unwrap();
|
||||
assert_eq!(&vrs[1..6], b"BEA01");
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
//! UDF ISO writer — creates Blu-ray disc images.
|
||||
//!
|
||||
//! Writes a minimal UDF 2.50 filesystem containing BDMV/STREAM/*.m2ts.
|
||||
//! The ISO can be mounted or read back via IsoStream.
|
||||
//!
|
||||
//! Layout:
|
||||
//! Sector 0-15: System area (zeros)
|
||||
//! Sector 16-18: Volume Recognition Sequence (BEA01, NSR03, TEA01)
|
||||
//! Sector 32-37: Volume Descriptor Sequence
|
||||
//! Sector 256: Anchor Volume Descriptor Pointer
|
||||
//! Sector 260-271: Metadata partition (FSD, ICBs, directories)
|
||||
//! Sector 288+: File data (m2ts content)
|
||||
//! Last-256: Reserve AVDP
|
||||
|
||||
use std::io::{self, Seek, SeekFrom, Write};
|
||||
|
||||
const SECTOR_SIZE: u64 = 2048;
|
||||
|
||||
// Layout constants
|
||||
const VRS_START: u32 = 16; // Volume Recognition Sequence
|
||||
const VDS_START: u32 = 32; // Volume Descriptor Sequence
|
||||
const AVDP_SECTOR: u32 = 256; // Anchor Volume Descriptor Pointer
|
||||
const PARTITION_START: u32 = 257; // Physical partition start
|
||||
const METADATA_START: u32 = 260; // Metadata partition content
|
||||
const FSD_SECTOR: u32 = 260; // File Set Descriptor
|
||||
const ROOT_ICB_SECTOR: u32 = 261; // Root directory ICB
|
||||
const ROOT_DIR_SECTOR: u32 = 262; // Root directory data
|
||||
const BDMV_ICB_SECTOR: u32 = 263; // BDMV/ ICB
|
||||
const BDMV_DIR_SECTOR: u32 = 264; // BDMV/ directory data
|
||||
const STREAM_ICB_SECTOR: u32 = 265; // BDMV/STREAM/ ICB
|
||||
const STREAM_DIR_SECTOR: u32 = 266; // BDMV/STREAM/ directory data
|
||||
const M2TS_ICB_SECTOR: u32 = 267; // m2ts file ICB
|
||||
const DATA_START: u32 = 288; // Start of file data (aligned)
|
||||
|
||||
/// Write a complete BD ISO image.
|
||||
///
|
||||
/// Writes UDF structure, then streams m2ts content from the writer.
|
||||
/// Call `start()` first, then write BD-TS bytes, then call `finish()`.
|
||||
pub struct IsoWriter<W: Write + Seek> {
|
||||
writer: W,
|
||||
volume_id: String,
|
||||
m2ts_name: String,
|
||||
data_start_sector: u32,
|
||||
bytes_written: u64,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> IsoWriter<W> {
|
||||
/// Create a new ISO writer. Call `start()` to write the UDF header.
|
||||
pub fn new(writer: W, volume_id: &str, m2ts_name: &str) -> Self {
|
||||
Self {
|
||||
writer,
|
||||
volume_id: volume_id.to_string(),
|
||||
m2ts_name: m2ts_name.to_string(),
|
||||
data_start_sector: DATA_START,
|
||||
bytes_written: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update volume ID and m2ts filename. Must be called before `start()`.
|
||||
pub fn with_names(mut self, volume_id: &str, m2ts_name: &str) -> Self {
|
||||
self.volume_id = volume_id.to_string();
|
||||
self.m2ts_name = m2ts_name.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Write UDF filesystem header. After this, write m2ts content bytes.
|
||||
pub fn start(&mut self) -> io::Result<()> {
|
||||
// System area: sectors 0-15 (zeros)
|
||||
let zero_sector = [0u8; SECTOR_SIZE as usize];
|
||||
for _ in 0..VRS_START {
|
||||
self.writer.write_all(&zero_sector)?;
|
||||
}
|
||||
|
||||
// Volume Recognition Sequence
|
||||
self.write_vrs()?;
|
||||
|
||||
// Pad sectors 19-31
|
||||
for _ in 19..VDS_START {
|
||||
self.writer.write_all(&zero_sector)?;
|
||||
}
|
||||
|
||||
// Volume Descriptor Sequence (sectors 32-37)
|
||||
self.write_vds()?;
|
||||
|
||||
// Pad sectors 38-255
|
||||
for _ in 38..AVDP_SECTOR {
|
||||
self.writer.write_all(&zero_sector)?;
|
||||
}
|
||||
|
||||
// AVDP at sector 256
|
||||
self.write_avdp()?;
|
||||
|
||||
// Partition area: metadata file ICB at partition_start
|
||||
self.write_metadata_file_icb()?;
|
||||
|
||||
// Pad to metadata start
|
||||
for _ in (PARTITION_START + 1)..METADATA_START {
|
||||
self.writer.write_all(&zero_sector)?;
|
||||
}
|
||||
|
||||
// Metadata partition
|
||||
self.write_fsd()?;
|
||||
self.write_root_icb()?;
|
||||
self.write_root_dir()?;
|
||||
self.write_bdmv_icb()?;
|
||||
self.write_bdmv_dir()?;
|
||||
self.write_stream_icb()?;
|
||||
self.write_stream_dir()?;
|
||||
self.write_m2ts_icb(0)?; // placeholder size, updated in finish()
|
||||
|
||||
// Pad to data start
|
||||
for _ in (M2TS_ICB_SECTOR + 1)..self.data_start_sector {
|
||||
self.writer.write_all(&zero_sector)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write m2ts content bytes. Call after `start()`.
|
||||
pub fn write_data(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let n = self.writer.write(buf)?;
|
||||
self.bytes_written += n as u64;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Finalize the ISO: pad to sector boundary, update file sizes, write reserve AVDP.
|
||||
pub fn finish(&mut self) -> io::Result<()> {
|
||||
// Pad to sector boundary
|
||||
let remainder = (self.bytes_written % SECTOR_SIZE) as usize;
|
||||
if remainder > 0 {
|
||||
let pad = SECTOR_SIZE as usize - remainder;
|
||||
let zeros = vec![0u8; pad];
|
||||
self.writer.write_all(&zeros)?;
|
||||
self.bytes_written += pad as u64;
|
||||
}
|
||||
|
||||
let total_data_sectors = (self.bytes_written / SECTOR_SIZE) as u32;
|
||||
let total_sectors = self.data_start_sector + total_data_sectors;
|
||||
|
||||
// Seek back and update m2ts file ICB with actual size
|
||||
self.writer
|
||||
.seek(SeekFrom::Start(M2TS_ICB_SECTOR as u64 * SECTOR_SIZE))?;
|
||||
self.write_m2ts_icb(self.bytes_written)?;
|
||||
|
||||
// Seek to end and write reserve AVDP
|
||||
let reserve_sector = if total_sectors > 512 {
|
||||
total_sectors - 256
|
||||
} else {
|
||||
total_sectors.saturating_sub(1).max(AVDP_SECTOR + 1)
|
||||
};
|
||||
self.writer
|
||||
.seek(SeekFrom::Start(reserve_sector as u64 * SECTOR_SIZE))?;
|
||||
self.write_avdp()?;
|
||||
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── UDF structure writers ──────────────────────────────────────────────
|
||||
|
||||
fn write_vrs(&mut self) -> io::Result<()> {
|
||||
// BEA01 at sector 16
|
||||
let mut bea = [0u8; SECTOR_SIZE as usize];
|
||||
bea[0] = 0; // structure type
|
||||
bea[1..6].copy_from_slice(b"BEA01");
|
||||
bea[6] = 1; // structure version
|
||||
self.writer.write_all(&bea)?;
|
||||
|
||||
// NSR03 at sector 17 (UDF 2.50)
|
||||
let mut nsr = [0u8; SECTOR_SIZE as usize];
|
||||
nsr[0] = 0;
|
||||
nsr[1..6].copy_from_slice(b"NSR03");
|
||||
nsr[6] = 1;
|
||||
self.writer.write_all(&nsr)?;
|
||||
|
||||
// TEA01 at sector 18
|
||||
let mut tea = [0u8; SECTOR_SIZE as usize];
|
||||
tea[0] = 0;
|
||||
tea[1..6].copy_from_slice(b"TEA01");
|
||||
tea[6] = 1;
|
||||
self.writer.write_all(&tea)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_vds(&mut self) -> io::Result<()> {
|
||||
// Primary Volume Descriptor (tag 1) at sector 32
|
||||
let mut pvd = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut pvd, 1, VDS_START);
|
||||
// Volume Identifier at offset 24 (32-byte d-string)
|
||||
write_dstring(&mut pvd[24..56], &self.volume_id);
|
||||
self.writer.write_all(&pvd)?;
|
||||
|
||||
// Partition Descriptor (tag 5) at sector 33
|
||||
let mut pd = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut pd, 5, VDS_START + 1);
|
||||
// Partition starting location at offset 188
|
||||
pd[188..192].copy_from_slice(&PARTITION_START.to_le_bytes());
|
||||
// Partition length (large enough for everything)
|
||||
let part_len: u32 = 0xFFFF_FFFF;
|
||||
pd[192..196].copy_from_slice(&part_len.to_le_bytes());
|
||||
self.writer.write_all(&pd)?;
|
||||
|
||||
// Logical Volume Descriptor (tag 6) at sector 34
|
||||
let mut lvd = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut lvd, 6, VDS_START + 2);
|
||||
// Logical block size at offset 212
|
||||
lvd[212..216].copy_from_slice(&2048u32.to_le_bytes());
|
||||
// Number of partition maps at offset 268
|
||||
lvd[268..272].copy_from_slice(&2u32.to_le_bytes());
|
||||
// Partition map 1: Type 1 (physical), 6 bytes
|
||||
lvd[440] = 1; // type
|
||||
lvd[441] = 6; // length
|
||||
// Partition map 2: Type 2 (metadata), 64 bytes
|
||||
lvd[446] = 2; // type
|
||||
lvd[447] = 64; // length
|
||||
// Entity ID for metadata partition
|
||||
lvd[450..473].copy_from_slice(b"*UDF Metadata Partition");
|
||||
self.writer.write_all(&lvd)?;
|
||||
|
||||
// Unallocated Space Descriptor (tag 7) at sector 35
|
||||
let mut usd = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut usd, 7, VDS_START + 3);
|
||||
self.writer.write_all(&usd)?;
|
||||
|
||||
// Implementation Use Volume Descriptor (tag 4) at sector 36
|
||||
let mut iuvd = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut iuvd, 4, VDS_START + 4);
|
||||
self.writer.write_all(&iuvd)?;
|
||||
|
||||
// Terminating Descriptor (tag 8) at sector 37
|
||||
let mut td = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut td, 8, VDS_START + 5);
|
||||
self.writer.write_all(&td)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_avdp(&mut self) -> io::Result<()> {
|
||||
let mut avdp = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut avdp, 2, AVDP_SECTOR);
|
||||
// Main VDS extent_ad: {length, location} per UDF spec
|
||||
avdp[16..20].copy_from_slice(&(6u32 * 2048).to_le_bytes()); // length
|
||||
avdp[20..24].copy_from_slice(&VDS_START.to_le_bytes()); // location
|
||||
// Reserve VDS extent_ad (same as main for simplicity)
|
||||
avdp[24..28].copy_from_slice(&(6u32 * 2048).to_le_bytes()); // length
|
||||
avdp[28..32].copy_from_slice(&VDS_START.to_le_bytes()); // location
|
||||
self.writer.write_all(&avdp)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_metadata_file_icb(&mut self) -> io::Result<()> {
|
||||
// Extended File Entry (tag 266) at partition_start
|
||||
// Points to metadata content at METADATA_START
|
||||
let mut icb = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut icb, 266, PARTITION_START);
|
||||
// ICB tag at offset 16
|
||||
icb[16..20].copy_from_slice(&0u32.to_le_bytes()); // prior recorded
|
||||
icb[20..22].copy_from_slice(&0u16.to_le_bytes()); // strategy type
|
||||
icb[22..24].copy_from_slice(&0u16.to_le_bytes()); // strategy parameter
|
||||
// File type at offset 27: 250 = metadata file
|
||||
icb[27] = 250;
|
||||
// Information length at offset 56
|
||||
let meta_len: u64 = 12 * SECTOR_SIZE; // 12 sectors of metadata
|
||||
icb[56..64].copy_from_slice(&meta_len.to_le_bytes());
|
||||
// Extended attribute length at offset 208
|
||||
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
// Allocation descriptor at offset 216: short_ad (length + position)
|
||||
let ad_len = meta_len as u32;
|
||||
let ad_pos = METADATA_START - PARTITION_START; // relative to partition
|
||||
icb[216..220].copy_from_slice(&ad_len.to_le_bytes());
|
||||
icb[220..224].copy_from_slice(&ad_pos.to_le_bytes());
|
||||
self.writer.write_all(&icb)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_fsd(&mut self) -> io::Result<()> {
|
||||
let mut fsd = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut fsd, 256, FSD_SECTOR);
|
||||
// Root Directory ICB (long_ad at offset 400)
|
||||
let root_lba = ROOT_ICB_SECTOR - METADATA_START; // metadata-relative
|
||||
fsd[400..404].copy_from_slice(&SECTOR_SIZE.to_le_bytes()[..4]); // extent length
|
||||
fsd[404..408].copy_from_slice(&root_lba.to_le_bytes());
|
||||
self.writer.write_all(&fsd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_root_icb(&mut self) -> io::Result<()> {
|
||||
let mut icb = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut icb, 266, ROOT_ICB_SECTOR);
|
||||
icb[27] = 4; // file type: directory
|
||||
let dir_len: u64 = SECTOR_SIZE;
|
||||
icb[56..64].copy_from_slice(&dir_len.to_le_bytes());
|
||||
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
let ad_pos = ROOT_DIR_SECTOR - METADATA_START;
|
||||
icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||
icb[220..224].copy_from_slice(&ad_pos.to_le_bytes());
|
||||
self.writer.write_all(&icb)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_root_dir(&mut self) -> io::Result<()> {
|
||||
let mut dir = [0u8; SECTOR_SIZE as usize];
|
||||
let mut offset = 0;
|
||||
// Parent entry (.. points to self)
|
||||
offset += write_fid(
|
||||
&mut dir[offset..],
|
||||
ROOT_ICB_SECTOR - METADATA_START,
|
||||
"",
|
||||
true,
|
||||
);
|
||||
// BDMV directory entry
|
||||
offset += write_fid(
|
||||
&mut dir[offset..],
|
||||
BDMV_ICB_SECTOR - METADATA_START,
|
||||
"BDMV",
|
||||
false,
|
||||
);
|
||||
let _ = offset;
|
||||
self.writer.write_all(&dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_bdmv_icb(&mut self) -> io::Result<()> {
|
||||
let mut icb = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut icb, 266, BDMV_ICB_SECTOR);
|
||||
icb[27] = 4; // directory
|
||||
let dir_len: u64 = SECTOR_SIZE;
|
||||
icb[56..64].copy_from_slice(&dir_len.to_le_bytes());
|
||||
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
let ad_pos = BDMV_DIR_SECTOR - METADATA_START;
|
||||
icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||
icb[220..224].copy_from_slice(&ad_pos.to_le_bytes());
|
||||
self.writer.write_all(&icb)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_bdmv_dir(&mut self) -> io::Result<()> {
|
||||
let mut dir = [0u8; SECTOR_SIZE as usize];
|
||||
let mut offset = 0;
|
||||
offset += write_fid(
|
||||
&mut dir[offset..],
|
||||
ROOT_ICB_SECTOR - METADATA_START,
|
||||
"",
|
||||
true,
|
||||
);
|
||||
offset += write_fid(
|
||||
&mut dir[offset..],
|
||||
STREAM_ICB_SECTOR - METADATA_START,
|
||||
"STREAM",
|
||||
false,
|
||||
);
|
||||
let _ = offset;
|
||||
self.writer.write_all(&dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_stream_icb(&mut self) -> io::Result<()> {
|
||||
let mut icb = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut icb, 266, STREAM_ICB_SECTOR);
|
||||
icb[27] = 4; // directory
|
||||
let dir_len: u64 = SECTOR_SIZE;
|
||||
icb[56..64].copy_from_slice(&dir_len.to_le_bytes());
|
||||
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
let ad_pos = STREAM_DIR_SECTOR - METADATA_START;
|
||||
icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||
icb[220..224].copy_from_slice(&ad_pos.to_le_bytes());
|
||||
self.writer.write_all(&icb)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_stream_dir(&mut self) -> io::Result<()> {
|
||||
let mut dir = [0u8; SECTOR_SIZE as usize];
|
||||
let mut offset = 0;
|
||||
offset += write_fid(
|
||||
&mut dir[offset..],
|
||||
BDMV_ICB_SECTOR - METADATA_START,
|
||||
"",
|
||||
true,
|
||||
);
|
||||
offset += write_fid(
|
||||
&mut dir[offset..],
|
||||
M2TS_ICB_SECTOR - METADATA_START,
|
||||
&self.m2ts_name,
|
||||
false,
|
||||
);
|
||||
let _ = offset;
|
||||
self.writer.write_all(&dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_m2ts_icb(&mut self, file_size: u64) -> io::Result<()> {
|
||||
let mut icb = [0u8; SECTOR_SIZE as usize];
|
||||
write_descriptor_tag(&mut icb, 266, M2TS_ICB_SECTOR);
|
||||
icb[27] = 5; // file type: regular file
|
||||
icb[56..64].copy_from_slice(&file_size.to_le_bytes());
|
||||
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
// Allocation: data starts at DATA_START in the physical partition.
|
||||
// UDF short_ad is 30 bits for extent length (max 1 GB = 0x3FFFFFFF).
|
||||
// For files > 1 GB, write multiple short_ad entries of 1 GB each plus remainder.
|
||||
let data_offset = self.data_start_sector - PARTITION_START;
|
||||
const MAX_EXTENT: u64 = 0x3FFF_FFFF; // 1 GB - 1 (30-bit max)
|
||||
let mut remaining = file_size;
|
||||
let mut ad_offset: usize = 216;
|
||||
let mut sector_pos = data_offset;
|
||||
while remaining > 0 && ad_offset + 8 <= SECTOR_SIZE as usize {
|
||||
let extent_len = if remaining > MAX_EXTENT {
|
||||
MAX_EXTENT
|
||||
} else {
|
||||
remaining
|
||||
};
|
||||
icb[ad_offset..ad_offset + 4].copy_from_slice(&(extent_len as u32).to_le_bytes());
|
||||
icb[ad_offset + 4..ad_offset + 8].copy_from_slice(§or_pos.to_le_bytes());
|
||||
ad_offset += 8; // each short_ad is 8 bytes
|
||||
let extent_sectors = ((extent_len + SECTOR_SIZE - 1) / SECTOR_SIZE) as u32;
|
||||
sector_pos += extent_sectors;
|
||||
remaining -= extent_len;
|
||||
}
|
||||
self.writer.write_all(&icb)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── UDF primitives ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Write a UDF Descriptor Tag at the start of a sector.
|
||||
fn write_descriptor_tag(buf: &mut [u8], tag_id: u16, sector: u32) {
|
||||
buf[0..2].copy_from_slice(&tag_id.to_le_bytes());
|
||||
// Descriptor version: 3 (UDF 2.50)
|
||||
buf[2..4].copy_from_slice(&3u16.to_le_bytes());
|
||||
// Tag location
|
||||
buf[12..16].copy_from_slice(§or.to_le_bytes());
|
||||
// Compute CRC-CCITT over descriptor body (bytes 16+)
|
||||
let body = &buf[16..];
|
||||
let body_len = body.len();
|
||||
let crc = udf_crc(body);
|
||||
buf[8..10].copy_from_slice(&crc.to_le_bytes());
|
||||
// Descriptor CRC length
|
||||
buf[10..12].copy_from_slice(&(body_len as u16).to_le_bytes());
|
||||
// Compute tag checksum: sum of bytes 0-3, 5-15 mod 256
|
||||
buf[4] = 0; // clear before computing
|
||||
let checksum: u8 = buf[0..4]
|
||||
.iter()
|
||||
.chain(buf[5..16].iter())
|
||||
.fold(0u8, |acc, &b| acc.wrapping_add(b));
|
||||
buf[4] = checksum;
|
||||
}
|
||||
|
||||
/// UDF CRC-CCITT (CRC-16/ECMA-182 polynomial 0x11021).
|
||||
fn udf_crc(data: &[u8]) -> u16 {
|
||||
// CRC lookup table for polynomial 0x11021
|
||||
static CRC_TABLE: [u16; 256] = {
|
||||
let mut table = [0u16; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut crc = (i as u16) << 8;
|
||||
let mut j = 0;
|
||||
while j < 8 {
|
||||
if crc & 0x8000 != 0 {
|
||||
crc = (crc << 1) ^ 0x1021;
|
||||
} else {
|
||||
crc <<= 1;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
table[i] = crc;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
let mut crc: u16 = 0;
|
||||
for &byte in data {
|
||||
crc = (crc << 8) ^ CRC_TABLE[((crc >> 8) as u8 ^ byte) as usize];
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Write a UDF d-string (compressed unicode string with length prefix).
|
||||
fn write_dstring(buf: &mut [u8], s: &str) {
|
||||
let max = buf.len() - 1; // last byte is length
|
||||
let bytes = s.as_bytes();
|
||||
let len = bytes.len().min(max);
|
||||
if len > 0 {
|
||||
buf[0] = 8; // compression ID: 8 = Latin-1
|
||||
buf[1..1 + len].copy_from_slice(&bytes[..len]);
|
||||
buf[buf.len() - 1] = (len + 1) as u8; // d-string length including comp ID
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a File Identifier Descriptor. Returns bytes written (4-byte aligned).
|
||||
fn write_fid(buf: &mut [u8], icb_lba: u32, name: &str, is_parent: bool) -> usize {
|
||||
// Tag 257 = File Identifier Descriptor
|
||||
let name_bytes = name.as_bytes();
|
||||
let name_len = if is_parent { 0 } else { name_bytes.len() + 1 }; // +1 for comp ID
|
||||
let fid_len = 38 + name_len; // fixed header + identifier
|
||||
let padded = (fid_len + 3) & !3; // 4-byte align
|
||||
|
||||
if padded > buf.len() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Tag
|
||||
buf[0..2].copy_from_slice(&257u16.to_le_bytes());
|
||||
// File version number at offset 16
|
||||
buf[16..18].copy_from_slice(&1u16.to_le_bytes());
|
||||
// File characteristics at offset 18
|
||||
buf[18] = if is_parent { 0x0A } else { 0x02 }; // parent | directory
|
||||
if !is_parent && !name.contains('.') {
|
||||
buf[18] = 0x02; // directory
|
||||
} else if !is_parent {
|
||||
buf[18] = 0x00; // file
|
||||
}
|
||||
// ICB (long_ad at offset 20): extent length + location
|
||||
buf[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||
buf[24..28].copy_from_slice(&icb_lba.to_le_bytes());
|
||||
// Identifier length at offset 36
|
||||
buf[36] = name_len as u8;
|
||||
// Implementation use length at offset 37
|
||||
buf[37] = 0;
|
||||
// File identifier at offset 38
|
||||
if !is_parent && !name_bytes.is_empty() {
|
||||
buf[38] = 8; // compression ID: Latin-1
|
||||
buf[39..39 + name_bytes.len()].copy_from_slice(name_bytes);
|
||||
}
|
||||
|
||||
padded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Read a little-endian u16 from a byte slice at the given offset.
|
||||
fn le_u16(data: &[u8], off: usize) -> u16 {
|
||||
u16::from_le_bytes([data[off], data[off + 1]])
|
||||
}
|
||||
|
||||
/// Read a little-endian u32 from a byte slice at the given offset.
|
||||
#[allow(dead_code)]
|
||||
fn le_u32(data: &[u8], off: usize) -> u32 {
|
||||
u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
|
||||
}
|
||||
|
||||
/// Read a little-endian u64 from a byte slice at the given offset.
|
||||
fn le_u64(data: &[u8], off: usize) -> u64 {
|
||||
u64::from_le_bytes([
|
||||
data[off],
|
||||
data[off + 1],
|
||||
data[off + 2],
|
||||
data[off + 3],
|
||||
data[off + 4],
|
||||
data[off + 5],
|
||||
data[off + 6],
|
||||
data[off + 7],
|
||||
])
|
||||
}
|
||||
|
||||
/// Get the sector at a given sector number from the output data.
|
||||
fn sector(data: &[u8], num: u32) -> &[u8] {
|
||||
let start = num as usize * SECTOR_SIZE as usize;
|
||||
&data[start..start + SECTOR_SIZE as usize]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isowriter_creates_valid_udf() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut w = IsoWriter::new(buf, "TEST_VOL", "00001.m2ts");
|
||||
w.start().unwrap();
|
||||
w.write_data(&[0xAA; 4096]).unwrap();
|
||||
w.finish().unwrap();
|
||||
let data = w.writer.into_inner();
|
||||
|
||||
// AVDP at sector 256 should have tag ID = 2
|
||||
let avdp = sector(&data, AVDP_SECTOR);
|
||||
assert_eq!(le_u16(avdp, 0), 2, "AVDP tag ID should be 2");
|
||||
|
||||
// VRS at sector 16 should contain "BEA01"
|
||||
let vrs = sector(&data, VRS_START);
|
||||
assert_eq!(&vrs[1..6], b"BEA01", "VRS sector 16 should contain BEA01");
|
||||
|
||||
// FSD at metadata sector should have tag ID = 256
|
||||
let fsd = sector(&data, FSD_SECTOR);
|
||||
assert_eq!(le_u16(fsd, 0), 256, "FSD tag ID should be 256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isowriter_updates_file_size() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut w = IsoWriter::new(buf, "SIZE_TEST", "00001.m2ts");
|
||||
w.start().unwrap();
|
||||
|
||||
let test_data = vec![0x42u8; 8192]; // exactly 4 sectors
|
||||
let written = w.write_data(&test_data).unwrap();
|
||||
assert_eq!(written, 8192);
|
||||
|
||||
w.finish().unwrap();
|
||||
let data = w.writer.into_inner();
|
||||
|
||||
// Read m2ts ICB at M2TS_ICB_SECTOR and check information length at offset 56
|
||||
let icb = sector(&data, M2TS_ICB_SECTOR);
|
||||
let file_size = le_u64(icb, 56);
|
||||
assert_eq!(
|
||||
file_size, 8192,
|
||||
"m2ts ICB file size should match bytes written (8192), got {}",
|
||||
file_size
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isowriter_with_names() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut w = IsoWriter::new(buf, "MY_DISC", "00042.m2ts");
|
||||
w.start().unwrap();
|
||||
w.write_data(&[0x00; 2048]).unwrap();
|
||||
w.finish().unwrap();
|
||||
let data = w.writer.into_inner();
|
||||
|
||||
// Check PVD (sector 32) volume_id at offset 24 as d-string
|
||||
let pvd = sector(&data, VDS_START);
|
||||
// d-string: byte 0 = compression ID (8), then ASCII chars
|
||||
assert_eq!(pvd[24], 8, "PVD volume_id compression ID should be 8");
|
||||
assert_eq!(
|
||||
&pvd[25..32],
|
||||
b"MY_DISC",
|
||||
"PVD should contain volume_id 'MY_DISC'"
|
||||
);
|
||||
|
||||
// Check STREAM directory (sector 266) for m2ts filename in FID
|
||||
let stream_dir = sector(&data, STREAM_DIR_SECTOR);
|
||||
// The FID for the m2ts file should contain the filename after the parent entry.
|
||||
// Search for "00042.m2ts" in the sector data
|
||||
let name = b"00042.m2ts";
|
||||
let found = stream_dir.windows(name.len()).any(|w| w == name);
|
||||
assert!(
|
||||
found,
|
||||
"STREAM directory should contain m2ts filename '00042.m2ts'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isowriter_empty_content() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut w = IsoWriter::new(buf, "EMPTY", "00001.m2ts");
|
||||
w.start().unwrap();
|
||||
// No data written
|
||||
w.finish().unwrap();
|
||||
let data = w.writer.into_inner();
|
||||
|
||||
// Should still have valid UDF structure
|
||||
// AVDP at sector 256
|
||||
let avdp = sector(&data, AVDP_SECTOR);
|
||||
assert_eq!(
|
||||
le_u16(avdp, 0),
|
||||
2,
|
||||
"AVDP tag should be present even with no data"
|
||||
);
|
||||
|
||||
// VRS
|
||||
let vrs = sector(&data, VRS_START);
|
||||
assert_eq!(&vrs[1..6], b"BEA01");
|
||||
|
||||
// FSD
|
||||
let fsd = sector(&data, FSD_SECTOR);
|
||||
assert_eq!(le_u16(fsd, 0), 256);
|
||||
|
||||
// m2ts ICB should show 0 file size
|
||||
let icb = sector(&data, M2TS_ICB_SECTOR);
|
||||
let file_size = le_u64(icb, 56);
|
||||
assert_eq!(file_size, 0, "empty content should have 0 file size");
|
||||
|
||||
// Output should be at least DATA_START sectors (the header structure)
|
||||
assert!(
|
||||
data.len() >= DATA_START as usize * SECTOR_SIZE as usize,
|
||||
"output too small for valid UDF structure"
|
||||
);
|
||||
}
|
||||
}
|
||||
+225
-111
@@ -1,171 +1,285 @@
|
||||
//! M2tsStream — BD transport stream with embedded metadata header.
|
||||
//!
|
||||
//! Write: prepends FMKV metadata header, then passes through BD-TS bytes.
|
||||
//! Read: extracts metadata header (or scans PMT), then yields BD-TS bytes.
|
||||
//! Write: prepends FMKV metadata header, then muxes PES frames into BD-TS.
|
||||
//! Read: extracts metadata header (or scans PMT), then demuxes BD-TS into PES frames.
|
||||
|
||||
use super::{meta, ts, IOStream, ReadSeek};
|
||||
use super::{meta, ts};
|
||||
use crate::disc::{DiscTitle, Stream as DiscStream};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
type PesSetup = (
|
||||
Vec<u16>,
|
||||
Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
Vec<(u16, usize)>,
|
||||
);
|
||||
|
||||
/// Size of initial scan buffer for PMT/stream detection.
|
||||
const SCAN_SIZE: usize = 1024 * 1024;
|
||||
|
||||
enum Mode {
|
||||
Write {
|
||||
writer: Box<dyn Write>,
|
||||
header_written: bool,
|
||||
muxer: super::tsmux::TsMuxer<Box<dyn Write>>,
|
||||
},
|
||||
Read {
|
||||
reader: Box<dyn ReadSeek>,
|
||||
reader: Box<dyn Read>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Read as many bytes as possible into buf (multiple read calls if needed).
|
||||
/// Bounded by buf.len() — caller controls max bytes read.
|
||||
fn read_fill(r: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let mut total = 0;
|
||||
while total < buf.len() {
|
||||
match r.read(&mut buf[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// BD transport stream with embedded metadata.
|
||||
pub struct M2tsStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
finished: bool,
|
||||
/// Content size in bytes (file size minus header), set for read mode.
|
||||
content_size: Option<u64>,
|
||||
// PES support
|
||||
demuxer: Option<ts::TsDemuxer>,
|
||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
pes_eof: bool,
|
||||
/// Codec private data per stream (from FMKV header).
|
||||
stored_codec_privates: Vec<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl M2tsStream {
|
||||
/// Create for writing. Metadata header is written on first write().
|
||||
pub fn new(writer: impl Write + 'static) -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
mode: Mode::Write {
|
||||
writer: Box::new(writer),
|
||||
header_written: false,
|
||||
},
|
||||
finished: false,
|
||||
content_size: None,
|
||||
/// Create for writing PES frames → BD-TS output.
|
||||
/// Writes FMKV metadata header, then muxes PES frames into BD transport stream.
|
||||
pub fn create(mut writer: impl Write + 'static, title: &DiscTitle) -> io::Result<Self> {
|
||||
// Write FMKV metadata header
|
||||
if !title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(title);
|
||||
meta::write_header(&mut writer, &m)?;
|
||||
}
|
||||
let pids: Vec<u16> = title
|
||||
.streams
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
DiscStream::Video(v) => v.pid,
|
||||
DiscStream::Audio(a) => a.pid,
|
||||
DiscStream::Subtitle(s) => s.pid,
|
||||
})
|
||||
.collect();
|
||||
let boxed: Box<dyn Write> = Box::new(writer);
|
||||
let mut muxer = super::tsmux::TsMuxer::new(boxed, &pids);
|
||||
for (i, cp) in title.codec_privates.iter().enumerate() {
|
||||
if let Some(data) = cp {
|
||||
muxer.set_codec_private(i, data.clone());
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
mode: Mode::Write { muxer },
|
||||
demuxer: None,
|
||||
parsers: Vec::new(),
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track: Vec::new(),
|
||||
pes_eof: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set stream metadata. Returns self for chaining.
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
fn setup_pes(streams: &[DiscStream]) -> PesSetup {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
let (pid, codec) = match s {
|
||||
DiscStream::Video(v) => (v.pid, v.codec),
|
||||
DiscStream::Audio(a) => (a.pid, a.codec),
|
||||
DiscStream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec)));
|
||||
}
|
||||
(pids, parsers, pid_to_track)
|
||||
}
|
||||
|
||||
/// Open an m2ts file for reading.
|
||||
/// Open an M2TS stream for reading. Takes any Read source — file, pipe, socket.
|
||||
///
|
||||
/// Tries FMKV metadata header first. Falls back to PMT scan + PTS duration.
|
||||
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
|
||||
// Get total file size for progress tracking
|
||||
let file_size = reader.seek(SeekFrom::End(0))?;
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
/// Tries FMKV metadata header first. Falls back to PMT scan of first 1 MB.
|
||||
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
|
||||
// Read first chunk — enough for FMKV header or PMT scan
|
||||
let mut head = vec![0u8; SCAN_SIZE];
|
||||
let head_len = read_fill(&mut reader, &mut head)?;
|
||||
head.truncate(head_len);
|
||||
|
||||
// Try FMKV metadata header
|
||||
if let Ok(Some(m)) = meta::read_header(&mut reader) {
|
||||
let header_end = reader.stream_position()?;
|
||||
let content_size = file_size.saturating_sub(header_end);
|
||||
// Try FMKV metadata header from the buffered head
|
||||
let mut cursor = io::Cursor::new(&head);
|
||||
if let Ok(Some(m)) = meta::read_header(&mut cursor) {
|
||||
let header_end = cursor.position() as usize;
|
||||
let title = m.to_title();
|
||||
let (pids, parsers, pid_to_track) = Self::setup_pes(&title.streams);
|
||||
// Chain: remaining head bytes + rest of reader
|
||||
let remaining_head = &head[header_end..];
|
||||
let chain: Box<dyn Read> =
|
||||
Box::new(io::Cursor::new(remaining_head.to_vec()).chain(reader));
|
||||
return Ok(Self {
|
||||
disc_title: m.to_title(),
|
||||
mode: Mode::Read {
|
||||
reader: Box::new(reader),
|
||||
disc_title: title.clone(),
|
||||
mode: Mode::Read { reader: chain },
|
||||
demuxer: if pids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ts::TsDemuxer::new(&pids))
|
||||
},
|
||||
finished: false,
|
||||
content_size: Some(content_size),
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
pes_eof: false,
|
||||
stored_codec_privates: title.codec_privates,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: scan PMT for streams, PTS for duration
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
let mut buf = vec![0u8; SCAN_SIZE];
|
||||
let n = reader.read(&mut buf)?;
|
||||
// No FMKV header — scan head for PMT
|
||||
let streams = ts::scan_streams(&head)
|
||||
.ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?;
|
||||
|
||||
let streams = ts::scan_streams(&buf[..n])
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no streams found"))?;
|
||||
let (pids, parsers, pid_to_track) = Self::setup_pes(&streams);
|
||||
|
||||
let video_pid = streams.iter().find_map(|s| match s {
|
||||
DiscStream::Video(v) => Some(v.pid),
|
||||
_ => None,
|
||||
});
|
||||
let duration = video_pid
|
||||
.and_then(|pid| ts::scan_duration(&mut reader, pid))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
// Chain: full head (it's all TS data) + rest of reader
|
||||
let chain: Box<dyn Read> = Box::new(io::Cursor::new(head).chain(reader));
|
||||
|
||||
Ok(Self {
|
||||
disc_title: DiscTitle {
|
||||
duration_secs: duration,
|
||||
duration_secs: 0.0, // unknown without seeking
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
},
|
||||
mode: Mode::Read {
|
||||
reader: Box::new(reader),
|
||||
mode: Mode::Read { reader: chain },
|
||||
demuxer: if pids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ts::TsDemuxer::new(&pids))
|
||||
},
|
||||
finished: false,
|
||||
content_size: Some(file_size),
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
pes_eof: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for M2tsStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
impl crate::pes::Stream for M2tsStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.pes_eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
loop {
|
||||
let reader = match &mut self.mode {
|
||||
Mode::Read { reader } => reader,
|
||||
_ => return Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
};
|
||||
let mut buf = vec![0u8; 192 * 1024];
|
||||
let n = reader.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
self.pes_eof = true;
|
||||
// Flush demuxer — last PES packet may still be in the assembler
|
||||
if let Some(ref mut demuxer) = self.demuxer {
|
||||
for pes in &demuxer.flush() {
|
||||
if let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(self.pending_frames.pop_front());
|
||||
}
|
||||
|
||||
if let Some(ref mut demuxer) = self.demuxer {
|
||||
let packets = demuxer.feed(&buf[..n]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Write { muxer } => muxer.write_frame(frame.track, frame.pts, &frame.data),
|
||||
Mode::Read { .. } => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if self.finished {
|
||||
return Ok(());
|
||||
}
|
||||
self.finished = true;
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
match &mut self.mode {
|
||||
Mode::Write { muxer } => muxer.finish(),
|
||||
Mode::Read { .. } => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn total_bytes(&self) -> Option<u64> {
|
||||
self.content_size
|
||||
fn info(&self) -> &crate::disc::DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for M2tsStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
Mode::Write {
|
||||
ref mut writer,
|
||||
ref mut header_written,
|
||||
} => {
|
||||
if !*header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
meta::write_header(&mut *writer, &m)?;
|
||||
}
|
||||
*header_written = true;
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
// First check stored codec_privates from FMKV header
|
||||
if let Some(Some(cp)) = self.stored_codec_privates.get(track) {
|
||||
return Some(cp.clone());
|
||||
}
|
||||
// Fall back to parser-extracted codec_private
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
for (idx, s) in self.disc_title.streams.iter().enumerate() {
|
||||
if let crate::disc::Stream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
writer.write(buf)
|
||||
}
|
||||
Mode::Read { .. } => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stream opened for reading",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
)),
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
+72
-117
@@ -3,11 +3,9 @@
|
||||
//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
|
||||
//! Other tools skip the header during TS sync recovery (scan for 0x47).
|
||||
|
||||
use crate::disc::{
|
||||
AudioStream, Codec, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
|
||||
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
|
||||
@@ -48,6 +46,9 @@ pub enum MetaStream {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
secondary: bool,
|
||||
/// Base64-encoded codec initialization data (HEVCDecoderConfigurationRecord, etc.)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
codec_private: Option<String>,
|
||||
},
|
||||
#[serde(rename = "audio")]
|
||||
Audio {
|
||||
@@ -76,33 +77,40 @@ pub enum MetaStream {
|
||||
}
|
||||
|
||||
impl M2tsMeta {
|
||||
/// Build metadata from a disc Title.
|
||||
/// Build metadata from a DiscTitle. Codec privates come from title.codec_privates.
|
||||
pub fn from_title(title: &DiscTitle) -> Self {
|
||||
use base64::Engine;
|
||||
let streams = title
|
||||
.streams
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
.enumerate()
|
||||
.map(|(i, s)| match s {
|
||||
Stream::Video(v) => MetaStream::Video {
|
||||
pid: v.pid,
|
||||
codec: codec_to_str(v.codec),
|
||||
resolution: v.resolution.clone(),
|
||||
frame_rate: v.frame_rate.clone(),
|
||||
hdr: hdr_to_str(v.hdr),
|
||||
codec: v.codec.id().into(),
|
||||
resolution: v.resolution.to_string(),
|
||||
frame_rate: v.frame_rate.to_string(),
|
||||
hdr: v.hdr.id().into(),
|
||||
label: v.label.clone(),
|
||||
secondary: v.secondary,
|
||||
codec_private: title
|
||||
.codec_privates
|
||||
.get(i)
|
||||
.and_then(|cp| cp.as_ref())
|
||||
.map(|cp| base64::engine::general_purpose::STANDARD.encode(cp)),
|
||||
},
|
||||
Stream::Audio(a) => MetaStream::Audio {
|
||||
pid: a.pid,
|
||||
codec: codec_to_str(a.codec),
|
||||
channels: a.channels.clone(),
|
||||
codec: a.codec.id().into(),
|
||||
channels: a.channels.to_string(),
|
||||
language: a.language.clone(),
|
||||
sample_rate: a.sample_rate.clone(),
|
||||
sample_rate: a.sample_rate.to_string(),
|
||||
label: a.label.clone(),
|
||||
secondary: a.secondary,
|
||||
},
|
||||
Stream::Subtitle(s) => MetaStream::Subtitle {
|
||||
pid: s.pid,
|
||||
codec: codec_to_str(s.codec),
|
||||
codec: s.codec.id().into(),
|
||||
language: s.language.clone(),
|
||||
forced: s.forced,
|
||||
},
|
||||
@@ -131,12 +139,17 @@ impl M2tsMeta {
|
||||
hdr,
|
||||
label,
|
||||
secondary,
|
||||
codec_private: _,
|
||||
} => Stream::Video(VideoStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
resolution: resolution.clone(),
|
||||
frame_rate: frame_rate.clone(),
|
||||
hdr: str_to_hdr(hdr),
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
resolution: resolution
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::Resolution::Unknown),
|
||||
frame_rate: frame_rate
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::FrameRate::Unknown),
|
||||
hdr: hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr),
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
@@ -151,10 +164,14 @@ impl M2tsMeta {
|
||||
secondary,
|
||||
} => Stream::Audio(AudioStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
channels: channels.clone(),
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
channels: channels
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::AudioChannels::Unknown),
|
||||
language: language.clone(),
|
||||
sample_rate: sample_rate.clone(),
|
||||
sample_rate: sample_rate
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::SampleRate::Unknown),
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
}),
|
||||
@@ -165,7 +182,7 @@ impl M2tsMeta {
|
||||
forced,
|
||||
} => Stream::Subtitle(SubtitleStream {
|
||||
pid: *pid,
|
||||
codec: str_to_codec(codec),
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
language: language.clone(),
|
||||
forced: *forced,
|
||||
codec_data: None,
|
||||
@@ -183,8 +200,31 @@ impl M2tsMeta {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: crate::disc::ContentFormat::BdTs,
|
||||
codec_privates: self.codec_privates(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract codec_private data per stream (from FMKV header).
|
||||
/// Returns a Vec matching stream order — None for streams without codec_private.
|
||||
pub fn codec_privates(&self) -> Vec<Option<Vec<u8>>> {
|
||||
self.streams
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if let MetaStream::Video {
|
||||
codec_private: Some(ref b64),
|
||||
..
|
||||
} = s
|
||||
{
|
||||
{
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.decode(b64).ok()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the metadata header to a writer. Padded to 192-byte boundary.
|
||||
@@ -205,28 +245,27 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to read a metadata header from the start of an m2ts file.
|
||||
/// Returns None for bare m2ts files (no header).
|
||||
/// On success, leaves reader positioned at the first TS packet.
|
||||
/// On failure, seeks back to the start.
|
||||
pub fn read_header<R: Read + Seek>(r: &mut R) -> io::Result<Option<M2tsMeta>> {
|
||||
let start = r.stream_position()?;
|
||||
/// Try to read an FMKV metadata header.
|
||||
/// Returns None if magic bytes don't match. Consumes header bytes on success.
|
||||
/// Caller handles seek-back on failure if needed (e.g. for fallback PMT scan).
|
||||
pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
const MAX_JSON_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
let mut magic = [0u8; 8];
|
||||
if r.read_exact(&mut magic).is_err() {
|
||||
r.seek(SeekFrom::Start(start))?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if magic[..4] != MAGIC[..4] {
|
||||
// Not a freemkv m2ts — seek back
|
||||
r.seek(SeekFrom::Start(start))?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
r.read_exact(&mut len_buf)?;
|
||||
let json_len = u32::from_be_bytes(len_buf) as usize;
|
||||
if json_len > MAX_JSON_SIZE {
|
||||
return Err(crate::error::Error::NoMetadata.into());
|
||||
}
|
||||
|
||||
let mut json_buf = vec![0u8; json_len];
|
||||
r.read_exact(&mut json_buf)?;
|
||||
@@ -238,37 +277,6 @@ pub fn read_header<R: Read + Seek>(r: &mut R) -> io::Result<Option<M2tsMeta>> {
|
||||
let raw_len = 8 + 4 + json_len;
|
||||
let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
if padding > 0 {
|
||||
r.seek(SeekFrom::Current(padding as i64))?;
|
||||
}
|
||||
|
||||
Ok(Some(meta))
|
||||
}
|
||||
|
||||
/// Read a metadata header from a forward-only stream (no Seek required).
|
||||
/// Returns None if the magic bytes don't match. Consumes the header bytes.
|
||||
pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
let mut magic = [0u8; 8];
|
||||
r.read_exact(&mut magic)?;
|
||||
|
||||
if magic[..4] != MAGIC[..4] {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
r.read_exact(&mut len_buf)?;
|
||||
let json_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut json_buf = vec![0u8; json_len];
|
||||
r.read_exact(&mut json_buf)?;
|
||||
|
||||
let meta: M2tsMeta = serde_json::from_slice(&json_buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Skip padding
|
||||
let raw_len = 8 + 4 + json_len;
|
||||
let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
if padding > 0 {
|
||||
let mut skip = vec![0u8; padding];
|
||||
r.read_exact(&mut skip)?;
|
||||
@@ -277,58 +285,5 @@ pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>
|
||||
Ok(Some(meta))
|
||||
}
|
||||
|
||||
// Codec string conversion (compact, no English — just codec identifiers)
|
||||
fn codec_to_str(c: Codec) -> String {
|
||||
match c {
|
||||
Codec::Hevc => "hevc",
|
||||
Codec::H264 => "h264",
|
||||
Codec::Vc1 => "vc1",
|
||||
Codec::Mpeg2 => "mpeg2",
|
||||
Codec::TrueHd => "truehd",
|
||||
Codec::DtsHdMa => "dtshd_ma",
|
||||
Codec::DtsHdHr => "dtshd_hr",
|
||||
Codec::Dts => "dts",
|
||||
Codec::Ac3 => "ac3",
|
||||
Codec::Ac3Plus => "eac3",
|
||||
Codec::Lpcm => "lpcm",
|
||||
Codec::Pgs => "pgs",
|
||||
Codec::DvdSub => "dvdsub",
|
||||
Codec::Unknown(_) => "unknown",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn str_to_codec(s: &str) -> Codec {
|
||||
match s {
|
||||
"hevc" => Codec::Hevc,
|
||||
"h264" => Codec::H264,
|
||||
"vc1" => Codec::Vc1,
|
||||
"mpeg2" => Codec::Mpeg2,
|
||||
"truehd" => Codec::TrueHd,
|
||||
"dtshd_ma" => Codec::DtsHdMa,
|
||||
"dtshd_hr" => Codec::DtsHdHr,
|
||||
"dts" => Codec::Dts,
|
||||
"ac3" => Codec::Ac3,
|
||||
"eac3" => Codec::Ac3Plus,
|
||||
"lpcm" => Codec::Lpcm,
|
||||
"pgs" => Codec::Pgs,
|
||||
_ => Codec::Unknown(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn hdr_to_str(h: HdrFormat) -> String {
|
||||
match h {
|
||||
HdrFormat::Sdr => "sdr",
|
||||
HdrFormat::Hdr10 => "hdr10",
|
||||
HdrFormat::DolbyVision => "dv",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn str_to_hdr(s: &str) -> HdrFormat {
|
||||
match s {
|
||||
"hdr10" => HdrFormat::Hdr10,
|
||||
"dv" => HdrFormat::DolbyVision,
|
||||
_ => HdrFormat::Sdr,
|
||||
}
|
||||
}
|
||||
// Serialization uses Codec::id() / HdrFormat::id() and Display impls.
|
||||
// Deserialization uses FromStr impls (.parse()) on each enum.
|
||||
|
||||
+95
-46
@@ -5,7 +5,7 @@
|
||||
//! cues and seek head are finalized at the end.
|
||||
|
||||
use super::ebml;
|
||||
use crate::disc::{AudioStream, Chapter, Codec, SubtitleStream, VideoStream};
|
||||
use crate::disc::{AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream};
|
||||
use std::io::{self, Seek, SeekFrom, Write};
|
||||
|
||||
/// MKV track definition (built from disc stream metadata).
|
||||
@@ -20,6 +20,14 @@ pub struct MkvTrack {
|
||||
// Video-specific
|
||||
pub pixel_width: u32,
|
||||
pub pixel_height: u32,
|
||||
pub default_duration_ns: u64, // nanoseconds per frame (0 = unknown)
|
||||
pub display_width: u32, // display aspect ratio width (0 = same as pixel)
|
||||
pub display_height: u32, // display aspect ratio height (0 = same as pixel)
|
||||
// HDR colour metadata
|
||||
pub colour_matrix: u8, // MatrixCoefficients (9=bt2020nc)
|
||||
pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ)
|
||||
pub colour_primaries: u8, // Primaries (9=bt2020)
|
||||
pub colour_range: u8, // Range (1=tv/limited)
|
||||
// Audio-specific
|
||||
pub sample_rate: f64,
|
||||
pub channels: u8,
|
||||
@@ -35,17 +43,41 @@ impl MkvTrack {
|
||||
Codec::Mpeg2 => "V_MPEG2",
|
||||
_ => "V_MPEG2",
|
||||
};
|
||||
let (w, h) = parse_resolution(&v.resolution);
|
||||
let (w, h) = v.resolution.pixels();
|
||||
let (num, den) = v.frame_rate.as_fraction();
|
||||
let default_duration_ns = if num > 0 {
|
||||
(1_000_000_000u64 * den as u64) / num as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (matrix, transfer, primaries, range) = match v.color_space {
|
||||
ColorSpace::Bt2020 => (9, 16, 9, 1), // bt2020nc, PQ, bt2020, limited
|
||||
ColorSpace::Bt709 => (1, 1, 1, 1), // bt709
|
||||
ColorSpace::Unknown => (0, 0, 0, 0),
|
||||
};
|
||||
// Override transfer for non-PQ HDR
|
||||
let transfer = match v.hdr {
|
||||
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => 16, // PQ
|
||||
HdrFormat::Hlg => 18,
|
||||
_ => transfer,
|
||||
};
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_VIDEO,
|
||||
codec_id,
|
||||
language: "und".into(),
|
||||
name: v.label.clone(),
|
||||
codec_private: None, // filled later by parser
|
||||
codec_private: None,
|
||||
is_default: !v.secondary,
|
||||
is_forced: false,
|
||||
pixel_width: w,
|
||||
pixel_height: h,
|
||||
default_duration_ns,
|
||||
display_width: w,
|
||||
display_height: h,
|
||||
colour_matrix: matrix,
|
||||
colour_transfer: transfer,
|
||||
colour_primaries: primaries,
|
||||
colour_range: range,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
@@ -61,18 +93,28 @@ impl MkvTrack {
|
||||
Codec::Lpcm => "A_PCM/INT/BIG",
|
||||
_ => "A_AC3",
|
||||
};
|
||||
let sr = parse_sample_rate(&a.sample_rate);
|
||||
let ch = parse_channels(&a.channels);
|
||||
let sr = a.sample_rate.hz();
|
||||
let ch = a.channels.count();
|
||||
|
||||
let name = a.label.clone();
|
||||
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||||
codec_id,
|
||||
language: a.language.clone(),
|
||||
name: a.label.clone(),
|
||||
name,
|
||||
codec_private: None,
|
||||
is_default: !a.secondary,
|
||||
is_forced: false,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
default_duration_ns: 0,
|
||||
display_width: 0,
|
||||
display_height: 0,
|
||||
colour_matrix: 0,
|
||||
colour_transfer: 0,
|
||||
colour_primaries: 0,
|
||||
colour_range: 0,
|
||||
sample_rate: sr,
|
||||
channels: ch,
|
||||
bit_depth: 0,
|
||||
@@ -94,6 +136,13 @@ impl MkvTrack {
|
||||
is_forced: s.forced,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
default_duration_ns: 0,
|
||||
display_width: 0,
|
||||
display_height: 0,
|
||||
colour_matrix: 0,
|
||||
colour_transfer: 0,
|
||||
colour_primaries: 0,
|
||||
colour_range: 0,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
@@ -116,6 +165,7 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
cluster_pos: u64,
|
||||
cluster_size_pos: u64,
|
||||
cluster_ts_ms: i64,
|
||||
base_pts_ms: Option<i64>,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
|
||||
@@ -216,11 +266,29 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
codec_private_filled.push(true);
|
||||
}
|
||||
|
||||
// DefaultDuration — frame duration in nanoseconds
|
||||
if track.default_duration_ns > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::DEFAULT_DURATION, track.default_duration_ns)?;
|
||||
}
|
||||
|
||||
// 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)?;
|
||||
if track.display_width > 0 && track.display_height > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::DISPLAY_WIDTH, track.display_width as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::DISPLAY_HEIGHT, track.display_height as u64)?;
|
||||
}
|
||||
// Colour metadata (HDR)
|
||||
if track.colour_matrix > 0 || track.colour_transfer > 0 {
|
||||
let col_pos = ebml::start_master(&mut writer, ebml::COLOUR)?;
|
||||
ebml::write_uint(&mut writer, ebml::MATRIX_COEFFICIENTS, track.colour_matrix as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRANSFER_CHARACTERISTICS, track.colour_transfer as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::PRIMARIES, track.colour_primaries as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::RANGE, track.colour_range as u64)?;
|
||||
ebml::end_master(&mut writer, col_pos)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, vid_pos)?;
|
||||
}
|
||||
|
||||
@@ -265,6 +333,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
cluster_pos: 0,
|
||||
cluster_size_pos: 0,
|
||||
cluster_ts_ms: 0,
|
||||
base_pts_ms: None,
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
codec_private_slots,
|
||||
@@ -280,7 +349,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
keyframe: bool,
|
||||
data: &[u8],
|
||||
) -> io::Result<()> {
|
||||
let pts_ms = pts_ns / 1_000_000;
|
||||
let raw_ms = pts_ns / 1_000_000;
|
||||
let base = *self.base_pts_ms.get_or_insert(raw_ms);
|
||||
let pts_ms = raw_ms - base;
|
||||
|
||||
// Start new cluster if needed
|
||||
if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS {
|
||||
@@ -414,45 +485,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
fn parse_resolution(s: &str) -> (u32, u32) {
|
||||
if s.contains("2160") {
|
||||
(3840, 2160)
|
||||
} else if s.contains("1080") {
|
||||
(1920, 1080)
|
||||
} else if s.contains("720") {
|
||||
(1280, 720)
|
||||
} else if s.contains("576") {
|
||||
(720, 576)
|
||||
} else if s.contains("480") {
|
||||
(720, 480)
|
||||
} else {
|
||||
(1920, 1080)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_sample_rate(s: &str) -> f64 {
|
||||
if s.contains("192") {
|
||||
192_000.0
|
||||
} else if s.contains("96") {
|
||||
96000.0
|
||||
} else {
|
||||
48000.0
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_channels(s: &str) -> u8 {
|
||||
if s.contains("7.1") {
|
||||
8
|
||||
} else if s.contains("5.1") {
|
||||
6
|
||||
} else if s.contains("stereo") || s.contains("2.0") {
|
||||
2
|
||||
} else if s.contains("mono") {
|
||||
1
|
||||
} else {
|
||||
6
|
||||
}
|
||||
}
|
||||
// Old parse_resolution/parse_sample_rate/parse_channels removed —
|
||||
// Resolution::pixels(), SampleRate::hz(), AudioChannels::count() replace them.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -487,6 +521,13 @@ mod tests {
|
||||
is_forced: false,
|
||||
pixel_width: 1920,
|
||||
pixel_height: 1080,
|
||||
default_duration_ns: 41708333,
|
||||
display_width: 1920,
|
||||
display_height: 1080,
|
||||
colour_matrix: 0,
|
||||
colour_transfer: 0,
|
||||
colour_primaries: 0,
|
||||
colour_range: 0,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
@@ -504,6 +545,13 @@ mod tests {
|
||||
is_forced: false,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
default_duration_ns: 0,
|
||||
display_width: 0,
|
||||
display_height: 0,
|
||||
colour_matrix: 0,
|
||||
colour_transfer: 0,
|
||||
colour_primaries: 0,
|
||||
colour_range: 0,
|
||||
sample_rate: 48000.0,
|
||||
channels: 6,
|
||||
bit_depth: 0,
|
||||
@@ -797,3 +845,4 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+203
-417
@@ -1,47 +1,33 @@
|
||||
//! MkvStream — Matroska container stream.
|
||||
//!
|
||||
//! Write: BD-TS bytes in → demux → codec parse → MKV container out.
|
||||
//! Read: MKV container in → extract frames → wrap as BD-TS → bytes out.
|
||||
//! Read: MKV container → demux EBML → PES frames out.
|
||||
//! Write: PES frames in → MKV mux → Matroska container.
|
||||
|
||||
use super::codec::{self, CodecParser};
|
||||
use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE};
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::ts::TsDemuxer;
|
||||
use super::{ebml, IOStream, ReadSeek, WriteSeek};
|
||||
use super::{ebml, WriteSeek};
|
||||
|
||||
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
|
||||
|
||||
/// Skip `n` bytes on a forward-only reader (no Seek required).
|
||||
fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> {
|
||||
io::copy(&mut r.take(n), &mut io::sink())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use crate::disc::*;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
|
||||
/// Lookahead buffer for codec header detection (5 MB default).
|
||||
const DEFAULT_MAX_BUFFER: usize = DEFAULT_LOOKAHEAD_SIZE;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum WritePhase {
|
||||
Scanning,
|
||||
Streaming,
|
||||
}
|
||||
|
||||
struct WriteState {
|
||||
demuxer: TsDemuxer,
|
||||
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
|
||||
writer: Option<Box<dyn WriteSeek>>,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
tracks: Vec<MkvTrack>,
|
||||
lookahead: LookaheadBuffer,
|
||||
phase: WritePhase,
|
||||
video_pending: usize,
|
||||
}
|
||||
use std::io::{self, Read};
|
||||
|
||||
struct ReadState {
|
||||
reader: Box<dyn ReadSeek>,
|
||||
buf: Vec<u8>,
|
||||
pos: usize,
|
||||
len: usize,
|
||||
reader: Box<dyn Read>,
|
||||
cluster_ts_ms: i64,
|
||||
/// Codec private data per track (track_number, hvcC/avcC bytes).
|
||||
codec_privates: Vec<(u16, Vec<u8>)>,
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Write(Box<WriteState>),
|
||||
Write {
|
||||
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
|
||||
},
|
||||
Read(ReadState),
|
||||
}
|
||||
|
||||
@@ -49,215 +35,74 @@ enum Mode {
|
||||
pub struct MkvStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
max_buffer: usize,
|
||||
finished: bool,
|
||||
/// File size in bytes, set for read mode.
|
||||
file_size: Option<u64>,
|
||||
}
|
||||
|
||||
impl MkvStream {
|
||||
/// Create for writing. BD-TS bytes written to this stream produce MKV output.
|
||||
pub fn new(writer: impl Write + Seek + 'static) -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
mode: Mode::Write(Box::new(WriteState {
|
||||
demuxer: TsDemuxer::new(&[]),
|
||||
muxer: None,
|
||||
writer: Some(Box::new(writer)),
|
||||
parsers: Vec::new(),
|
||||
pid_to_track: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
lookahead: LookaheadBuffer::new(DEFAULT_MAX_BUFFER),
|
||||
phase: WritePhase::Scanning,
|
||||
video_pending: 0,
|
||||
})),
|
||||
max_buffer: DEFAULT_MAX_BUFFER,
|
||||
finished: false,
|
||||
file_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set stream metadata. Returns self for chaining.
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
if let Mode::Write(ref mut ws) = self.mode {
|
||||
let mut pids = Vec::new();
|
||||
for s in &dt.streams {
|
||||
let (pid, track, parser) = match s {
|
||||
crate::disc::Stream::Video(v) => {
|
||||
// Only count primary video as pending — secondary streams
|
||||
// (Dolby Vision EL, PiP) may never produce codec headers
|
||||
if !v.secondary {
|
||||
ws.video_pending += 1;
|
||||
}
|
||||
(v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec))
|
||||
}
|
||||
crate::disc::Stream::Audio(a) => {
|
||||
(a.pid, MkvTrack::audio(a), codec::parser_for_codec(a.codec))
|
||||
}
|
||||
crate::disc::Stream::Subtitle(s) => (
|
||||
s.pid,
|
||||
MkvTrack::subtitle(s),
|
||||
codec::parser_for_codec_with_data(s.codec, s.codec_data.clone()),
|
||||
),
|
||||
};
|
||||
let idx = ws.tracks.len();
|
||||
pids.push(pid);
|
||||
ws.pid_to_track.push((pid, idx));
|
||||
ws.parsers.push((pid, parser));
|
||||
ws.tracks.push(track);
|
||||
/// Create for writing PES frames → MKV container.
|
||||
/// Codec privates come from title.codec_privates (populated by input stream).
|
||||
pub fn create(writer: Box<dyn WriteSeek>, title: &DiscTitle) -> io::Result<Self> {
|
||||
let mut tracks = Vec::new();
|
||||
let mut has_default_video = false;
|
||||
let mut has_default_audio = false;
|
||||
for (idx, s) in title.streams.iter().enumerate() {
|
||||
let mut track = match s {
|
||||
crate::disc::Stream::Video(v) => MkvTrack::video(v),
|
||||
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
|
||||
crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s),
|
||||
};
|
||||
// Only first video and first audio are default
|
||||
if track.is_default {
|
||||
match track.track_type {
|
||||
1 if !has_default_video => has_default_video = true,
|
||||
2 if !has_default_audio => has_default_audio = true,
|
||||
_ => track.is_default = false,
|
||||
}
|
||||
}
|
||||
ws.demuxer = TsDemuxer::new(&pids);
|
||||
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
|
||||
track.codec_private = Some(cp.clone());
|
||||
}
|
||||
tracks.push(track);
|
||||
}
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
|
||||
let muxer = MkvMuxer::new_with_chapters(
|
||||
writer,
|
||||
&tracks,
|
||||
Some(&title.playlist),
|
||||
title.duration_secs,
|
||||
&title.chapters,
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
mode: Mode::Write { muxer: Some(muxer) },
|
||||
})
|
||||
}
|
||||
|
||||
/// Set lookahead buffer size. Returns self.
|
||||
pub fn max_buffer(mut self, size: usize) -> Self {
|
||||
self.max_buffer = size;
|
||||
if let Mode::Write(ref mut ws) = self.mode {
|
||||
ws.lookahead = LookaheadBuffer::new(size);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Open an MKV file for reading.
|
||||
pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result<Self> {
|
||||
let file_size = reader.seek(SeekFrom::End(0))?;
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
let disc_title = parse_mkv_header(&mut reader)?;
|
||||
/// Open an MKV file for reading → PES frames.
|
||||
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
|
||||
let (disc_title, codec_privates) = parse_mkv_header(&mut reader)?;
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mode: Mode::Read(ReadState {
|
||||
reader: Box::new(reader),
|
||||
buf: Vec::new(),
|
||||
pos: 0,
|
||||
len: 0,
|
||||
cluster_ts_ms: 0,
|
||||
codec_privates,
|
||||
}),
|
||||
max_buffer: 0,
|
||||
finished: false,
|
||||
file_size: Some(file_size),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for MkvStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if self.finished {
|
||||
return Ok(());
|
||||
}
|
||||
self.finished = true;
|
||||
if let Mode::Write(ref mut ws) = self.mode {
|
||||
// Flush remaining PES packets
|
||||
if let Some(ref mut muxer) = ws.muxer {
|
||||
for pes in &ws.demuxer.flush() {
|
||||
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
// Write cues and finalize
|
||||
if let Some(muxer) = ws.muxer.take() {
|
||||
muxer.finish()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn total_bytes(&self) -> Option<u64> {
|
||||
self.file_size
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────
|
||||
|
||||
impl Write for MkvStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let dt = &self.disc_title;
|
||||
let ws = match self.mode {
|
||||
Mode::Write(ref mut ws) => ws,
|
||||
Mode::Read(_) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stream opened for reading",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match ws.phase {
|
||||
WritePhase::Scanning => {
|
||||
// Feed demuxer for codec detection
|
||||
let packets = ws.demuxer.feed(buf);
|
||||
for pes in &packets {
|
||||
if let Some((_, p)) = ws.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
let _ = p.parse(pes);
|
||||
}
|
||||
}
|
||||
|
||||
let state = ws.lookahead.push(buf);
|
||||
|
||||
// Check if all video codec headers found
|
||||
if check_codec_private(ws) {
|
||||
ws.lookahead.mark_ready();
|
||||
begin_streaming(ws, dt)?;
|
||||
return Ok(buf.len());
|
||||
}
|
||||
|
||||
match state {
|
||||
LookaheadState::Collecting | LookaheadState::Ready => Ok(buf.len()),
|
||||
LookaheadState::Overflow => Err(io::Error::new(
|
||||
io::ErrorKind::OutOfMemory,
|
||||
"no codec headers found within lookahead buffer",
|
||||
)),
|
||||
}
|
||||
}
|
||||
WritePhase::Streaming => {
|
||||
let packets = ws.demuxer.feed(buf);
|
||||
if let Some(ref mut muxer) = ws.muxer {
|
||||
for pes in &packets {
|
||||
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read ───────────────────────────────────────────────────────
|
||||
|
||||
impl Read for MkvStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
impl crate::pes::Stream for MkvStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
let rs = match self.mode {
|
||||
Mode::Read(ref mut rs) => rs,
|
||||
Mode::Write(_) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stream opened for writing",
|
||||
))
|
||||
}
|
||||
Mode::Write { .. } => return Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
};
|
||||
|
||||
// Drain internal buffer first
|
||||
if rs.pos < rs.len {
|
||||
let n = (rs.len - rs.pos).min(buf.len());
|
||||
buf[..n].copy_from_slice(&rs.buf[rs.pos..rs.pos + n]);
|
||||
rs.pos += n;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
// Read next element from MKV
|
||||
loop {
|
||||
let (id, size, _) = match ebml::read_element_header(&mut rs.reader) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(0),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
match id {
|
||||
@@ -278,126 +123,93 @@ impl Read for MkvStream {
|
||||
}
|
||||
|
||||
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
|
||||
let frame = &block[vl + 3..];
|
||||
let keyframe = block[vl + 2] & 0x80 != 0;
|
||||
let data = block[vl + 3..].to_vec();
|
||||
let pts_ms = rs.cluster_ts_ms + rel_ts as i64;
|
||||
let track_idx = (track as usize).saturating_sub(1); // MKV tracks are 1-based
|
||||
|
||||
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);
|
||||
// Skip blocks for non-existent tracks
|
||||
if track_idx >= self.disc_title.streams.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(Some(crate::pes::PesFrame {
|
||||
track: track_idx,
|
||||
pts: pts_ms * 1_000_000, // ms → ns
|
||||
keyframe,
|
||||
data,
|
||||
}));
|
||||
}
|
||||
_ => {
|
||||
if size != u64::MAX && size > 0 {
|
||||
rs.reader.seek(SeekFrom::Current(size as i64))?;
|
||||
}
|
||||
skip_bytes(&mut rs.reader, size)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write internals ────────────────────────────────────────────
|
||||
|
||||
fn check_codec_private(ws: &mut WriteState) -> bool {
|
||||
if ws.video_pending == 0 {
|
||||
return true;
|
||||
}
|
||||
for (pid, parser) in &ws.parsers {
|
||||
if let Some(cp) = parser.codec_private() {
|
||||
if let Some((_, idx)) = ws.pid_to_track.iter().find(|(p, _)| p == pid) {
|
||||
if ws.tracks[*idx].codec_private.is_none() {
|
||||
ws.tracks[*idx].codec_private = Some(cp);
|
||||
ws.video_pending -= 1;
|
||||
}
|
||||
}
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Write {
|
||||
muxer: Some(ref mut m),
|
||||
} => m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data),
|
||||
Mode::Write { muxer: None } => Ok(()),
|
||||
Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
}
|
||||
ws.video_pending == 0
|
||||
}
|
||||
|
||||
fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> {
|
||||
let writer = ws
|
||||
.writer
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other("writer already consumed"))?;
|
||||
|
||||
ws.muxer = Some(MkvMuxer::new_with_chapters(
|
||||
writer,
|
||||
&ws.tracks,
|
||||
Some(&dt.playlist),
|
||||
dt.duration_secs,
|
||||
&dt.chapters,
|
||||
)?);
|
||||
ws.phase = WritePhase::Streaming;
|
||||
|
||||
// Re-parse buffered data through a fresh demuxer, then reset the main
|
||||
// demuxer so stale PES assembler state from scanning doesn't cause
|
||||
// duplicate or incomplete packets during streaming.
|
||||
let pids: Vec<u16> = ws.pid_to_track.iter().map(|(pid, _)| *pid).collect();
|
||||
let buffered = ws.lookahead.drain();
|
||||
if !buffered.is_empty() {
|
||||
let mut temp = TsDemuxer::new(&pids);
|
||||
let packets = temp.feed(&buffered);
|
||||
if let Some(ref mut muxer) = ws.muxer {
|
||||
for pes in &packets {
|
||||
write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?;
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { ref mut muxer } = self.mode {
|
||||
if let Some(m) = muxer.take() {
|
||||
m.finish()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ws.demuxer = TsDemuxer::new(&pids);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_pes(
|
||||
pid_to_track: &[(u16, usize)],
|
||||
parsers: &mut [(u16, Box<dyn CodecParser>)],
|
||||
muxer: &mut MkvMuxer<Box<dyn WriteSeek>>,
|
||||
pes: &super::ts::PesPacket,
|
||||
) -> io::Result<()> {
|
||||
let idx = match pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
|
||||
Some((_, idx)) => *idx,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let parser = match parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
Some((_, p)) => p,
|
||||
None => return Ok(()),
|
||||
};
|
||||
for frame in parser.parse(pes) {
|
||||
muxer.write_frame(idx, frame.pts_ns, frame.keyframe, &frame.data)?;
|
||||
fn info(&self) -> &crate::disc::DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let track_num = (track + 1) as u16; // MKV tracks are 1-based
|
||||
if let Mode::Read(ref rs) = self.mode {
|
||||
rs.codec_privates
|
||||
.iter()
|
||||
.find(|(tn, _)| *tn == track_num)
|
||||
.map(|(_, data)| data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
true // MKV has all headers upfront in the EBML header
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── MKV header parsing (read side) ────────────────────────────
|
||||
|
||||
fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
|
||||
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
|
||||
fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
|
||||
let mut title = String::new();
|
||||
let mut duration_ms = 0.0f64;
|
||||
let mut ts_scale: u64 = 1_000_000;
|
||||
let mut streams: Vec<crate::disc::Stream> = Vec::new();
|
||||
let mut codec_privates: Vec<(u16, Vec<u8>)> = Vec::new();
|
||||
|
||||
let (id, size, _) = ebml::read_element_header(r)?;
|
||||
if id != ebml::EBML {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "not EBML"));
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
if size > i64::MAX as u64 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"EBML header too large",
|
||||
));
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
r.seek(SeekFrom::Current(size as i64))?;
|
||||
skip_bytes(r, size)?;
|
||||
|
||||
let (id, _, _) = ebml::read_element_header(r)?;
|
||||
if id != ebml::SEGMENT {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "no Segment"));
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
|
||||
let (mut got_info, mut got_tracks) = (false, false);
|
||||
@@ -413,91 +225,107 @@ fn parse_mkv_header(r: &mut (impl Read + Seek)) -> io::Result<DiscTitle> {
|
||||
|
||||
match id {
|
||||
ebml::INFO => {
|
||||
let end = r.stream_position()? + size;
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::TIMESTAMP_SCALE => ts_scale = ebml::read_uint_val(r, cs as usize)?,
|
||||
ebml::DURATION => duration_ms = ebml::read_float_val(r, cs as usize)?,
|
||||
ebml::TITLE => title = ebml::read_string_val(r, cs as usize)?,
|
||||
_ => {
|
||||
r.seek(SeekFrom::Current(cs as i64))?;
|
||||
skip_bytes(r, cs)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
got_info = true;
|
||||
}
|
||||
ebml::TRACKS => {
|
||||
let end = r.stream_position()? + size;
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
if cid == ebml::TRACK_ENTRY {
|
||||
if let Some(s) = parse_track(r, cs)? {
|
||||
let (stream, tnum, cp) = parse_track(r, cs)?;
|
||||
if let Some(s) = stream {
|
||||
streams.push(s);
|
||||
}
|
||||
if let Some(cp) = cp {
|
||||
codec_privates.push((tnum, cp));
|
||||
}
|
||||
} else {
|
||||
r.seek(SeekFrom::Current(cs as i64))?;
|
||||
skip_bytes(r, cs)?;
|
||||
}
|
||||
}
|
||||
got_tracks = true;
|
||||
}
|
||||
ebml::CLUSTER => break,
|
||||
_ if size != u64::MAX => {
|
||||
r.seek(SeekFrom::Current(size as i64))?;
|
||||
skip_bytes(r, size)?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DiscTitle {
|
||||
let disc_title = DiscTitle {
|
||||
playlist: title,
|
||||
duration_secs: duration_ms * (ts_scale as f64) / 1_000_000_000.0,
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
})
|
||||
};
|
||||
Ok((disc_title, codec_privates))
|
||||
}
|
||||
|
||||
fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate::disc::Stream>> {
|
||||
let end = r.stream_position()? + size;
|
||||
/// Returns (stream, track_number, codec_private_bytes)
|
||||
fn parse_track(
|
||||
r: &mut impl Read,
|
||||
size: u64,
|
||||
) -> io::Result<(Option<crate::disc::Stream>, u16, Option<Vec<u8>>)> {
|
||||
let (mut ttype, mut tnum) = (0u64, 0u16);
|
||||
let (mut codec_id, mut lang, mut name) = (String::new(), String::from("und"), String::new());
|
||||
let (mut ph, mut sr, mut ch, mut forced) = (0u32, 0.0f64, 0u8, false);
|
||||
let mut codec_priv: Option<Vec<u8>> = None;
|
||||
|
||||
while r.stream_position()? < end {
|
||||
let (cid, cs, _) = ebml::read_element_header(r)?;
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::TRACK_NUMBER => tnum = ebml::read_uint_val(r, cs as usize)? as u16,
|
||||
ebml::TRACK_TYPE => ttype = ebml::read_uint_val(r, cs as usize)?,
|
||||
ebml::CODEC_ID => codec_id = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::CODEC_PRIVATE => codec_priv = Some(ebml::read_binary_val(r, cs as usize)?),
|
||||
ebml::LANGUAGE => lang = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::TRACK_NAME => name = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::FLAG_FORCED => forced = ebml::read_uint_val(r, cs as usize)? != 0,
|
||||
ebml::VIDEO => {
|
||||
let ve = r.stream_position()? + cs;
|
||||
while r.stream_position()? < ve {
|
||||
let (vid, vs, _) = ebml::read_element_header(r)?;
|
||||
let mut vrem = cs;
|
||||
while vrem > 0 {
|
||||
let (vid, vs, vhlen) = ebml::read_element_header(r)?;
|
||||
vrem = vrem.saturating_sub(vhlen as u64 + vs);
|
||||
if vid == ebml::PIXEL_HEIGHT {
|
||||
ph = ebml::read_uint_val(r, vs as usize)? as u32;
|
||||
} else {
|
||||
r.seek(SeekFrom::Current(vs as i64))?;
|
||||
skip_bytes(r, vs)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
ebml::AUDIO => {
|
||||
let ae = r.stream_position()? + cs;
|
||||
while r.stream_position()? < ae {
|
||||
let (aid, as_, _) = ebml::read_element_header(r)?;
|
||||
let mut arem = cs;
|
||||
while arem > 0 {
|
||||
let (aid, as_, ahlen) = ebml::read_element_header(r)?;
|
||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||
match aid {
|
||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
||||
ebml::CHANNELS => ch = ebml::read_uint_val(r, as_ as usize)? as u8,
|
||||
_ => {
|
||||
r.seek(SeekFrom::Current(as_ as i64))?;
|
||||
skip_bytes(r, as_)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
r.seek(SeekFrom::Current(cs as i64))?;
|
||||
skip_bytes(r, cs)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,30 +344,37 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
|
||||
"S_VOBSUB" => Codec::DvdSub,
|
||||
_ => Codec::Unknown(0),
|
||||
};
|
||||
let res = format!("{ph}p");
|
||||
let chs: String = match ch {
|
||||
8 => "7.1",
|
||||
6 => "5.1",
|
||||
2 => "stereo",
|
||||
1 => "mono",
|
||||
_ => "5.1",
|
||||
}
|
||||
.into();
|
||||
let srs: String = if sr >= 96000.0 { "96kHz" } else { "48kHz" }.into();
|
||||
let res = Resolution::from_height(ph);
|
||||
let chs = AudioChannels::from_count(ch);
|
||||
let srs = if sr >= 96000.0 {
|
||||
SampleRate::S96
|
||||
} else {
|
||||
SampleRate::S48
|
||||
};
|
||||
|
||||
Ok(match ttype {
|
||||
1 => Some(crate::disc::Stream::Video(VideoStream {
|
||||
pid: tnum,
|
||||
codec,
|
||||
resolution: res,
|
||||
frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
label: name,
|
||||
})),
|
||||
// Map MKV track numbers to BD-TS PIDs
|
||||
let ts_pid = if tnum == 1 {
|
||||
0x1011
|
||||
} else {
|
||||
0x1100 + (tnum - 2)
|
||||
};
|
||||
|
||||
let stream = match ttype {
|
||||
1 => {
|
||||
let is_secondary = name.contains("Dolby Vision EL") || name.contains("DV EL");
|
||||
Some(crate::disc::Stream::Video(VideoStream {
|
||||
pid: ts_pid,
|
||||
codec,
|
||||
resolution: res,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: is_secondary,
|
||||
label: name,
|
||||
}))
|
||||
}
|
||||
2 => Some(crate::disc::Stream::Audio(AudioStream {
|
||||
pid: tnum,
|
||||
pid: ts_pid,
|
||||
codec,
|
||||
channels: chs,
|
||||
language: lang,
|
||||
@@ -548,18 +383,17 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
|
||||
label: name,
|
||||
})),
|
||||
17 => Some(crate::disc::Stream::Subtitle(SubtitleStream {
|
||||
pid: tnum,
|
||||
pid: ts_pid,
|
||||
codec,
|
||||
language: lang,
|
||||
forced,
|
||||
codec_data: None,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
Ok((stream, tnum, codec_priv))
|
||||
}
|
||||
|
||||
// ── BD-TS frame wrapping (read side) ──────────────────────────
|
||||
|
||||
fn block_vint(d: &[u8]) -> (u64, usize) {
|
||||
if d.is_empty() {
|
||||
return (0, 0);
|
||||
@@ -570,68 +404,20 @@ fn block_vint(d: &[u8]) -> (u64, usize) {
|
||||
if d[0] & 0x40 != 0 && d.len() >= 2 {
|
||||
return ((((d[0] & 0x3F) as u64) << 8) | d[1] as u64, 2);
|
||||
}
|
||||
(0, 1)
|
||||
}
|
||||
|
||||
fn frame_to_ts(out: &mut Vec<u8>, track: u16, pts_ms: i64, data: &[u8]) {
|
||||
let pid = if track == 1 {
|
||||
0x1011
|
||||
} else {
|
||||
0x1100 + (track - 2)
|
||||
};
|
||||
let stream_id: u8 = if track == 1 { 0xE0 } else { 0xBD };
|
||||
let pts = encode_pts(pts_ms * 90);
|
||||
let hdr = [0x00, 0x00, 0x01, stream_id, 0x00, 0x00, 0x80, 0x80, 0x05];
|
||||
|
||||
let mut pes = Vec::with_capacity(hdr.len() + pts.len() + data.len());
|
||||
pes.extend_from_slice(&hdr);
|
||||
pes.extend_from_slice(&pts);
|
||||
pes.extend_from_slice(data);
|
||||
|
||||
let mut off = 0;
|
||||
let mut pusi = true;
|
||||
while off < pes.len() {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = (pid >> 8) as u8 & 0x1F;
|
||||
if pusi {
|
||||
pkt[5] |= 0x40;
|
||||
pusi = false;
|
||||
}
|
||||
pkt[6] = pid as u8;
|
||||
|
||||
let space = 184;
|
||||
let rem = pes.len() - off;
|
||||
let n = rem.min(space);
|
||||
|
||||
if n < space {
|
||||
let pad = space - n;
|
||||
pkt[7] = 0x30; // AF + payload
|
||||
pkt[8] = pad as u8;
|
||||
if pad > 1 {
|
||||
pkt[9] = 0x00;
|
||||
}
|
||||
for byte in pkt.iter_mut().take((8 + pad).min(192)).skip(10) {
|
||||
*byte = 0xFF;
|
||||
}
|
||||
pkt[8 + pad..8 + pad + n].copy_from_slice(&pes[off..off + n]);
|
||||
} else {
|
||||
pkt[7] = 0x10; // payload only
|
||||
pkt[8..8 + n].copy_from_slice(&pes[off..off + n]);
|
||||
}
|
||||
|
||||
out.extend_from_slice(&pkt);
|
||||
off += n;
|
||||
if d[0] & 0x20 != 0 && d.len() >= 3 {
|
||||
return (
|
||||
(((d[0] & 0x1F) as u64) << 16) | ((d[1] as u64) << 8) | d[2] as u64,
|
||||
3,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_pts(pts: i64) -> [u8; 5] {
|
||||
let p = pts as u64;
|
||||
[
|
||||
0x21 | ((p >> 29) & 0x0E) as u8,
|
||||
((p >> 22) & 0xFF) as u8,
|
||||
0x01 | ((p >> 14) & 0xFE) as u8,
|
||||
((p >> 7) & 0xFF) as u8,
|
||||
0x01 | ((p << 1) & 0xFE) as u8,
|
||||
]
|
||||
if d[0] & 0x10 != 0 && d.len() >= 4 {
|
||||
return (
|
||||
(((d[0] & 0x0F) as u64) << 24)
|
||||
| ((d[1] as u64) << 16)
|
||||
| ((d[2] as u64) << 8)
|
||||
| d[3] as u64,
|
||||
4,
|
||||
);
|
||||
}
|
||||
(0, 1) // Unsupported 5+ byte VINT — treat as track 0
|
||||
}
|
||||
|
||||
+18
-43
@@ -1,30 +1,24 @@
|
||||
//! Stream-based I/O pipeline.
|
||||
//!
|
||||
//! All formats are streams. Two URLs, left reads, right writes:
|
||||
//! All formats are PES streams. Read from a format → PES frames.
|
||||
//! Write PES frames → a format.
|
||||
//!
|
||||
//! ```text
|
||||
//! freemkv disc:// mkv://Dune.mkv
|
||||
//! freemkv m2ts://Dune.m2ts mkv://Dune.mkv
|
||||
//! freemkv disc:// network://10.0.0.1:9000
|
||||
//! freemkv disc:// stdio://
|
||||
//! freemkv stdio:// mkv://Dune.mkv
|
||||
//! ```
|
||||
//!
|
||||
//! Streams implement `IOStream` for uniform handling:
|
||||
//!
|
||||
//! ```text
|
||||
//! let mut input = open_input("disc://", &opts)?;
|
||||
//! let mut output = open_output("mkv://Dune.mkv", input.info())?;
|
||||
//! io::copy(&mut *input, &mut *output)?;
|
||||
//! let mut input = input("iso://Disc.iso", &opts)?;
|
||||
//! let title = input.info().clone();
|
||||
//! let mut output = output("mkv://Dune.mkv", &title)?;
|
||||
//! while let Ok(Some(frame)) = input.read() {
|
||||
//! output.write(&frame)?;
|
||||
//! }
|
||||
//! output.finish()?;
|
||||
//! ```
|
||||
//!
|
||||
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||
|
||||
pub mod codec;
|
||||
pub mod disc;
|
||||
pub mod ebml;
|
||||
pub mod iso;
|
||||
mod isowriter;
|
||||
pub mod lookahead;
|
||||
mod m2ts;
|
||||
pub mod meta;
|
||||
pub mod mkv;
|
||||
@@ -35,39 +29,20 @@ pub mod ps;
|
||||
pub mod resolve;
|
||||
pub mod stdio;
|
||||
pub mod ts;
|
||||
pub mod tsmux;
|
||||
pub mod tsreader;
|
||||
|
||||
pub use disc::{DiscOptions, DiscStream};
|
||||
pub use iso::IsoStream;
|
||||
pub use disc::DiscStream;
|
||||
pub use iso::IsoSectorReader;
|
||||
pub use m2ts::M2tsStream;
|
||||
pub use mkvstream::MkvStream;
|
||||
pub use network::NetworkStream;
|
||||
pub use null::NullStream;
|
||||
pub use resolve::{open_input, open_output, parse_url, InputOptions, StreamUrl};
|
||||
pub use resolve::{input, output, parse_url, InputOptions, StreamUrl};
|
||||
pub use stdio::StdioStream;
|
||||
|
||||
use crate::disc::DiscTitle;
|
||||
use std::io::{self, Read, Seek, Write};
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
/// Common interface for all stream types.
|
||||
///
|
||||
/// A stream can be opened for reading or created for writing.
|
||||
/// Calling the unsupported direction returns an error.
|
||||
pub trait IOStream: Read + Write {
|
||||
/// Get stream metadata.
|
||||
fn info(&self) -> &DiscTitle;
|
||||
|
||||
/// Finalize the stream (flush, write index/cues, close).
|
||||
fn finish(&mut self) -> io::Result<()>;
|
||||
|
||||
/// Total content size in bytes, if known. Used for progress display.
|
||||
fn total_bytes(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Combined traits for internal trait objects.
|
||||
pub(crate) trait ReadSeek: Read + Seek {}
|
||||
impl<T: Read + Seek> ReadSeek for T {}
|
||||
|
||||
pub(crate) trait WriteSeek: Write + Seek {}
|
||||
// WriteSeek — used internally by MKV muxer (container format requires seeking).
|
||||
pub trait WriteSeek: Write + Seek {}
|
||||
impl<T: Write + Seek> WriteSeek for T {}
|
||||
|
||||
+59
-126
@@ -1,18 +1,14 @@
|
||||
//! NetworkStream — BD-TS over TCP with embedded metadata.
|
||||
//! NetworkStream — PES frames over TCP with embedded metadata.
|
||||
//!
|
||||
//! **Security:** Data is transmitted over plain TCP with no encryption.
|
||||
//! Use only on trusted networks (LAN). TLS support is planned.
|
||||
//! Use only on trusted networks (LAN).
|
||||
//!
|
||||
//! Write side (sender): connects to a listener, sends FMKV header + BD-TS data.
|
||||
//! Read side (receiver): listens for a connection, reads FMKV header + BD-TS data.
|
||||
//!
|
||||
//! The FMKV metadata header is the same format as M2tsStream uses — so a
|
||||
//! NetworkStream reader can hand off to any output stream (MKV, M2TS, etc.)
|
||||
//! with full metadata (labels, languages, duration).
|
||||
//! Write side (sender): connects to a listener, sends FMKV header + PES frames.
|
||||
//! Read side (receiver): listens for a connection, reads FMKV header + PES frames.
|
||||
|
||||
use super::{meta, IOStream};
|
||||
use super::meta;
|
||||
use crate::disc::DiscTitle;
|
||||
use std::io::{self, BufReader, BufWriter, Read, Write};
|
||||
use std::io::{self, BufReader, BufWriter, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// I/O buffer size for network reads/writes.
|
||||
@@ -32,7 +28,6 @@ enum Mode {
|
||||
pub struct NetworkStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl NetworkStream {
|
||||
@@ -46,7 +41,6 @@ impl NetworkStream {
|
||||
writer: BufWriter::with_capacity(NET_BUF_SIZE, stream),
|
||||
header_written: false,
|
||||
},
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,48 +58,31 @@ impl NetworkStream {
|
||||
stream.set_nodelay(true)?;
|
||||
let mut reader = BufReader::with_capacity(NET_BUF_SIZE, stream);
|
||||
|
||||
// Read FMKV metadata header (inline, since TcpStream doesn't impl Seek)
|
||||
let disc_title = meta::read_header_from_stream(&mut reader)?
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"no FMKV metadata header from sender",
|
||||
)
|
||||
})?
|
||||
// Read FMKV metadata header
|
||||
let disc_title = meta::read_header(&mut reader)?
|
||||
.ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })?
|
||||
.to_title();
|
||||
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mode: Mode::Read { reader },
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for NetworkStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if self.finished {
|
||||
return Ok(());
|
||||
impl crate::pes::Stream for NetworkStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
match &mut self.mode {
|
||||
Mode::Read { reader } => crate::pes::PesFrame::deserialize(reader),
|
||||
_ => Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
}
|
||||
self.finished = true;
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()?;
|
||||
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for NetworkStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Write {
|
||||
ref mut writer,
|
||||
writer,
|
||||
ref mut header_written,
|
||||
..
|
||||
} => {
|
||||
if !*header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
@@ -114,46 +91,34 @@ impl Write for NetworkStream {
|
||||
}
|
||||
*header_written = true;
|
||||
}
|
||||
writer.write(buf)
|
||||
frame.serialize(writer)
|
||||
}
|
||||
Mode::Read { .. } => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stream opened for reading",
|
||||
)),
|
||||
_ => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { ref mut writer, .. } = self.mode {
|
||||
writer.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { writer, .. } = &mut self.mode {
|
||||
writer.flush()?;
|
||||
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for NetworkStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self.mode {
|
||||
Mode::Read { ref mut reader } => reader.read(buf),
|
||||
Mode::Write { .. } => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stream opened for writing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
// NetworkStream is PES-only — no IOStream/Read/Write byte interface.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
AudioStream, Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream,
|
||||
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat,
|
||||
Resolution, SampleRate, Stream, VideoStream,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// Build a DiscTitle with streams for metadata tests.
|
||||
fn sample_title() -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: "NetworkTest".into(),
|
||||
@@ -165,8 +130,8 @@ mod tests {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
secondary: false,
|
||||
@@ -175,9 +140,9 @@ mod tests {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "7.1".into(),
|
||||
channels: AudioChannels::Surround71,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "English".into(),
|
||||
}),
|
||||
@@ -185,57 +150,15 @@ mod tests {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires TCP; may be flaky in CI environments
|
||||
fn network_listen_connect_roundtrip() {
|
||||
// Bind to OS-assigned port
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener); // Release so NetworkStream::listen can bind
|
||||
fn network_pes_roundtrip() {
|
||||
use crate::pes;
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let addr_clone = addr.clone();
|
||||
|
||||
// Spawn listener in a thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut received = Vec::new();
|
||||
loop {
|
||||
match ns.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => received.extend_from_slice(&buf[..n]),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
received
|
||||
});
|
||||
|
||||
// Small delay to let the listener thread bind
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// Connect and write data
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
|
||||
let payload = b"Hello from the write side of the network stream!";
|
||||
writer.write_all(payload).unwrap();
|
||||
writer.finish().unwrap();
|
||||
|
||||
let received = handle.join().unwrap();
|
||||
// The received data should end with our payload (after the FMKV header)
|
||||
assert!(
|
||||
received.windows(payload.len()).any(|w| w == payload),
|
||||
"payload not found in received data (got {} bytes)",
|
||||
received.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires TCP; may be flaky in CI environments
|
||||
fn network_metadata_flows() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
@@ -244,35 +167,45 @@ mod tests {
|
||||
let addr_clone = addr.clone();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
let info = ns.info().clone();
|
||||
info
|
||||
let mut ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
let info = pes::Stream::info(&ns).clone();
|
||||
let mut frames = Vec::new();
|
||||
while let Ok(Some(f)) = pes::Stream::read(&mut ns) {
|
||||
frames.push(f);
|
||||
}
|
||||
(info, frames)
|
||||
});
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
|
||||
// Must write at least one byte to trigger header send
|
||||
writer.write_all(&[0u8; 192]).unwrap();
|
||||
writer.finish().unwrap();
|
||||
let frame = pes::PesFrame {
|
||||
track: 0,
|
||||
pts: 90000,
|
||||
keyframe: true,
|
||||
data: vec![0x47; 192],
|
||||
};
|
||||
pes::Stream::write(&mut writer, &frame).unwrap();
|
||||
pes::Stream::finish(&mut writer).unwrap();
|
||||
|
||||
let info = handle.join().unwrap();
|
||||
let (info, frames) = handle.join().unwrap();
|
||||
assert_eq!(info.playlist, "NetworkTest");
|
||||
assert_eq!(info.duration_secs, 3600.0);
|
||||
assert_eq!(info.streams.len(), 2);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].track, 0);
|
||||
assert_eq!(frames[0].pts, 90000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_empty_addr_errors() {
|
||||
let result = NetworkStream::connect("");
|
||||
assert!(result.is_err(), "empty address should fail");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_no_port_errors() {
|
||||
// Connecting to an address without a port should fail
|
||||
let result = NetworkStream::connect("127.0.0.1");
|
||||
assert!(result.is_err(), "address without port should fail");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+13
-95
@@ -1,114 +1,32 @@
|
||||
//! NullStream — discards all data. Write-only. For benchmarking.
|
||||
//! NullStream — discards all data. Write-only PES sink. For benchmarking.
|
||||
|
||||
use super::IOStream;
|
||||
use crate::disc::DiscTitle;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io;
|
||||
|
||||
/// Null stream — accepts writes, discards data. For benchmarking rip speed.
|
||||
/// Null stream — accepts PES writes, discards data. For benchmarking rip speed.
|
||||
pub struct NullStream {
|
||||
disc_title: DiscTitle,
|
||||
bytes_written: u64,
|
||||
}
|
||||
|
||||
impl Default for NullStream {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NullStream {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(title: &DiscTitle) -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
bytes_written: 0,
|
||||
disc_title: title.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bytes_written(&self) -> u64 {
|
||||
self.bytes_written
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for NullStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
impl crate::pes::Stream for NullStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn write(&mut self, _: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for NullStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.bytes_written += buf.len() as u64;
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for NullStream {
|
||||
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"null stream is write-only",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn null_counts_bytes() {
|
||||
let mut ns = NullStream::new();
|
||||
assert_eq!(ns.bytes_written(), 0);
|
||||
ns.write_all(&[0u8; 100]).unwrap();
|
||||
assert_eq!(ns.bytes_written(), 100);
|
||||
ns.write_all(&[1u8; 50]).unwrap();
|
||||
assert_eq!(ns.bytes_written(), 150);
|
||||
// Single write returns correct count
|
||||
let n = ns.write(&[0u8; 200]).unwrap();
|
||||
assert_eq!(n, 200);
|
||||
assert_eq!(ns.bytes_written(), 350);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_read_errors() {
|
||||
let mut ns = NullStream::new();
|
||||
let mut buf = [0u8; 10];
|
||||
let err = ns.read(&mut buf).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_finish_ok() {
|
||||
let mut ns = NullStream::new();
|
||||
ns.write_all(&[0u8; 1000]).unwrap();
|
||||
ns.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_implements_iostream() {
|
||||
let ns = NullStream::new();
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(ns);
|
||||
boxed.write_all(&[0u8; 50]).unwrap();
|
||||
let info = boxed.info();
|
||||
assert_eq!(info.streams.len(), 0);
|
||||
boxed.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_total_bytes_returns_none() {
|
||||
let ns = NullStream::new();
|
||||
assert_eq!(ns.total_bytes(), None);
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ impl PsDemuxer {
|
||||
let mut pos = 0;
|
||||
|
||||
while let Some(sc) = find_start_code(&self.buffer, pos) {
|
||||
|
||||
if sc + 3 >= self.buffer.len() {
|
||||
// Not enough bytes to read the start code ID.
|
||||
break;
|
||||
|
||||
+151
-154
@@ -1,37 +1,31 @@
|
||||
//! Stream URL resolver — parses URL strings into IOStream instances.
|
||||
//! Stream URL resolver — parses URL strings into PES stream instances.
|
||||
//!
|
||||
//! Format: `scheme://path`
|
||||
//!
|
||||
//! | Scheme | Input | Output | Path |
|
||||
//! |--------|-------|--------|------|
|
||||
//! | disc:// | Yes | -- | empty (auto-detect) or /dev/sgN |
|
||||
//! | m2ts:// | Yes | Yes | file path (required) |
|
||||
//! | iso:// | Yes | -- | file path (required) |
|
||||
//! | mkv:// | Yes | Yes | file path (required) |
|
||||
//! | m2ts:// | Yes | Yes | file path (required) |
|
||||
//! | network:// | Yes (listen) | Yes (connect) | host:port (required) |
|
||||
//! | stdio:// | Yes (stdin) | Yes (stdout) | empty |
|
||||
//! | iso:// | Yes | -- | file path (required) |
|
||||
//! | null:// | -- | Yes | empty |
|
||||
//!
|
||||
//! Bare paths without a scheme are rejected.
|
||||
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||
|
||||
use super::disc::{DiscOptions, DiscStream};
|
||||
use super::iso::IsoStream;
|
||||
use super::disc::DiscStream;
|
||||
use super::network::NetworkStream;
|
||||
use super::null::NullStream;
|
||||
use super::stdio::StdioStream;
|
||||
use super::{IOStream, M2tsStream, MkvStream};
|
||||
use crate::disc::DiscTitle;
|
||||
use std::io::{self, BufReader, BufWriter};
|
||||
use super::{M2tsStream, MkvStream};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// I/O buffer size for file streams.
|
||||
const IO_BUF_SIZE: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Default MKV lookahead buffer size.
|
||||
/// Dynamically increased for UHD content (many streams delay video codec headers).
|
||||
const MKV_LOOKAHEAD_DEFAULT: usize = 10 * 1024 * 1024;
|
||||
const MKV_LOOKAHEAD_UHD: usize = 100 * 1024 * 1024;
|
||||
|
||||
/// Parsed stream URL.
|
||||
pub enum StreamUrl {
|
||||
/// Optical disc drive. Device path is optional (auto-detect if None).
|
||||
@@ -88,33 +82,30 @@ impl StreamUrl {
|
||||
}
|
||||
|
||||
/// Parse a URL string into a typed StreamUrl.
|
||||
///
|
||||
/// All URLs must use the `scheme://path` format. Bare paths are not supported.
|
||||
///
|
||||
/// ```text
|
||||
/// disc:// → Disc { device: None }
|
||||
/// disc:///dev/sg4 → Disc { device: Some("/dev/sg4") }
|
||||
/// m2ts:///tmp/Dune.m2ts → M2ts { path: "/tmp/Dune.m2ts" }
|
||||
/// mkv://Dune.mkv → Mkv { path: "Dune.mkv" }
|
||||
/// network://10.0.0.1:9000 → Network { addr: "10.0.0.1:9000" }
|
||||
/// null:// → Null
|
||||
/// ```
|
||||
pub fn parse_url(url: &str) -> StreamUrl {
|
||||
if let Some(rest) = url.strip_prefix("disc://") {
|
||||
return if rest.is_empty() {
|
||||
StreamUrl::Disc { device: None }
|
||||
} else {
|
||||
StreamUrl::Disc { device: Some(PathBuf::from(rest)) }
|
||||
StreamUrl::Disc {
|
||||
device: Some(PathBuf::from(rest)),
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("m2ts://") {
|
||||
return StreamUrl::M2ts { path: PathBuf::from(rest) };
|
||||
return StreamUrl::M2ts {
|
||||
path: PathBuf::from(rest),
|
||||
};
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("mkv://") {
|
||||
return StreamUrl::Mkv { path: PathBuf::from(rest) };
|
||||
return StreamUrl::Mkv {
|
||||
path: PathBuf::from(rest),
|
||||
};
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("network://") {
|
||||
return StreamUrl::Network { addr: rest.to_string() };
|
||||
return StreamUrl::Network {
|
||||
addr: rest.to_string(),
|
||||
};
|
||||
}
|
||||
if url == "null://" || url.starts_with("null://") {
|
||||
return StreamUrl::Null;
|
||||
@@ -123,24 +114,28 @@ pub fn parse_url(url: &str) -> StreamUrl {
|
||||
return StreamUrl::Stdio;
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("iso://") {
|
||||
return StreamUrl::Iso { path: PathBuf::from(rest) };
|
||||
return StreamUrl::Iso {
|
||||
path: PathBuf::from(rest),
|
||||
};
|
||||
}
|
||||
StreamUrl::Unknown {
|
||||
raw: url.to_string(),
|
||||
}
|
||||
StreamUrl::Unknown { raw: url.to_string() }
|
||||
}
|
||||
|
||||
/// Validate that a file path is non-empty and has a filename component.
|
||||
fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"),
|
||||
));
|
||||
return Err(crate::error::Error::StreamUrlMissingPath {
|
||||
scheme: scheme.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if path.file_name().is_none() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("{scheme}://{} is not a valid file path — must include a filename", path.display()),
|
||||
));
|
||||
return Err(crate::error::Error::StreamUrlInvalid {
|
||||
url: format!("{scheme}://{}", path.display()),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -148,131 +143,133 @@ fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
|
||||
/// Validate that a network address has host:port format.
|
||||
fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
if addr.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"network:// requires host:port (e.g. network://0.0.0.0:9000)",
|
||||
));
|
||||
return Err(crate::error::Error::StreamUrlMissingPath {
|
||||
scheme: "network".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if !addr.contains(':') {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("network://{addr} missing port — use network://{addr}:PORT"),
|
||||
));
|
||||
return Err(crate::error::Error::StreamUrlMissingPort {
|
||||
addr: addr.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a stream URL for reading (source).
|
||||
pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream>> {
|
||||
let parsed = parse_url(url);
|
||||
|
||||
match parsed {
|
||||
StreamUrl::Disc { device } => {
|
||||
let disc_opts = DiscOptions {
|
||||
device,
|
||||
keydb_path: opts.keydb_path.as_ref().map(|p| p.into()),
|
||||
title_index: opts.title_index,
|
||||
};
|
||||
let stream = DiscStream::open(disc_opts)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("m2ts://{}: {}", path.display(), e)))?;
|
||||
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::open(reader)?))
|
||||
}
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("mkv://{}: {}", path.display(), e)))?;
|
||||
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::open(reader)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::listen(addr)?))
|
||||
}
|
||||
StreamUrl::Stdio => {
|
||||
Ok(Box::new(StdioStream::input()))
|
||||
}
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
let scan_opts = match &opts.keydb_path {
|
||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
Ok(Box::new(IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?))
|
||||
}
|
||||
StreamUrl::Null => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
"null:// is write-only — cannot use as input"))
|
||||
}
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a stream URL for writing (destination).
|
||||
pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>> {
|
||||
let parsed = parse_url(url);
|
||||
|
||||
match parsed {
|
||||
StreamUrl::Disc { .. } => {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"disc:// is read-only — cannot use as output"))
|
||||
}
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
Ok(Box::new(IsoStream::create(&path.to_string_lossy())?.meta(meta)))
|
||||
}
|
||||
StreamUrl::Null => {
|
||||
Ok(Box::new(NullStream::new().meta(meta)))
|
||||
}
|
||||
StreamUrl::Stdio => {
|
||||
Ok(Box::new(StdioStream::output().meta(meta)))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::create(path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("m2ts://{}: {}", path.display(), e)))?;
|
||||
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::new(writer).meta(meta)))
|
||||
}
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::create(path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("mkv://{}: {}", path.display(), e)))?;
|
||||
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
let lookahead = if meta.streams.len() > 15 {
|
||||
MKV_LOOKAHEAD_UHD
|
||||
} else {
|
||||
MKV_LOOKAHEAD_DEFAULT
|
||||
};
|
||||
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead)))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::connect(addr)?.meta(meta)))
|
||||
}
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", raw)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for opening an input stream.
|
||||
#[derive(Default)]
|
||||
pub struct InputOptions {
|
||||
pub keydb_path: Option<String>,
|
||||
pub title_index: Option<usize>,
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub raw: bool,
|
||||
}
|
||||
|
||||
/// Open a PES input stream (produces PES frames).
|
||||
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||
let parsed = parse_url(url);
|
||||
match parsed {
|
||||
StreamUrl::Disc { .. } => {
|
||||
// Disc sources should use DiscStream::new() directly.
|
||||
// The caller opens the drive, inits, scans, then creates the stream.
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources",
|
||||
))
|
||||
}
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
let scan_opts = match &opts.keydb_path {
|
||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?;
|
||||
let capacity = reader.capacity();
|
||||
let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
if disc.titles.is_empty() {
|
||||
return Err(crate::error::Error::NoStreams.into());
|
||||
}
|
||||
let idx = opts.title_index.unwrap_or(0);
|
||||
if idx >= disc.titles.len() {
|
||||
return Err(crate::error::Error::DiscTitleRange {
|
||||
index: idx,
|
||||
count: disc.titles.len(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let title = disc.titles[idx].clone();
|
||||
let keys = disc.decrypt_keys();
|
||||
let format = disc.content_format;
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 64, format);
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::open(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::open(reader)?))
|
||||
}
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::open(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::open(reader)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::listen(addr)?))
|
||||
}
|
||||
StreamUrl::Stdio => Ok(Box::new(StdioStream::input())),
|
||||
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a PES output stream (consumes PES frames).
|
||||
pub fn output(
|
||||
url: &str,
|
||||
title: &crate::disc::DiscTitle,
|
||||
) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||
let parsed = parse_url(url);
|
||||
match parsed {
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::create(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let writer: Box<dyn super::WriteSeek> =
|
||||
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
|
||||
Ok(Box::new(MkvStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::create(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::connect(addr)?.meta(title)))
|
||||
}
|
||||
StreamUrl::Stdio => Ok(Box::new(StdioStream::output(title))),
|
||||
StreamUrl::Null => Ok(Box::new(NullStream::new(title))),
|
||||
StreamUrl::Disc { .. } => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
StreamUrl::Iso { .. } => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-73
@@ -1,17 +1,21 @@
|
||||
//! StdioStream — raw byte pipe via stdin/stdout. Format-agnostic.
|
||||
//! StdioStream — PES frames via stdin/stdout with FMKV metadata header.
|
||||
//!
|
||||
//! The FMKV header carries stream metadata (PIDs, codecs, languages, codec_privates)
|
||||
//! so the receiving end can set up muxing without scanning the content.
|
||||
|
||||
use super::IOStream;
|
||||
use super::meta;
|
||||
use crate::disc::DiscTitle;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io::{self, Write};
|
||||
|
||||
/// Stdio stream — reads from stdin, writes to stdout.
|
||||
///
|
||||
/// No headers, no metadata, no format opinions. Just bytes.
|
||||
/// The format is determined by whatever is on the other end.
|
||||
/// Stdio stream — reads PES from stdin, writes PES to stdout.
|
||||
/// FMKV metadata header is written/read automatically.
|
||||
pub struct StdioStream {
|
||||
disc_title: DiscTitle,
|
||||
reader: Option<io::Stdin>,
|
||||
writer: Option<io::Stdout>,
|
||||
writer: Option<io::BufWriter<io::Stdout>>,
|
||||
header_written: bool,
|
||||
header_read: bool,
|
||||
stored_codec_privates: Vec<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl StdioStream {
|
||||
@@ -21,94 +25,82 @@ impl StdioStream {
|
||||
disc_title: DiscTitle::empty(),
|
||||
reader: Some(io::stdin()),
|
||||
writer: None,
|
||||
header_written: false,
|
||||
header_read: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a stdio stream for writing (stdout).
|
||||
pub fn output() -> Self {
|
||||
pub fn output(title: &DiscTitle) -> Self {
|
||||
Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
disc_title: title.clone(),
|
||||
reader: None,
|
||||
writer: Some(io::stdout()),
|
||||
writer: Some(io::BufWriter::new(io::stdout())),
|
||||
header_written: false,
|
||||
header_read: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set metadata (for output — passed through from input side).
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IOStream for StdioStream {
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(ref mut w) = self.writer {
|
||||
w.flush()?;
|
||||
/// Read the FMKV metadata header from stdin on first read.
|
||||
fn ensure_header_read(&mut self) -> io::Result<()> {
|
||||
if self.header_read {
|
||||
return Ok(());
|
||||
}
|
||||
self.header_read = true;
|
||||
if let Some(ref mut r) = self.reader {
|
||||
if let Ok(Some(m)) = meta::read_header(r) {
|
||||
let title = m.to_title();
|
||||
self.stored_codec_privates = title.codec_privates.clone();
|
||||
self.disc_title = title;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for StdioStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self.reader {
|
||||
Some(ref mut r) => r.read(buf),
|
||||
None => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stdio:// opened for output — cannot read",
|
||||
)),
|
||||
impl crate::pes::Stream for StdioStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
self.ensure_header_read()?;
|
||||
match &mut self.reader {
|
||||
Some(r) => crate::pes::PesFrame::deserialize(r),
|
||||
None => Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for StdioStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.writer {
|
||||
Some(ref mut w) => w.write(buf),
|
||||
None => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"stdio:// opened for input — cannot write",
|
||||
)),
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.writer {
|
||||
Some(ref mut w) => {
|
||||
if !self.header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
meta::write_header(w, &m)?;
|
||||
}
|
||||
self.header_written = true;
|
||||
}
|
||||
frame.serialize(w)
|
||||
}
|
||||
None => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.writer {
|
||||
Some(ref mut w) => w.flush(),
|
||||
None => Ok(()),
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(w) = &mut self.writer {
|
||||
w.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_input_read_errors_on_write() {
|
||||
let mut stream = StdioStream::input();
|
||||
let err = stream.write(&[0u8; 10]).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(err.to_string().contains("cannot write"), "got: {}", err);
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
self.stored_codec_privates
|
||||
.get(track)
|
||||
.and_then(|c| c.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_total_bytes_returns_none() {
|
||||
let input = StdioStream::input();
|
||||
assert_eq!(input.total_bytes(), None);
|
||||
let output = StdioStream::output();
|
||||
assert_eq!(output.total_bytes(), None);
|
||||
fn headers_ready(&self) -> bool {
|
||||
// After first read(), header is parsed and codec_privates populated
|
||||
self.header_read || self.writer.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
+78
-35
@@ -99,14 +99,28 @@ impl PesAssembler {
|
||||
/// 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
|
||||
pid_index: Vec<i16>, // PID → index into assemblers, -1 = not tracked
|
||||
remainder: Vec<u8>, // leftover bytes from previous feed() call
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
/// Take the remainder bytes (leftover from last feed() that didn't
|
||||
/// align to a 192-byte packet boundary).
|
||||
pub fn take_remainder(&mut self) -> Vec<u8> {
|
||||
std::mem::take(&mut self.remainder)
|
||||
}
|
||||
|
||||
/// Set the remainder bytes. Used to transfer alignment state from
|
||||
/// one demuxer to another without losing sync.
|
||||
pub fn set_remainder(&mut self, data: Vec<u8>) {
|
||||
self.remainder = data;
|
||||
}
|
||||
|
||||
/// Create a new demuxer tracking the given PIDs.
|
||||
pub fn new(pids: &[u16]) -> Self {
|
||||
let mut pid_index = [-1i16; 8192];
|
||||
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
||||
let table_size = (max_pid + 1).max(8192);
|
||||
let mut pid_index = vec![-1i16; table_size];
|
||||
let mut assemblers = Vec::with_capacity(pids.len());
|
||||
for (i, &pid) in pids.iter().enumerate() {
|
||||
pid_index[pid as usize] = i as i16;
|
||||
@@ -155,7 +169,11 @@ impl TsDemuxer {
|
||||
let adaptation = (ts[3] >> 4) & 0x03;
|
||||
|
||||
// Check if we're tracking this PID
|
||||
let idx = self.pid_index[pid as usize];
|
||||
let idx = if (pid as usize) < self.pid_index.len() {
|
||||
self.pid_index[pid as usize]
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
if idx < 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -165,6 +183,9 @@ impl TsDemuxer {
|
||||
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
|
||||
// Adaptation field present
|
||||
let af_len = ts[4] as usize;
|
||||
if af_len > 183 {
|
||||
continue; // Malformed: AF length exceeds TS payload
|
||||
}
|
||||
5 + af_len
|
||||
} else {
|
||||
4
|
||||
@@ -196,9 +217,15 @@ impl TsDemuxer {
|
||||
}
|
||||
}
|
||||
|
||||
// Save leftover bytes for next call
|
||||
// Save leftover bytes for next call (cap at one packet to prevent unbounded growth)
|
||||
if offset < work.len() {
|
||||
self.remainder.extend_from_slice(&work[offset..]);
|
||||
let leftover = &work[offset..];
|
||||
if leftover.len() < BD_TS_PACKET_SIZE {
|
||||
self.remainder.extend_from_slice(leftover);
|
||||
} else {
|
||||
// More than one full packet leftover — something is wrong, discard
|
||||
self.remainder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
completed
|
||||
@@ -245,30 +272,38 @@ fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
|
||||
let pts_dts_flags = (data[7] >> 6) & 0x03;
|
||||
let header_data_len = data[8] as usize;
|
||||
let data_start = 9 + header_data_len;
|
||||
let data_start = (9 + header_data_len).min(data.len());
|
||||
|
||||
let mut pts = None;
|
||||
let mut dts = None;
|
||||
|
||||
if pts_dts_flags >= 2 && data.len() >= 14 {
|
||||
pts = Some(parse_timestamp(&data[9..14]));
|
||||
if pts_dts_flags >= 2 && header_data_len >= 5 && data.len() >= 14 {
|
||||
pts = parse_timestamp(&data[9..14]);
|
||||
}
|
||||
if pts_dts_flags == 3 && data.len() >= 19 {
|
||||
dts = Some(parse_timestamp(&data[14..19]));
|
||||
if pts_dts_flags == 3 && header_data_len >= 10 && data.len() >= 19 {
|
||||
dts = parse_timestamp(&data[14..19]);
|
||||
}
|
||||
|
||||
(pts, dts, data_start)
|
||||
}
|
||||
|
||||
/// Parse a 5-byte PTS/DTS timestamp (33 bits in 90kHz).
|
||||
fn parse_timestamp(data: &[u8]) -> i64 {
|
||||
/// Validates marker bits per MPEG-2 spec. Returns None on invalid encoding.
|
||||
fn parse_timestamp(data: &[u8]) -> Option<i64> {
|
||||
if data.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
// Validate marker bits: byte 2 bit 0 and byte 4 bit 0 must be 1
|
||||
if (data[2] & 0x01) == 0 || (data[4] & 0x01) == 0 {
|
||||
return None;
|
||||
}
|
||||
let b0 = data[0] as i64;
|
||||
let b1 = data[1] as i64;
|
||||
let b2 = data[2] as i64;
|
||||
let b3 = data[3] as i64;
|
||||
let b4 = data[4] as i64;
|
||||
|
||||
((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1
|
||||
Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -300,6 +335,10 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
let section_len = (((data[pat_start + 1] & 0x0F) as usize) << 8)
|
||||
| data[pat_start + 2] as usize;
|
||||
let entries_start = pat_start + 8;
|
||||
if section_len < 4 {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
}
|
||||
let entries_end = pat_start + 3 + section_len - 4;
|
||||
let mut e = entries_start;
|
||||
while e + 4 <= data.len() && e < entries_end {
|
||||
@@ -363,8 +402,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0x1B => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::H264,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: String::new(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -373,8 +412,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0x24 => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: String::new(),
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -383,8 +422,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0xEA => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Vc1,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: String::new(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -393,8 +432,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0x02 => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: "1080i".into(),
|
||||
frame_rate: String::new(),
|
||||
resolution: Resolution::R1080i,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -403,45 +442,45 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
0x81 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Ac3,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x83 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Ac3Plus,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::DtsHdMa,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x82 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Dts,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
@@ -495,7 +534,7 @@ pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
return Some(parse_timestamp(&payload[9..14]));
|
||||
return parse_timestamp(&payload[9..14]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,7 +564,7 @@ pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
last_pts = Some(parse_timestamp(&payload[9..14]));
|
||||
last_pts = parse_timestamp(&payload[9..14]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,14 +616,18 @@ mod tests {
|
||||
fn test_parse_timestamp() {
|
||||
// Example: PTS = 0 → encoded as 21 00 01 00 01
|
||||
let data = [0x21, 0x00, 0x01, 0x00, 0x01];
|
||||
assert_eq!(parse_timestamp(&data), 0);
|
||||
assert_eq!(parse_timestamp(&data), Some(0));
|
||||
|
||||
// Example: PTS = 90000 (1 second at 90kHz)
|
||||
// Manual encoding: 33 bits = 0x00015F90
|
||||
// This is just a sanity check that the parser doesn't crash
|
||||
let data2 = [0x21, 0x00, 0x07, 0xE9, 0x01]; // approximate
|
||||
let pts = parse_timestamp(&data2);
|
||||
assert!(pts >= 0);
|
||||
assert!(pts.is_some() && pts.unwrap() >= 0);
|
||||
|
||||
// Invalid marker bits → returns None
|
||||
let bad = [0x00, 0x00, 0x00, 0x00, 0x00]; // marker bits wrong
|
||||
assert_eq!(parse_timestamp(&bad), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! BD Transport Stream muxer — PES frames → 192-byte BD-TS packets.
|
||||
//!
|
||||
//! Takes PES frames and writes them as BD-TS (Blu-ray transport stream)
|
||||
//! packets. Each frame is wrapped in a PES header, split into TS packets,
|
||||
//! and prepended with the 4-byte TP_extra_header.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
const TS_PAYLOAD: usize = 184;
|
||||
|
||||
pub struct TsMuxer<W: Write> {
|
||||
writer: W,
|
||||
pids: Vec<u16>,
|
||||
continuity: Vec<u8>, // per-PID continuity counter (0-15)
|
||||
codec_privates: Vec<Option<Vec<u8>>>, // per-track codec_private (for video parameter sets)
|
||||
params_written: Vec<bool>, // per-track: have we written parameter sets?
|
||||
base_pts_ns: Option<i64>,
|
||||
}
|
||||
|
||||
impl<W: Write> TsMuxer<W> {
|
||||
pub fn new(writer: W, pids: &[u16]) -> Self {
|
||||
let n = pids.len();
|
||||
Self {
|
||||
writer,
|
||||
pids: pids.to_vec(),
|
||||
continuity: vec![0u8; n],
|
||||
codec_privates: vec![None; n],
|
||||
params_written: vec![false; n],
|
||||
base_pts_ns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set codec_private data for a track. Used to prepend VPS/SPS/PPS
|
||||
/// as Annex B NALs before the first keyframe in the transport stream.
|
||||
pub fn set_codec_private(&mut self, track: usize, data: Vec<u8>) {
|
||||
if track < self.codec_privates.len() {
|
||||
self.codec_privates[track] = Some(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a PES frame as BD-TS packets.
|
||||
/// Video frame data is expected as length-prefixed NALUs (MKV/PES format)
|
||||
/// and is converted to Annex B for transport stream.
|
||||
pub fn write_frame(&mut self, track: usize, pts_ns: i64, data: &[u8]) -> io::Result<()> {
|
||||
if track >= self.pids.len() {
|
||||
return Ok(()); // unknown track, skip
|
||||
}
|
||||
let base = *self.base_pts_ns.get_or_insert(pts_ns);
|
||||
let pts_ns = pts_ns - base;
|
||||
|
||||
let pid = self.pids[track];
|
||||
let is_video = (0x1011..=0x101F).contains(&pid);
|
||||
|
||||
// For video: convert length-prefixed NALUs to Annex B (start codes)
|
||||
// On first keyframe, prepend parameter sets from codec_private
|
||||
let es_data = if is_video && !data.is_empty() {
|
||||
let mut annex_b = Vec::new();
|
||||
// Prepend codec_private parameter sets on first keyframe
|
||||
if !self.params_written[track] {
|
||||
if let Some(ref cp) = self.codec_privates[track] {
|
||||
if let Some(params) = hvcc_to_annex_b(cp) {
|
||||
annex_b.extend_from_slice(¶ms);
|
||||
self.params_written[track] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
annex_b.extend_from_slice(&length_prefixed_to_annex_b(data));
|
||||
annex_b
|
||||
} else {
|
||||
data.to_vec()
|
||||
};
|
||||
|
||||
// Build PES packet: header + data
|
||||
let pts_90k = if pts_ns >= 0 {
|
||||
(pts_ns as u64).saturating_mul(9) / 100_000
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let pes_header = build_pes_header(pid, pts_90k, es_data.len());
|
||||
let pes_packet = [&pes_header[..], &es_data[..]].concat();
|
||||
|
||||
// Split into TS packets
|
||||
let mut offset = 0;
|
||||
let mut first = true;
|
||||
while offset < pes_packet.len() {
|
||||
let remaining = pes_packet.len() - offset;
|
||||
let payload_len = remaining.min(TS_PAYLOAD);
|
||||
let need_stuffing = payload_len < TS_PAYLOAD;
|
||||
|
||||
// TP_extra_header (4 bytes — arrival time, set to 0)
|
||||
let tp_extra = [0u8; 4];
|
||||
|
||||
// TS header (4 bytes)
|
||||
let cc = self.continuity[track];
|
||||
self.continuity[track] = (cc + 1) & 0x0F;
|
||||
|
||||
let mut ts_header = [0u8; 4];
|
||||
ts_header[0] = SYNC_BYTE;
|
||||
ts_header[1] = ((pid >> 8) as u8) & 0x1F;
|
||||
if first {
|
||||
ts_header[1] |= 0x40; // PUSI
|
||||
}
|
||||
ts_header[2] = pid as u8;
|
||||
ts_header[3] = 0x10 | cc; // no adaptation, has payload
|
||||
|
||||
if need_stuffing {
|
||||
// Adaptation field for stuffing
|
||||
let stuff_len = TS_PAYLOAD - payload_len;
|
||||
ts_header[3] = 0x30 | cc; // adaptation + payload
|
||||
|
||||
self.writer.write_all(&tp_extra)?;
|
||||
self.writer.write_all(&ts_header)?;
|
||||
|
||||
// Write adaptation field: length byte + flags byte + 0xFF padding
|
||||
// stuff_len == 1: AF length = 0 (just the length byte, no flags)
|
||||
// stuff_len >= 2: AF length = stuff_len-1, flags = 0, rest 0xFF
|
||||
static STUFF_FF: [u8; 184] = [0xFF; 184];
|
||||
if stuff_len == 1 {
|
||||
self.writer.write_all(&[0u8])?; // adaptation_field_length = 0
|
||||
} else {
|
||||
self.writer.write_all(&[(stuff_len - 1) as u8])?; // AF length
|
||||
self.writer.write_all(&[0u8])?; // flags
|
||||
if stuff_len > 2 {
|
||||
self.writer.write_all(&STUFF_FF[..stuff_len - 2])?;
|
||||
}
|
||||
}
|
||||
self.writer
|
||||
.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||
} else {
|
||||
self.writer.write_all(&tp_extra)?;
|
||||
self.writer.write_all(&ts_header)?;
|
||||
self.writer
|
||||
.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||
}
|
||||
|
||||
offset += payload_len;
|
||||
first = false;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> io::Result<()> {
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a PES packet header for a BD stream.
|
||||
fn build_pes_header(pid: u16, pts_90k: u64, data_len: usize) -> Vec<u8> {
|
||||
// Determine stream_id from PID range
|
||||
let stream_id: u8 = if (0x1011..=0x101F).contains(&pid) {
|
||||
0xE0 // video
|
||||
} else {
|
||||
0xBD // audio, PGS subtitle, or default (private stream 1)
|
||||
};
|
||||
|
||||
let pes_data_len = data_len + 8; // 3 header bytes + 5 PTS bytes + data
|
||||
let mut header = Vec::with_capacity(14);
|
||||
|
||||
// Start code: 00 00 01 stream_id
|
||||
header.push(0x00);
|
||||
header.push(0x00);
|
||||
header.push(0x01);
|
||||
header.push(stream_id);
|
||||
|
||||
// PES packet length (0 = unbounded for video or if too large for u16)
|
||||
if stream_id == 0xE0 || pes_data_len > 65535 {
|
||||
header.push(0x00);
|
||||
header.push(0x00);
|
||||
} else {
|
||||
let len = pes_data_len as u16;
|
||||
header.push((len >> 8) as u8);
|
||||
header.push(len as u8);
|
||||
}
|
||||
|
||||
// Flags: 10xx xxxx — MPEG-2, PTS present
|
||||
header.push(0x80); // marker bits
|
||||
header.push(0x80); // PTS present
|
||||
|
||||
// PES header data length
|
||||
header.push(5); // 5 bytes of PTS
|
||||
|
||||
// PTS (5 bytes, 33-bit timestamp with markers)
|
||||
let pts = pts_90k & 0x1_FFFF_FFFF;
|
||||
header.push(0x21 | (((pts >> 29) & 0x0E) as u8));
|
||||
header.push(((pts >> 22) & 0xFF) as u8);
|
||||
header.push(0x01 | (((pts >> 14) & 0xFE) as u8));
|
||||
header.push(((pts >> 7) & 0xFF) as u8);
|
||||
header.push(0x01 | (((pts << 1) & 0xFE) as u8));
|
||||
|
||||
header
|
||||
}
|
||||
|
||||
/// Extract NAL arrays from HEVCDecoderConfigurationRecord and convert to Annex B.
|
||||
/// Returns VPS + SPS + PPS as Annex B NAL units (00 00 00 01 + NAL).
|
||||
fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
// HEVCDecoderConfigurationRecord: 22 bytes header, then NAL arrays
|
||||
if hvcc.len() < 23 {
|
||||
return None;
|
||||
}
|
||||
let num_arrays = hvcc[22] as usize;
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 23;
|
||||
|
||||
for _ in 0..num_arrays {
|
||||
if offset + 3 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
// array: 1 byte (completeness + NAL type), 2 bytes (numNalus)
|
||||
let _nal_type = hvcc[offset] & 0x3F;
|
||||
let num_nalus = u16::from_be_bytes([hvcc[offset + 1], hvcc[offset + 2]]) as usize;
|
||||
offset += 3;
|
||||
|
||||
for _ in 0..num_nalus {
|
||||
if offset + 2 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
let nal_len = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
|
||||
offset += 2;
|
||||
if offset + nal_len > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
out.extend_from_slice(&hvcc[offset..offset + nal_len]);
|
||||
offset += nal_len;
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert length-prefixed NALUs (4-byte BE length + NAL) to Annex B
|
||||
/// (00 00 00 01 + NAL). Used for video elementary streams in TS.
|
||||
fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut offset = 0;
|
||||
while offset + 4 <= data.len() {
|
||||
let len = u32::from_be_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3],
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
if offset + len > data.len() {
|
||||
break;
|
||||
}
|
||||
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
out.extend_from_slice(&data[offset..offset + len]);
|
||||
offset += len;
|
||||
}
|
||||
// If data doesn't look like length-prefixed NALs (no valid parse),
|
||||
// return original data unchanged — it may already be Annex B.
|
||||
if out.is_empty() && !data.is_empty() {
|
||||
return data.to_vec();
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! TsDemuxReader — reads from any source, demuxes BD-TS, produces PES frames.
|
||||
//!
|
||||
//! Wraps any Read source with a TsDemuxer + CodecParsers.
|
||||
//! One implementation used by M2TS, Network, Stdio, and any other BD-TS input.
|
||||
|
||||
use super::codec::{self, CodecParser};
|
||||
use super::ts::TsDemuxer;
|
||||
use crate::disc::Stream as DiscStream;
|
||||
use crate::pes::PesFrame;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read};
|
||||
|
||||
const READ_BUF_SIZE: usize = 192 * 1024; // 1024 BD-TS packets
|
||||
|
||||
/// Generic BD-TS → PES frame reader.
|
||||
pub struct TsDemuxReader<R: Read> {
|
||||
reader: R,
|
||||
demuxer: TsDemuxer,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
pending: VecDeque<PesFrame>,
|
||||
buf: Vec<u8>,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl<R: Read> TsDemuxReader<R> {
|
||||
/// Create from a reader and stream metadata.
|
||||
pub fn new(reader: R, streams: &[DiscStream]) -> Self {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
let (pid, c) = match s {
|
||||
DiscStream::Video(v) => (v.pid, v.codec),
|
||||
DiscStream::Audio(a) => (a.pid, a.codec),
|
||||
DiscStream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, codec::parser_for_codec(c)));
|
||||
}
|
||||
|
||||
Self {
|
||||
reader,
|
||||
demuxer: TsDemuxer::new(&pids),
|
||||
parsers,
|
||||
pid_to_track,
|
||||
pending: VecDeque::new(),
|
||||
buf: vec![0u8; READ_BUF_SIZE],
|
||||
eof: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next PES frame. Returns None at EOF.
|
||||
pub fn next_frame(&mut self) -> io::Result<Option<PesFrame>> {
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
loop {
|
||||
let n = self.reader.read(&mut self.buf)?;
|
||||
if n == 0 {
|
||||
self.eof = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let packets = self.demuxer.feed(&self.buf[..n]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending
|
||||
.push_back(PesFrame::from_codec_frame(*track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec private data for a track.
|
||||
pub fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
/// True when all primary video tracks have codec_private.
|
||||
pub fn headers_ready(&self, streams: &[DiscStream]) -> bool {
|
||||
for (idx, s) in streams.iter().enumerate() {
|
||||
if let DiscStream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
//! Stream — read PES frames in, write PES frames out.
|
||||
//!
|
||||
//! A stream is a stream. You read() from it or write() to it.
|
||||
//! The stream handles its own format internally.
|
||||
//!
|
||||
//! disc.read() → PES frame (sectors → decrypt → demux internally)
|
||||
//! mkv.write(frame) → MKV file (mux internally)
|
||||
|
||||
/// One frame of elementary stream data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PesFrame {
|
||||
/// Track index (0-based, matches stream info track order).
|
||||
pub track: usize,
|
||||
/// Presentation timestamp in nanoseconds.
|
||||
pub pts: i64,
|
||||
/// True if this is a keyframe (IDR for video).
|
||||
pub keyframe: bool,
|
||||
/// Raw elementary stream data (NAL units, audio samples, etc).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PesFrame {
|
||||
/// Serialize to bytes: track(1) | pts(8) | keyframe(1) | len(4) | data
|
||||
pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
|
||||
if self.track > 255 {
|
||||
return Err(crate::error::Error::PesInvalidMagic.into());
|
||||
}
|
||||
if self.data.len() > u32::MAX as usize {
|
||||
return Err(crate::error::Error::PesFrameTooLarge {
|
||||
size: self.data.len(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
w.write_all(&[self.track as u8])?;
|
||||
w.write_all(&self.pts.to_le_bytes())?;
|
||||
w.write_all(&[if self.keyframe { 1 } else { 0 }])?;
|
||||
w.write_all(&(self.data.len() as u32).to_le_bytes())?;
|
||||
w.write_all(&self.data)
|
||||
}
|
||||
|
||||
/// Deserialize from bytes. Returns None at EOF.
|
||||
pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result<Option<Self>> {
|
||||
const MAX_FRAME_SIZE: usize = 256 * 1024 * 1024; // 256 MB
|
||||
|
||||
let mut header = [0u8; 14]; // 1 + 8 + 1 + 4
|
||||
match r.read_exact(&mut header) {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let track = header[0] as usize;
|
||||
let pts = i64::from_le_bytes([
|
||||
header[1], header[2], header[3], header[4], header[5], header[6], header[7], header[8],
|
||||
]);
|
||||
let keyframe = header[9] != 0;
|
||||
let len = u32::from_le_bytes([header[10], header[11], header[12], header[13]]) as usize;
|
||||
if len > MAX_FRAME_SIZE {
|
||||
return Err(crate::error::Error::PesFrameTooLarge { size: len }.into());
|
||||
}
|
||||
let mut data = vec![0u8; len];
|
||||
r.read_exact(&mut data)?;
|
||||
Ok(Some(Self {
|
||||
track,
|
||||
pts,
|
||||
keyframe,
|
||||
data,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create from a codec::Frame with a track index.
|
||||
pub fn from_codec_frame(track: usize, frame: crate::mux::codec::Frame) -> Self {
|
||||
Self {
|
||||
track,
|
||||
pts: frame.pts_ns,
|
||||
keyframe: frame.keyframe,
|
||||
data: frame.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream. Read from it or write to it. Not both.
|
||||
pub trait Stream {
|
||||
/// Read the next frame. Returns None at end of stream.
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
|
||||
|
||||
/// Write a frame.
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
|
||||
|
||||
/// Finalize (flush, write index, close).
|
||||
fn finish(&mut self) -> std::io::Result<()>;
|
||||
|
||||
/// Stream metadata.
|
||||
fn info(&self) -> &crate::disc::DiscTitle;
|
||||
|
||||
/// Codec initialization data for a track (SPS/PPS, etc).
|
||||
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// True when codec_private is available for all video tracks.
|
||||
fn headers_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps any output stream and counts bytes written.
|
||||
///
|
||||
/// Progress tracking is a CLI concern — streams don't know their size.
|
||||
/// Wrap the output with CountingStream, then query bytes_written().
|
||||
///
|
||||
/// ```text
|
||||
/// let mut output = CountingStream::new(libfreemkv::output(dest, &title)?);
|
||||
/// while let Ok(Some(frame)) = input.read() {
|
||||
/// output.write(&frame)?;
|
||||
/// let pct = output.bytes_written() as f64 / total as f64;
|
||||
/// }
|
||||
/// ```
|
||||
pub struct CountingStream {
|
||||
inner: Box<dyn Stream>,
|
||||
written: u64,
|
||||
}
|
||||
|
||||
impl CountingStream {
|
||||
pub fn new(inner: Box<dyn Stream>) -> Self {
|
||||
Self { inner, written: 0 }
|
||||
}
|
||||
|
||||
/// Total bytes of PES frame data written through this stream.
|
||||
pub fn bytes_written(&self) -> u64 {
|
||||
self.written
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for CountingStream {
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
|
||||
self.inner.read()
|
||||
}
|
||||
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()> {
|
||||
self.written += frame.data.len() as u64;
|
||||
self.inner.write(frame)
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> std::io::Result<()> {
|
||||
self.inner.finish()
|
||||
}
|
||||
|
||||
fn info(&self) -> &crate::disc::DiscTitle {
|
||||
self.inner.info()
|
||||
}
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
self.inner.codec_private(track)
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
self.inner.headers_ready()
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ impl Mt1959 {
|
||||
|
||||
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let mut unlocked = false;
|
||||
for _attempt in 0..6 {
|
||||
for _attempt in 0..3 {
|
||||
match self.do_unlock(scsi) {
|
||||
Ok(_) => {
|
||||
unlocked = true;
|
||||
@@ -188,15 +188,17 @@ impl Mt1959 {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Err(_) => {
|
||||
let ok = if self.mode == MODE_A {
|
||||
let loaded = if self.mode == MODE_A {
|
||||
variant_a::load_firmware(self, scsi).is_ok()
|
||||
} else {
|
||||
variant_b::load_firmware(self, scsi).is_ok()
|
||||
};
|
||||
if ok {
|
||||
unlocked = true;
|
||||
break;
|
||||
if !loaded {
|
||||
continue;
|
||||
}
|
||||
// Firmware upload resets the drive. Give it time to
|
||||
// fully recover before retrying unlock.
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,7 +321,9 @@ impl PlatformDriver for Mt1959 {
|
||||
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked {
|
||||
self.run_init(scsi)?;
|
||||
// Don't retry init here — if init() failed, probing can't work either.
|
||||
// Retrying causes repeated USB bus resets on BU40N.
|
||||
return Ok(());
|
||||
}
|
||||
if self.probed {
|
||||
return Ok(());
|
||||
|
||||
+194
-22
@@ -5,9 +5,13 @@ use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
const SG_IO: u32 = 0x2285;
|
||||
const SG_SCSI_RESET: u32 = 0x2284;
|
||||
const SG_SCSI_RESET_DEVICE: i32 = 1;
|
||||
const SG_DXFER_NONE: i32 = -1;
|
||||
const SG_DXFER_TO_DEV: i32 = -2;
|
||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||
const SG_FLAG_DIRECT_IO: u32 = 1;
|
||||
const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
@@ -36,18 +40,24 @@ struct sg_io_hdr {
|
||||
info: u32,
|
||||
}
|
||||
|
||||
// Compile-time validation: sg_io_hdr must match the kernel's layout.
|
||||
// 64 bytes on 64-bit, 44 bytes on 32-bit (pointer-size dependent).
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 88);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
|
||||
|
||||
pub struct SgIoTransport {
|
||||
fd: i32,
|
||||
}
|
||||
|
||||
impl SgIoTransport {
|
||||
/// Open a SCSI device for use. Resets the drive first to ensure
|
||||
/// a known good state, then opens a fresh fd for commands.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_bytes = device.as_os_str().as_bytes();
|
||||
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
|
||||
c_path.extend_from_slice(path_bytes);
|
||||
c_path.push(0);
|
||||
|
||||
let device = Self::resolve_to_sg(device);
|
||||
Self::reset(&device)?;
|
||||
let c_path = Self::to_c_path(&device);
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
@@ -55,29 +65,183 @@ impl SgIoTransport {
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::DevicePermission {
|
||||
path: format!(
|
||||
"{}: permission denied (try running as root)",
|
||||
device.display()
|
||||
),
|
||||
}
|
||||
} else {
|
||||
Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
}
|
||||
});
|
||||
return Self::open_error(&device);
|
||||
}
|
||||
Ok(SgIoTransport { fd })
|
||||
}
|
||||
|
||||
/// Reset the drive to a known good state — equivalent to unplug/replug.
|
||||
/// After reset, the drive is clean and no fd is held open.
|
||||
///
|
||||
/// ## Why each step exists
|
||||
///
|
||||
/// When a process is killed (SIGKILL/kill -9) mid-SG_IO ioctl, two things
|
||||
/// go wrong: (1) the kernel's SG driver may have stale pending commands
|
||||
/// queued for the dead process's fd, and (2) the drive firmware may still
|
||||
/// be mid-operation (seeking, reading, processing a vendor command).
|
||||
///
|
||||
/// A new process opening the same /dev/sg* device gets a fresh fd, but the
|
||||
/// kernel doesn't automatically abort the dead process's commands — the
|
||||
/// drive can appear hung on the first SCSI command.
|
||||
///
|
||||
/// Additionally, killed processes skip Drop, so the tray may be locked
|
||||
/// via PREVENT MEDIUM REMOVAL with no process alive to unlock it.
|
||||
///
|
||||
/// ## Sequence
|
||||
///
|
||||
/// 1. **open** — allocates kernel SG state for this fd
|
||||
/// 2. **close** — triggers kernel cleanup: aborts any pending SG_IO
|
||||
/// commands associated with this fd. The key operation —
|
||||
/// the kernel's sg_release() cancels queued commands.
|
||||
/// 3. **sleep 2s** — the drive firmware needs time to finish/abort whatever
|
||||
/// it was doing when the previous process died. Without
|
||||
/// this, the next command may block on drive-internal state.
|
||||
/// 4. **open** — fresh fd with no stale commands in the kernel queue
|
||||
/// 5. **unlock** — ALLOW MEDIUM REMOVAL (CDB 0x1E, prevent=0). Clears
|
||||
/// any tray lock left by a killed process that never
|
||||
/// ran its Drop/cleanup.
|
||||
/// 6. **TUR** — TEST UNIT READY (CDB 0x00) with 3s timeout. If the
|
||||
/// drive responds, it's in a good state.
|
||||
/// 7. **escalate** — if TUR fails:
|
||||
/// - SG_SCSI_RESET (device level) — kernel sends a SCSI
|
||||
/// bus reset to the device, clearing all firmware state.
|
||||
/// - STOP + START UNIT (CDB 0x1B) — power-cycles the
|
||||
/// drive's logical unit, like pressing the eject button
|
||||
/// and reinserting.
|
||||
/// 8. **close** — release the fd. Drive is clean, nobody holds it.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
let c_path = Self::to_c_path(device);
|
||||
|
||||
// Step 1-2: open + close — flush stale kernel SG_IO state
|
||||
let probe_fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if probe_fd >= 0 {
|
||||
unsafe { libc::close(probe_fd) };
|
||||
}
|
||||
|
||||
// Step 3: let drive settle
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
|
||||
// Step 4: open clean fd
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Self::open_error(device);
|
||||
}
|
||||
|
||||
// Step 5: unlock tray
|
||||
let _ = Self::raw_command(fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
||||
|
||||
// Step 6: TUR — if drive responds, we're done
|
||||
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
|
||||
// Step 7: escalate — SG_SCSI_RESET
|
||||
let mut reset_type: i32 = SG_SCSI_RESET_DEVICE;
|
||||
unsafe { libc::ioctl(fd, SG_SCSI_RESET as _, &mut reset_type) };
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
|
||||
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
|
||||
// STOP + START
|
||||
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x00, 0], 3_000);
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x01, 0], 3_000);
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
let _ = Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: close — drive is clean
|
||||
unsafe { libc::close(fd) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_error<T>(device: &Path) -> Result<T> {
|
||||
let err = std::io::Error::last_os_error();
|
||||
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::DevicePermission {
|
||||
path: format!(
|
||||
"{}: permission denied (try running as root)",
|
||||
device.display()
|
||||
),
|
||||
}
|
||||
} else {
|
||||
Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a raw SCSI command on an fd. Used by reset() before the
|
||||
/// transport is constructed.
|
||||
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> std::result::Result<(), ()> {
|
||||
let mut sense = [0u8; 32];
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = SG_DXFER_NONE;
|
||||
hdr.cmd_len = cdb.len().min(16) as u8;
|
||||
hdr.mx_sb_len = sense.len() as u8;
|
||||
hdr.dxfer_len = 0;
|
||||
hdr.dxferp = std::ptr::null_mut();
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
if ret < 0 || hdr.status != 0 {
|
||||
Err(())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_c_path(device: &Path) -> Vec<u8> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_bytes = device.as_os_str().as_bytes();
|
||||
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
|
||||
c_path.extend_from_slice(path_bytes);
|
||||
c_path.push(0);
|
||||
c_path
|
||||
}
|
||||
|
||||
/// Resolve /dev/sr* -> /dev/sg* via sysfs. If already sg, returns as-is.
|
||||
/// Falls back to the original path if resolution fails.
|
||||
fn resolve_to_sg(device: &Path) -> std::path::PathBuf {
|
||||
let dev_name = match device.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => return device.to_path_buf(),
|
||||
};
|
||||
|
||||
if dev_name.starts_with("sg") {
|
||||
return device.to_path_buf();
|
||||
}
|
||||
|
||||
if dev_name.starts_with("sr") {
|
||||
let sg_dir = format!("/sys/class/block/{}/device/scsi_generic", dev_name);
|
||||
if let Ok(mut entries) = std::fs::read_dir(&sg_dir) {
|
||||
if let Some(Ok(entry)) = entries.next() {
|
||||
let sg_name = entry.file_name();
|
||||
return std::path::PathBuf::from(format!("/dev/{}", sg_name.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
device.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
libc::close(self.fd);
|
||||
}
|
||||
// Unlock tray before closing — don't leave it locked
|
||||
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +281,14 @@ impl ScsiTransport for SgIoTransport {
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
if dxfer_direction == SG_DXFER_FROM_DEV
|
||||
&& data.len() >= 4096
|
||||
&& (data.as_ptr() as usize) % 4096 == 0
|
||||
{
|
||||
hdr.flags = SG_FLAG_DIRECT_IO | SG_FLAG_Q_AT_HEAD;
|
||||
} else {
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
}
|
||||
|
||||
let ret = unsafe { libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
|
||||
|
||||
@@ -248,6 +248,24 @@ impl MacScsiTransport {
|
||||
exclusive: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reset the drive to a known good state.
|
||||
/// On macOS, we open the device, release exclusive access, wait for
|
||||
/// the system to reclaim it, then the next open() re-acquires.
|
||||
/// IOKit's USB layer handles device-level resets internally when the
|
||||
/// exclusive access is released and re-acquired.
|
||||
///
|
||||
/// NOTE: untested — macOS reset may need IOUSBDeviceInterface::ResetDevice()
|
||||
/// for USB drives. This is a best-effort implementation.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
// Opening and immediately dropping triggers release of exclusive access
|
||||
// which forces IOKit to reset the device state.
|
||||
if let Ok(transport) = Self::open(device) {
|
||||
drop(transport); // Drop releases exclusive access + closes plugin
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MacScsiTransport {
|
||||
|
||||
+26
-1
@@ -60,7 +60,7 @@ pub trait ScsiTransport: Send {
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
|
||||
// ── Platform-agnostic open ──────────────────────────────────────────────────
|
||||
// ── Platform-agnostic open / reset ──────────────────────────────────────────
|
||||
|
||||
/// Open a SCSI transport for the given device path.
|
||||
/// Selects the right backend for the current platform.
|
||||
@@ -88,6 +88,31 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset a SCSI device to a known good state. Platform-specific.
|
||||
/// On Linux: open/close fd cycle + TUR + SG_SCSI_RESET escalation.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::SgIoTransport::reset(device)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::MacScsiTransport::reset(device)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::SptiTransport::reset(device)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = device;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
|
||||
|
||||
/// SCSI INQUIRY response.
|
||||
|
||||
@@ -136,6 +136,55 @@ impl SptiTransport {
|
||||
|
||||
Ok(SptiTransport { handle })
|
||||
}
|
||||
|
||||
/// Reset the drive to a known good state.
|
||||
/// Opens the device, sends IOCTL_STORAGE_RESET_DEVICE to reset
|
||||
/// the USB/SCSI bus, then closes. Same concept as SG_SCSI_RESET on Linux.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
const IOCTL_STORAGE_RESET_DEVICE: u32 = 0x002D1004;
|
||||
|
||||
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
let win_path = normalize_device_path(dev_str);
|
||||
let wide: Vec<u16> = win_path.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// Open
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Ok(()); // can't open — skip reset, not fatal
|
||||
}
|
||||
|
||||
// Send device reset
|
||||
let mut returned: u32 = 0;
|
||||
unsafe {
|
||||
DeviceIoControl(
|
||||
handle,
|
||||
IOCTL_STORAGE_RESET_DEVICE,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
&mut returned,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
|
||||
// Close and wait for drive to settle
|
||||
unsafe { CloseHandle(handle) };
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SptiTransport {
|
||||
|
||||
+51
-1
@@ -7,8 +7,58 @@
|
||||
use crate::error::Result;
|
||||
|
||||
/// Read 2048-byte sectors from a disc or disc image.
|
||||
pub trait SectorReader {
|
||||
pub trait SectorReader: Send {
|
||||
/// Read `count` sectors starting at `lba` into `buf`.
|
||||
/// `buf` must be at least `count * 2048` bytes.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
|
||||
|
||||
/// Total capacity in sectors, if known.
|
||||
fn capacity(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// SectorReader backed by a file (ISO image).
|
||||
/// Seeks to lba * 2048, reads count * 2048 bytes.
|
||||
pub struct FileSectorReader {
|
||||
file: std::io::BufReader<std::fs::File>,
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl FileSectorReader {
|
||||
pub fn open(path: &str) -> std::io::Result<Self> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let len = file.metadata()?.len();
|
||||
let sectors = len / 2048;
|
||||
if sectors > u32::MAX as u64 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("{path}: image too large, max ~8 TB"),
|
||||
));
|
||||
}
|
||||
let capacity = sectors as u32;
|
||||
Ok(Self {
|
||||
file: std::io::BufReader::with_capacity(4 * 1024 * 1024, file),
|
||||
capacity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for FileSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let offset = lba as u64 * 2048;
|
||||
let bytes = count as usize * 2048;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|e| crate::error::Error::IoError { source: e })?;
|
||||
self.file
|
||||
.read_exact(&mut buf[..bytes])
|
||||
.map_err(|e| crate::error::Error::IoError { source: e })?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
+173
@@ -61,6 +61,11 @@ impl UdfFs {
|
||||
self.metadata_start
|
||||
}
|
||||
|
||||
/// Metadata partition size in sectors.
|
||||
pub(crate) fn metadata_sectors(&self) -> u32 {
|
||||
self.metadata_sectors
|
||||
}
|
||||
|
||||
/// Find a directory by path (e.g. "/BDMV/PLAYLIST").
|
||||
/// Path matching is case-insensitive.
|
||||
pub fn find_dir(&self, path: &str) -> Option<&DirEntry> {
|
||||
@@ -187,6 +192,50 @@ impl UdfFs {
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// All sector ranges that contain data (metadata + all files including STREAM).
|
||||
/// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps.
|
||||
pub fn all_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// UDF structure sectors
|
||||
let meta_end = self.metadata_start + self.metadata_sectors;
|
||||
ranges.push((0, meta_end));
|
||||
|
||||
// Walk entire tree including STREAM directories
|
||||
self.collect_all_file_ranges(reader, &self.root, &mut ranges)?;
|
||||
|
||||
// Merge overlapping/adjacent ranges and sort
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
let merged = merge_ranges(&ranges);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn collect_all_file_ranges(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
entry: &DirEntry,
|
||||
ranges: &mut Vec<(u32, u32)>,
|
||||
) -> Result<()> {
|
||||
for child in &entry.entries {
|
||||
if child.is_dir {
|
||||
self.collect_all_file_ranges(reader, child, ranges)?;
|
||||
} else {
|
||||
// Include the ICB sector
|
||||
ranges.push((self.meta_to_abs(child.meta_lba), 1));
|
||||
|
||||
// Include ALL file data extents (large m2ts files have many)
|
||||
if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
|
||||
for (data_lba, data_len) in extents {
|
||||
let abs_start = self.partition_start + data_lba;
|
||||
let sector_count = (data_len as u64).div_ceil(2048) as u32;
|
||||
ranges.push((abs_start, sector_count));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_file_ranges(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
@@ -788,6 +837,130 @@ fn parse_dstring(data: &[u8]) -> String {
|
||||
|
||||
/// Read a single 2048-byte sector from the drive.
|
||||
/// Uses standard READ(10) — no unlock required.
|
||||
/// Buffered sector reader — reduces SCSI round-trips by pre-fetching blocks.
|
||||
/// Each SCSI command has ~500ms overhead on USB drives, so reading 32 sectors
|
||||
/// at once (one command) is 32x faster than 32 individual reads.
|
||||
pub(crate) struct BufferedSectorReader<'a> {
|
||||
inner: &'a mut dyn SectorReader,
|
||||
cache_start: u32,
|
||||
cache: Vec<u8>,
|
||||
cache_sectors: u32,
|
||||
batch: u16,
|
||||
/// Pre-fetched sector data from bulk reads (sector ranges for AACS, MPLS, CLPI, etc.)
|
||||
prefetched: std::collections::HashMap<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<'a> BufferedSectorReader<'a> {
|
||||
pub(crate) fn new(inner: &'a mut dyn SectorReader, batch: u16) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
cache_start: u32::MAX,
|
||||
cache: Vec::new(),
|
||||
cache_sectors: 0,
|
||||
batch,
|
||||
prefetched: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BufferedSectorReader<'_> {
|
||||
/// Pre-read a contiguous range of sectors into the sliding cache.
|
||||
/// Used to bulk-load the UDF metadata partition so subsequent reads are instant.
|
||||
pub(crate) fn prefetch(&mut self, start_lba: u32, count: u32) {
|
||||
let total = count as usize * 2048;
|
||||
self.cache.resize(total, 0);
|
||||
let mut offset = 0u32;
|
||||
while offset < count {
|
||||
let batch = (count - offset).min(self.batch as u32) as u16;
|
||||
let buf_off = offset as usize * 2048;
|
||||
if self
|
||||
.inner
|
||||
.read_sectors(
|
||||
start_lba + offset,
|
||||
batch,
|
||||
&mut self.cache[buf_off..buf_off + batch as usize * 2048],
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
offset += batch as u32;
|
||||
}
|
||||
self.cache_start = start_lba;
|
||||
self.cache_sectors = offset;
|
||||
}
|
||||
|
||||
/// Pre-read multiple sector ranges into the permanent cache.
|
||||
/// Each range is read in batch-sized chunks and stored per-sector in a HashMap.
|
||||
/// Used to bulk-load all small files (AACS, MPLS, CLPI, META) before scanning.
|
||||
pub(crate) fn prefetch_ranges(&mut self, ranges: &[(u32, u32)]) {
|
||||
let mut tmp = vec![0u8; self.batch as usize * 2048];
|
||||
for &(start, count) in ranges {
|
||||
let mut offset = 0u32;
|
||||
while offset < count {
|
||||
let batch = (count - offset).min(self.batch as u32) as u16;
|
||||
let bytes = batch as usize * 2048;
|
||||
if self
|
||||
.inner
|
||||
.read_sectors(start + offset, batch, &mut tmp[..bytes])
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
for i in 0..batch as u32 {
|
||||
let s = i as usize * 2048;
|
||||
self.prefetched
|
||||
.insert(start + offset + i, tmp[s..s + 2048].to_vec());
|
||||
}
|
||||
offset += batch as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for BufferedSectorReader<'_> {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
) -> std::result::Result<usize, crate::error::Error> {
|
||||
if count == 1 {
|
||||
// Check permanent prefetch cache first (HashMap)
|
||||
if let Some(data) = self.prefetched.get(&lba) {
|
||||
buf[..2048].copy_from_slice(data);
|
||||
return Ok(2048);
|
||||
}
|
||||
// Check sliding cache
|
||||
if lba >= self.cache_start && lba < self.cache_start + self.cache_sectors {
|
||||
let offset = (lba - self.cache_start) as usize * 2048;
|
||||
buf[..2048].copy_from_slice(&self.cache[offset..offset + 2048]);
|
||||
return Ok(2048);
|
||||
}
|
||||
let block = self.batch;
|
||||
self.cache.resize(block as usize * 2048, 0);
|
||||
match self.inner.read_sectors(lba, block, &mut self.cache) {
|
||||
Ok(_) => {
|
||||
self.cache_start = lba;
|
||||
self.cache_sectors = block as u32;
|
||||
}
|
||||
Err(_) => {
|
||||
// Near end of disc or error — single sector fallback
|
||||
self.cache.resize(2048, 0);
|
||||
self.inner.read_sectors(lba, 1, &mut self.cache)?;
|
||||
self.cache_start = lba;
|
||||
self.cache_sectors = 1;
|
||||
}
|
||||
}
|
||||
buf[..2048].copy_from_slice(&self.cache[..2048]);
|
||||
Ok(2048)
|
||||
} else {
|
||||
// Multi-sector read — pass through
|
||||
self.inner.read_sectors(lba, count, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
reader.read_sectors(lba, 1, buf)?;
|
||||
Ok(())
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
//! Disc sector verification — read every sector and classify health.
|
||||
|
||||
use crate::disc::{Chapter, DiscTitle, Extent};
|
||||
use crate::sector::SectorReader;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Health status of a single sector read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SectorStatus {
|
||||
/// Read succeeded on first attempt, under 500ms
|
||||
Good,
|
||||
/// Read succeeded but took longer than expected (>500ms per sector)
|
||||
Slow,
|
||||
/// Read failed initially but succeeded on retry
|
||||
Recovered,
|
||||
/// Read failed all attempts — unrecoverable
|
||||
Bad,
|
||||
}
|
||||
|
||||
/// A contiguous range of bad or slow sectors.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SectorRange {
|
||||
pub start_lba: u32,
|
||||
pub count: u32,
|
||||
pub status: SectorStatus,
|
||||
/// Byte offset from start of title
|
||||
pub byte_offset: u64,
|
||||
}
|
||||
|
||||
/// Result of a disc verification scan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifyResult {
|
||||
pub total_sectors: u64,
|
||||
pub good: u64,
|
||||
pub slow: u64,
|
||||
pub recovered: u64,
|
||||
pub bad: u64,
|
||||
/// Contiguous ranges of non-good sectors
|
||||
pub ranges: Vec<SectorRange>,
|
||||
pub elapsed_secs: f64,
|
||||
}
|
||||
|
||||
impl VerifyResult {
|
||||
/// Percentage of sectors that are fully readable (good + slow + recovered).
|
||||
pub fn readable_pct(&self) -> f64 {
|
||||
if self.total_sectors == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
(self.total_sectors - self.bad) as f64 / self.total_sectors as f64 * 100.0
|
||||
}
|
||||
|
||||
/// True if every sector read successfully.
|
||||
pub fn is_perfect(&self) -> bool {
|
||||
self.bad == 0 && self.recovered == 0 && self.slow == 0
|
||||
}
|
||||
|
||||
/// Map a bad sector range to a chapter timestamp.
|
||||
pub fn chapter_at_offset(chapters: &[Chapter], byte_offset: u64, duration_secs: f64, total_bytes: u64) -> Option<(usize, f64)> {
|
||||
if total_bytes == 0 || chapters.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
|
||||
let mut chapter_idx = 0;
|
||||
for (i, ch) in chapters.iter().enumerate() {
|
||||
if ch.time_secs <= time_secs {
|
||||
chapter_idx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some((chapter_idx + 1, time_secs))
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress callback: (sectors_done, total_sectors, current_status)
|
||||
pub type ProgressFn = Box<dyn FnMut(u64, u64, SectorStatus)>;
|
||||
|
||||
/// Verify all sectors in a title's extents.
|
||||
/// Reads in batches for speed, falls back to single-sector on failure.
|
||||
pub fn verify_title(
|
||||
reader: &mut dyn SectorReader,
|
||||
title: &DiscTitle,
|
||||
batch_sectors: u16,
|
||||
mut on_progress: Option<ProgressFn>,
|
||||
) -> VerifyResult {
|
||||
let start = Instant::now();
|
||||
let mut good: u64 = 0;
|
||||
let mut slow: u64 = 0;
|
||||
let mut recovered: u64 = 0;
|
||||
let mut bad: u64 = 0;
|
||||
let mut ranges: Vec<SectorRange> = Vec::new();
|
||||
let mut sectors_done: u64 = 0;
|
||||
let mut byte_offset: u64 = 0;
|
||||
|
||||
let total_sectors: u64 = title.extents.iter().map(|e| e.sector_count as u64).sum();
|
||||
let mut buf = vec![0u8; batch_sectors as usize * 2048];
|
||||
|
||||
for ext in &title.extents {
|
||||
let mut offset: u32 = 0;
|
||||
while offset < ext.sector_count {
|
||||
let remaining = ext.sector_count - offset;
|
||||
let count = remaining.min(batch_sectors as u32) as u16;
|
||||
let lba = ext.start_lba + offset;
|
||||
let bytes = count as usize * 2048;
|
||||
|
||||
let batch_start = Instant::now();
|
||||
let batch_ok = reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes])
|
||||
.is_ok();
|
||||
let batch_ms = batch_start.elapsed().as_millis();
|
||||
|
||||
if batch_ok {
|
||||
// Batch succeeded — classify by speed
|
||||
let status = if batch_ms > count as u128 * 500 {
|
||||
SectorStatus::Slow
|
||||
} else {
|
||||
SectorStatus::Good
|
||||
};
|
||||
|
||||
match status {
|
||||
SectorStatus::Good => good += count as u64,
|
||||
SectorStatus::Slow => {
|
||||
slow += count as u64;
|
||||
ranges.push(SectorRange {
|
||||
start_lba: lba,
|
||||
count: count as u32,
|
||||
status: SectorStatus::Slow,
|
||||
byte_offset,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
sectors_done += count as u64;
|
||||
if let Some(ref mut cb) = on_progress {
|
||||
cb(sectors_done, total_sectors, status);
|
||||
}
|
||||
} else {
|
||||
// Batch failed — test each sector individually
|
||||
for i in 0..count {
|
||||
let sector_lba = lba + i as u32;
|
||||
let sector_offset = i as usize * 2048;
|
||||
let sector_byte_offset = byte_offset + i as u64 * 2048;
|
||||
|
||||
let s1 = Instant::now();
|
||||
let first_ok = reader
|
||||
.read_sectors(sector_lba, 1, &mut buf[sector_offset..sector_offset + 2048])
|
||||
.is_ok();
|
||||
let s1_ms = s1.elapsed().as_millis();
|
||||
|
||||
let status = if first_ok && s1_ms <= 500 {
|
||||
good += 1;
|
||||
SectorStatus::Good
|
||||
} else if first_ok {
|
||||
slow += 1;
|
||||
SectorStatus::Slow
|
||||
} else {
|
||||
// Retry once more
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
if reader
|
||||
.read_sectors(sector_lba, 1, &mut buf[sector_offset..sector_offset + 2048])
|
||||
.is_ok()
|
||||
{
|
||||
recovered += 1;
|
||||
SectorStatus::Recovered
|
||||
} else {
|
||||
bad += 1;
|
||||
SectorStatus::Bad
|
||||
}
|
||||
};
|
||||
|
||||
if status != SectorStatus::Good {
|
||||
// Merge with previous range if contiguous and same status
|
||||
if let Some(last) = ranges.last_mut() {
|
||||
if last.status == status
|
||||
&& last.start_lba + last.count == sector_lba
|
||||
{
|
||||
last.count += 1;
|
||||
} else {
|
||||
ranges.push(SectorRange {
|
||||
start_lba: sector_lba,
|
||||
count: 1,
|
||||
status,
|
||||
byte_offset: sector_byte_offset,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ranges.push(SectorRange {
|
||||
start_lba: sector_lba,
|
||||
count: 1,
|
||||
status,
|
||||
byte_offset: sector_byte_offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sectors_done += 1;
|
||||
if let Some(ref mut cb) = on_progress {
|
||||
cb(sectors_done, total_sectors, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += count as u32;
|
||||
byte_offset += bytes as u64;
|
||||
}
|
||||
}
|
||||
|
||||
VerifyResult {
|
||||
total_sectors,
|
||||
good,
|
||||
slow,
|
||||
recovered,
|
||||
bad,
|
||||
ranges,
|
||||
elapsed_secs: start.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
+92
-101
@@ -11,51 +11,29 @@ use libfreemkv::css;
|
||||
|
||||
// ── CSS Public API Tests ────────────────────────────────────────────────────
|
||||
|
||||
/// Test: css_descramble_sector_roundtrip_via_public_api
|
||||
///
|
||||
/// The public css::descramble_sector() wraps the LFSR descrambler.
|
||||
/// Since the cipher is XOR-based, calling descramble twice (with restored
|
||||
/// flags) should roundtrip the data.
|
||||
/// Test: css_descramble_sector modifies encrypted region and preserves header.
|
||||
#[test]
|
||||
fn css_descramble_sector_roundtrip_via_public_api() {
|
||||
let state = css::CssState {
|
||||
title_key: [0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
};
|
||||
|
||||
// Build a sector with scramble flag set
|
||||
let mut sector = vec![0x00u8; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]); // seed
|
||||
// PES header at byte 128
|
||||
sector[0x80] = 0x00;
|
||||
sector[0x81] = 0x00;
|
||||
sector[0x82] = 0x01;
|
||||
sector[0x83] = 0xE0;
|
||||
// Fill content
|
||||
for (i, byte) in sector.iter_mut().enumerate().take(2048).skip(0x84) {
|
||||
*byte = (i & 0xFF) as u8;
|
||||
}
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
let original = sector.clone();
|
||||
|
||||
// First descramble
|
||||
css::descramble_sector(&state, &mut sector);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00, "flag not cleared");
|
||||
assert_ne!(
|
||||
§or[0x80..0x84],
|
||||
&original[0x80..0x84],
|
||||
"content unchanged"
|
||||
);
|
||||
|
||||
// Restore flag for second pass
|
||||
sector[0x14] = 0x30;
|
||||
|
||||
// Second descramble = roundtrip
|
||||
css::descramble_sector(&state, &mut sector);
|
||||
assert_eq!(
|
||||
§or[0x80..2048],
|
||||
&original[0x80..2048],
|
||||
"double descramble did not roundtrip"
|
||||
);
|
||||
// Header preserved (except flag byte)
|
||||
for i in 0..128 {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||
}
|
||||
// Encrypted region modified
|
||||
assert_ne!(§or[128..256], &original[128..256]);
|
||||
}
|
||||
|
||||
/// Test: css_is_scrambled detects scramble flags correctly.
|
||||
@@ -363,8 +341,7 @@ fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
|
||||
/// The standard AACS IV, copied here independently so we are NOT importing
|
||||
/// the library's constant — this IS the cross-validation reference value.
|
||||
const CROSS_AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3,
|
||||
0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
/// Build a plaintext aligned unit with TS sync markers and recognisable
|
||||
@@ -373,8 +350,8 @@ const CROSS_AACS_IV: [u8; 16] = [
|
||||
#[test]
|
||||
fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
let unit_key: [u8; 16] = [
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
|
||||
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32,
|
||||
0x10,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
@@ -403,7 +380,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
for i in 0..16 {
|
||||
dk[i] = derived[i] ^ header[i];
|
||||
}
|
||||
ref_aes_cbc_encrypt(&dk, &CROSS_AACS_IV, &mut plaintext[16..aacs::ALIGNED_UNIT_LEN]);
|
||||
ref_aes_cbc_encrypt(
|
||||
&dk,
|
||||
&CROSS_AACS_IV,
|
||||
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
|
||||
);
|
||||
|
||||
// Sanity: ciphertext should differ
|
||||
assert_ne!(
|
||||
@@ -414,7 +395,10 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
|
||||
// -- Decrypt with the library --
|
||||
let ok = aacs::decrypt_unit(&mut plaintext, &unit_key);
|
||||
assert!(ok, "decrypt_unit returned false (TS sync verification failed)");
|
||||
assert!(
|
||||
ok,
|
||||
"decrypt_unit returned false (TS sync verification failed)"
|
||||
);
|
||||
assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared");
|
||||
|
||||
// Compare (byte 0 flag was cleared)
|
||||
@@ -432,8 +416,8 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
#[test]
|
||||
fn aacs_cross_validation_alternate_key() {
|
||||
let unit_key: [u8; 16] = [
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||
0x08,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
|
||||
@@ -452,7 +436,11 @@ fn aacs_cross_validation_alternate_key() {
|
||||
for i in 0..16 {
|
||||
dk[i] = derived[i] ^ header[i];
|
||||
}
|
||||
ref_aes_cbc_encrypt(&dk, &CROSS_AACS_IV, &mut plaintext[16..aacs::ALIGNED_UNIT_LEN]);
|
||||
ref_aes_cbc_encrypt(
|
||||
&dk,
|
||||
&CROSS_AACS_IV,
|
||||
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
|
||||
);
|
||||
|
||||
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
|
||||
|
||||
@@ -469,8 +457,8 @@ fn aacs_cross_validation_alternate_key() {
|
||||
#[test]
|
||||
fn aacs_bus_decrypt_cross_validation() {
|
||||
let read_data_key: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00,
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
||||
0x00,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
@@ -498,86 +486,74 @@ fn aacs_bus_decrypt_cross_validation() {
|
||||
|
||||
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
|
||||
|
||||
/// CSS descramble is XOR-based: applying it twice with restored scramble
|
||||
/// flag must recover the original plaintext. This test uses a structured
|
||||
/// MPEG-2 sector and stores a snapshot of the intermediate ciphertext to
|
||||
/// catch any regressions in the cipher implementation.
|
||||
/// CSS descramble modifies encrypted region deterministically.
|
||||
#[test]
|
||||
fn css_roundtrip_with_snapshot() {
|
||||
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed: [u8; 5] = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
|
||||
let mut sector = vec![0x00u8; 2048];
|
||||
sector[0] = 0x00;
|
||||
sector[1] = 0x00;
|
||||
sector[2] = 0x01;
|
||||
sector[3] = 0xBA;
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&seed);
|
||||
sector[0x80] = 0x00;
|
||||
sector[0x81] = 0x00;
|
||||
sector[0x82] = 0x01;
|
||||
sector[0x83] = 0xE0;
|
||||
sector[0x84] = 0x07;
|
||||
sector[0x85] = 0xEC;
|
||||
sector[0x86] = 0x80;
|
||||
sector[0x87] = 0x80;
|
||||
sector[0x88] = 0x05;
|
||||
sector[0x89] = 0x21;
|
||||
for i in 0x8A..2048 {
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
for i in 0x80..2048 {
|
||||
sector[i] = ((i * 7 + 3) & 0xFF) as u8;
|
||||
}
|
||||
let original = sector.clone();
|
||||
|
||||
// First descramble = "encrypt" via XOR
|
||||
css::lfsr::descramble_sector(&title_key, &mut sector);
|
||||
|
||||
// Snapshot the first 32 bytes of the encrypted region for regression
|
||||
let snapshot: Vec<u8> = sector[0x80..0xA0].to_vec();
|
||||
assert_eq!(snapshot.len(), 32);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00, "flag not cleared");
|
||||
assert_ne!(§or[0x80..0xA0], &original[0x80..0xA0]);
|
||||
|
||||
// Restore scramble flag and roundtrip
|
||||
sector[0x14] = 0x30;
|
||||
css::lfsr::descramble_sector(&title_key, &mut sector);
|
||||
// Determinism: same input → same output
|
||||
let mut sector2 = original.clone();
|
||||
css::lfsr::descramble_sector(&title_key, &mut sector2);
|
||||
assert_eq!(
|
||||
§or[0x80..2048],
|
||||
&original[0x80..2048],
|
||||
"CSS roundtrip failed"
|
||||
§or2[0x80..2048],
|
||||
"not deterministic"
|
||||
);
|
||||
}
|
||||
|
||||
/// Multiple key/seed combinations to exercise different LFSR states.
|
||||
/// Multiple key/seed combinations produce different outputs.
|
||||
#[test]
|
||||
fn css_roundtrip_multiple_keys() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
([0x00, 0x00, 0x00, 0x00, 0x00], [0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
([0xFF, 0xFF, 0xFF, 0xFF, 0xFF], [0xFF, 0xFF, 0xFF, 0xFF, 0xFF]),
|
||||
([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]),
|
||||
([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]),
|
||||
(
|
||||
[0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
[0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
),
|
||||
(
|
||||
[0x01, 0x02, 0x03, 0x04, 0x05],
|
||||
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
|
||||
),
|
||||
(
|
||||
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
|
||||
[0x12, 0x34, 0x56, 0x78, 0x9A],
|
||||
),
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (idx, (key, seed)) in cases.iter().enumerate() {
|
||||
let mut sector = vec![0x00u8; 2048];
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(seed);
|
||||
for i in 0x80..2048 {
|
||||
sector[i] = ((i + idx) & 0xFF) as u8;
|
||||
}
|
||||
let original = sector.clone();
|
||||
|
||||
css::lfsr::descramble_sector(key, &mut sector);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00, "case {}: flag not cleared", idx);
|
||||
|
||||
sector[0x14] = 0x30;
|
||||
css::lfsr::descramble_sector(key, &mut sector);
|
||||
assert_eq!(
|
||||
§or[0x80..2048],
|
||||
&original[0x80..2048],
|
||||
"case {}: roundtrip failed",
|
||||
idx
|
||||
);
|
||||
results.push(sector[0x80..0xA0].to_vec());
|
||||
}
|
||||
// Different keys/seeds should produce different outputs
|
||||
for i in 0..results.len() {
|
||||
for j in (i + 1)..results.len() {
|
||||
assert_ne!(
|
||||
results[i], results[j],
|
||||
"cases {} and {} produced same output",
|
||||
i, j
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,11 +570,26 @@ fn css_roundtrip_multiple_keys() {
|
||||
#[test]
|
||||
fn css_stevenson_attack_validates_cracked_key() {
|
||||
let candidates: &[([u8; 5], [u8; 5])] = &[
|
||||
([0x42, 0x13, 0x37, 0xBE, 0xEF], [0x11, 0x22, 0x33, 0x44, 0x55]),
|
||||
([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]),
|
||||
([0x10, 0x20, 0x30, 0x40, 0x50], [0x05, 0x06, 0x07, 0x08, 0x09]),
|
||||
([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]),
|
||||
([0x55, 0xAA, 0x55, 0xAA, 0x55], [0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x01, 0x02, 0x03, 0x04, 0x05],
|
||||
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
|
||||
),
|
||||
(
|
||||
[0x10, 0x20, 0x30, 0x40, 0x50],
|
||||
[0x05, 0x06, 0x07, 0x08, 0x09],
|
||||
),
|
||||
(
|
||||
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
|
||||
[0x12, 0x34, 0x56, 0x78, 0x9A],
|
||||
),
|
||||
(
|
||||
[0x55, 0xAA, 0x55, 0xAA, 0x55],
|
||||
[0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
),
|
||||
];
|
||||
|
||||
let mut any_cracked = false;
|
||||
|
||||
+136
-6
@@ -143,9 +143,15 @@ fn scan_options_with_keydb_pathbuf() {
|
||||
|
||||
// ── detect_format integration tests ───────────────────────────────────────
|
||||
|
||||
use libfreemkv::{Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream};
|
||||
use libfreemkv::{
|
||||
Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
|
||||
};
|
||||
|
||||
fn title_with_video(codec: Codec, resolution: &str, content_format: ContentFormat) -> DiscTitle {
|
||||
fn title_with_video(
|
||||
codec: Codec,
|
||||
resolution: Resolution,
|
||||
content_format: ContentFormat,
|
||||
) -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: "00800.mpls".into(),
|
||||
playlist_id: 800,
|
||||
@@ -155,8 +161,8 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma
|
||||
streams: vec![Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec,
|
||||
resolution: resolution.into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -165,6 +171,7 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,13 +198,13 @@ fn disc_title_duration_display_edge_cases() {
|
||||
|
||||
#[test]
|
||||
fn content_format_default_bdts() {
|
||||
let t = title_with_video(Codec::H264, "1080p", ContentFormat::BdTs);
|
||||
let t = title_with_video(Codec::H264, Resolution::R1080p, ContentFormat::BdTs);
|
||||
assert_eq!(t.content_format, ContentFormat::BdTs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_format_dvd_mpegps() {
|
||||
let t = title_with_video(Codec::Mpeg2, "480i", ContentFormat::MpegPs);
|
||||
let t = title_with_video(Codec::Mpeg2, Resolution::R480i, ContentFormat::MpegPs);
|
||||
assert_eq!(t.content_format, ContentFormat::MpegPs);
|
||||
}
|
||||
|
||||
@@ -368,6 +375,129 @@ fn resolve_encryption_no_aacs_dir() {
|
||||
assert!(disc.aacs.is_none(), "aacs should be None without /AACS dir");
|
||||
}
|
||||
|
||||
// ── Batch count arithmetic tests ──────────────────────────────────────────
|
||||
// Regression tests for the u16 truncation bug: when (remaining as u16) was
|
||||
// used instead of remaining.min(batch as u32) as u16, any remaining count
|
||||
// that was a multiple of 65536 would truncate to 0, causing an infinite loop.
|
||||
|
||||
/// Simulates the fixed batch count calculation from pipe.rs / drive.rs
|
||||
fn safe_batch_count(remaining: u32, batch_sectors: u16) -> u16 {
|
||||
remaining.min(batch_sectors as u32) as u16
|
||||
}
|
||||
|
||||
/// Simulates the BUGGY calculation that caused the infinite loop
|
||||
fn buggy_batch_count(remaining: u32, batch_sectors: u16) -> u16 {
|
||||
(remaining as u16).min(batch_sectors)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_normal() {
|
||||
// Normal case: remaining > batch_sectors
|
||||
assert_eq!(safe_batch_count(1000, 60), 60);
|
||||
assert_eq!(safe_batch_count(47533152, 60), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_last_batch() {
|
||||
// Last batch: remaining < batch_sectors
|
||||
assert_eq!(safe_batch_count(30, 60), 30);
|
||||
assert_eq!(safe_batch_count(1, 60), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_exact_boundary() {
|
||||
// Exact boundary: remaining == batch_sectors
|
||||
assert_eq!(safe_batch_count(60, 60), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_u16_overflow_regression() {
|
||||
// THE BUG: remaining is a multiple of 65536 → truncates to 0
|
||||
// 47513600 = 725 * 65536, lower 16 bits = 0
|
||||
let remaining: u32 = 47533152 - 19552; // = 47513600
|
||||
assert_eq!(remaining, 47513600);
|
||||
assert_eq!(
|
||||
remaining % 65536,
|
||||
0,
|
||||
"remaining should be multiple of 65536"
|
||||
);
|
||||
|
||||
// Buggy version produces 0 → infinite loop
|
||||
assert_eq!(buggy_batch_count(remaining, 60), 0);
|
||||
|
||||
// Fixed version produces 60
|
||||
assert_eq!(safe_batch_count(remaining, 60), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_other_u16_overflow_values() {
|
||||
// Other multiples of 65536
|
||||
assert_eq!(safe_batch_count(65536, 60), 60);
|
||||
assert_eq!(safe_batch_count(131072, 60), 60);
|
||||
assert_eq!(safe_batch_count(65536 * 100, 60), 60);
|
||||
|
||||
// Verify buggy version fails on all of these
|
||||
assert_eq!(buggy_batch_count(65536, 60), 0);
|
||||
assert_eq!(buggy_batch_count(131072, 60), 0);
|
||||
assert_eq!(buggy_batch_count(65536 * 100, 60), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_near_u16_boundary() {
|
||||
// Values just below and above 65536
|
||||
assert_eq!(safe_batch_count(65535, 60), 60);
|
||||
assert_eq!(safe_batch_count(65536, 60), 60);
|
||||
assert_eq!(safe_batch_count(65537, 60), 60);
|
||||
|
||||
// Buggy: 65535 as u16 = 65535, min(60) = 60 (OK by accident)
|
||||
assert_eq!(buggy_batch_count(65535, 60), 60);
|
||||
// Buggy: 65536 as u16 = 0, min(60) = 0 (BUG)
|
||||
assert_eq!(buggy_batch_count(65536, 60), 0);
|
||||
// Buggy: 65537 as u16 = 1, min(60) = 1 (wrong but doesn't loop)
|
||||
assert_eq!(buggy_batch_count(65537, 60), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_real_disc_sizes() {
|
||||
let batch: u16 = 60;
|
||||
|
||||
// DVD-5: ~2,295,104 sectors
|
||||
assert_eq!(safe_batch_count(2295104, batch), 60);
|
||||
|
||||
// BD-25: ~12,219,392 sectors
|
||||
assert_eq!(safe_batch_count(12219392, batch), 60);
|
||||
|
||||
// BD-50: ~24,438,784 sectors
|
||||
assert_eq!(safe_batch_count(24438784, batch), 60);
|
||||
|
||||
// UHD BD-66: ~33,554,432 sectors
|
||||
assert_eq!(safe_batch_count(33554432, batch), 60);
|
||||
|
||||
// UHD BD-100: ~47,533,152 sectors
|
||||
assert_eq!(safe_batch_count(47533152, batch), 60);
|
||||
|
||||
// Last few sectors of each
|
||||
assert_eq!(safe_batch_count(52, batch), 52);
|
||||
assert_eq!(safe_batch_count(3, batch), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_zero_remaining() {
|
||||
// Zero remaining should produce 0 (loop exits before this)
|
||||
assert_eq!(safe_batch_count(0, 60), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_max_batch_sizes() {
|
||||
// Test with different batch sizes used by detect_max_batch_sectors
|
||||
for &batch in &[3u16, 6, 9, 30, 60, 120, 240, 510] {
|
||||
// Large remaining should always return batch
|
||||
assert_eq!(safe_batch_count(47533152, batch), batch);
|
||||
// Small remaining should return remaining
|
||||
assert_eq!(safe_batch_count(1, batch), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_encryption_no_keydb() {
|
||||
// A UDF image with /AACS directory but no keydb path -> aacs is None
|
||||
|
||||
+169
-324
@@ -1,8 +1,9 @@
|
||||
//! Integration tests for the IOStream pipeline.
|
||||
//! Integration tests for the PES stream pipeline.
|
||||
|
||||
use libfreemkv::mux::meta::M2tsMeta;
|
||||
use libfreemkv::pes::Stream as PesStream;
|
||||
use libfreemkv::*;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn sample_disc_title() -> DiscTitle {
|
||||
DiscTitle {
|
||||
@@ -15,8 +16,8 @@ fn sample_disc_title() -> DiscTitle {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -25,18 +26,18 @@ fn sample_disc_title() -> DiscTitle {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "7.1".into(),
|
||||
channels: AudioChannels::Surround71,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "English Atmos".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1101,
|
||||
codec: Codec::Ac3,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "fra".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "French".into(),
|
||||
}),
|
||||
@@ -51,6 +52,7 @@ fn sample_disc_title() -> DiscTitle {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,70 +115,94 @@ fn parse_url_m2ts_relative() {
|
||||
|
||||
#[test]
|
||||
fn open_input_bare_path_errors() {
|
||||
let result = libfreemkv::open_input("Dune.mkv", &libfreemkv::InputOptions::default());
|
||||
let result = libfreemkv::input("Dune.mkv", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("expected error"),
|
||||
};
|
||||
assert!(msg.contains("not a valid stream URL"), "got: {}", msg);
|
||||
assert!(
|
||||
msg.contains("not a valid stream URL") || msg.contains("E9002"),
|
||||
"got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_bare_path_errors() {
|
||||
let dt = sample_disc_title();
|
||||
let result = libfreemkv::open_output("Dune.mkv", &dt);
|
||||
let result = libfreemkv::output("Dune.mkv", &dt);
|
||||
assert!(result.is_err());
|
||||
let msg = match result {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("expected error"),
|
||||
};
|
||||
assert!(msg.contains("not a valid stream URL"), "got: {}", msg);
|
||||
assert!(
|
||||
msg.contains("not a valid stream URL") || msg.contains("E9002"),
|
||||
"got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_m2ts_empty_path_errors() {
|
||||
let result = libfreemkv::open_input("m2ts://", &libfreemkv::InputOptions::default());
|
||||
let result = libfreemkv::input("m2ts://", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("expected error"),
|
||||
};
|
||||
assert!(msg.contains("requires a file path"), "got: {}", msg);
|
||||
assert!(
|
||||
msg.contains("requires a file path") || msg.contains("E9003"),
|
||||
"got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_null_input_errors() {
|
||||
let result = libfreemkv::open_input("null://", &libfreemkv::InputOptions::default());
|
||||
let result = libfreemkv::input("null://", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("expected error"),
|
||||
};
|
||||
assert!(msg.contains("write-only"), "got: {}", msg);
|
||||
assert!(
|
||||
msg.contains("write-only") || msg.contains("E9001"),
|
||||
"got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_output_disc_errors() {
|
||||
let dt = sample_disc_title();
|
||||
let result = libfreemkv::open_output("disc://", &dt);
|
||||
let result = libfreemkv::output("disc://", &dt);
|
||||
assert!(result.is_err());
|
||||
let msg = match result {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("expected error"),
|
||||
};
|
||||
assert!(msg.contains("read-only"), "got: {}", msg);
|
||||
assert!(
|
||||
msg.contains("read-only") || msg.contains("E9000"),
|
||||
"got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_network_no_port_errors() {
|
||||
let result = libfreemkv::open_input("network://10.0.0.1", &libfreemkv::InputOptions::default());
|
||||
let result = libfreemkv::input("network://10.0.0.1", &libfreemkv::InputOptions::default());
|
||||
assert!(result.is_err());
|
||||
let msg = match result {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("expected error"),
|
||||
};
|
||||
assert!(msg.contains("missing port"), "got: {}", msg);
|
||||
assert!(
|
||||
msg.contains("PES pipeline") || msg.contains("missing port") || msg.contains("E9004"),
|
||||
"got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,7 +227,7 @@ fn m2ts_meta_roundtrip() {
|
||||
// Check video
|
||||
if let Stream::Video(v) = &restored.streams[0] {
|
||||
assert_eq!(v.codec, Codec::Hevc);
|
||||
assert_eq!(v.resolution, "2160p");
|
||||
assert_eq!(v.resolution, Resolution::R2160p);
|
||||
assert_eq!(v.label, "Main");
|
||||
} else {
|
||||
panic!("expected video");
|
||||
@@ -261,124 +287,73 @@ fn m2ts_header_write_read() {
|
||||
fn m2ts_stream_write_read() {
|
||||
let dt = sample_disc_title();
|
||||
|
||||
// Build fake BD-TS packets
|
||||
let mut ts_data = Vec::new();
|
||||
for i in 0..10u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = 0x10;
|
||||
pkt[6] = 0x11;
|
||||
pkt[7] = 0x10;
|
||||
pkt[8] = i;
|
||||
ts_data.extend_from_slice(&pkt);
|
||||
}
|
||||
|
||||
// Write through M2tsStream to a Cursor
|
||||
// Write PES frames through M2tsStream to a Cursor
|
||||
let output = Cursor::new(Vec::new());
|
||||
let mut stream = M2tsStream::new(output).meta(&dt);
|
||||
stream.write_all(&ts_data).unwrap();
|
||||
let mut stream = M2tsStream::create(output, &dt).unwrap();
|
||||
|
||||
// Write some PES frames
|
||||
for i in 0..5u8 {
|
||||
let frame = libfreemkv::pes::PesFrame {
|
||||
track: 0,
|
||||
pts: i as i64 * 1_000_000,
|
||||
keyframe: i == 0,
|
||||
data: vec![i; 100],
|
||||
};
|
||||
stream.write(&frame).unwrap();
|
||||
}
|
||||
stream.finish().unwrap();
|
||||
|
||||
// M2tsStream consumed the cursor — we need the inner data.
|
||||
// For this test, write to a shared buffer instead.
|
||||
// Use a second pass: write header + data manually to verify read side.
|
||||
let mut encoded = Vec::new();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
libfreemkv::mux::meta::write_header(&mut encoded, &meta).unwrap();
|
||||
encoded.extend_from_slice(&ts_data);
|
||||
|
||||
// Read back
|
||||
let cursor = Cursor::new(encoded);
|
||||
let mut stream = M2tsStream::open(cursor).unwrap();
|
||||
// Verify the info is correct
|
||||
let info = stream.info();
|
||||
assert_eq!(info.streams.len(), 4);
|
||||
assert_eq!(info.duration_secs, 7200.0);
|
||||
|
||||
// Read BD-TS data
|
||||
let mut read_buf = vec![0u8; 192 * 10];
|
||||
let mut total = 0;
|
||||
loop {
|
||||
match stream.read(&mut read_buf[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(total, 192 * 10);
|
||||
for i in 0..10u8 {
|
||||
assert_eq!(read_buf[i as usize * 192 + 4], 0x47);
|
||||
assert_eq!(read_buf[i as usize * 192 + 8], i);
|
||||
}
|
||||
}
|
||||
|
||||
// ── M2tsStream passthrough identity ───────────────────────────
|
||||
// ── M2tsStream PES frame roundtrip ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_passthrough_preserves_data() {
|
||||
let dt = sample_disc_title();
|
||||
fn m2ts_pes_frame_roundtrip() {
|
||||
// PesFrame serialize/deserialize roundtrip
|
||||
let frame = libfreemkv::pes::PesFrame {
|
||||
track: 2,
|
||||
pts: 1_234_567_890,
|
||||
keyframe: true,
|
||||
data: vec![0xDE; 200],
|
||||
};
|
||||
|
||||
// Create original BD-TS data
|
||||
let mut original = Vec::new();
|
||||
for i in 0..100u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = (i % 3) << 4;
|
||||
pkt[6] = i;
|
||||
for (j, byte) in pkt.iter_mut().enumerate().skip(8) {
|
||||
*byte = i.wrapping_add(j as u8);
|
||||
}
|
||||
original.extend_from_slice(&pkt);
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
frame.serialize(&mut buf).unwrap();
|
||||
|
||||
// Build encoded: header + original data
|
||||
let mut encoded = Vec::new();
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
libfreemkv::mux::meta::write_header(&mut encoded, &meta).unwrap();
|
||||
encoded.extend_from_slice(&original);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let restored = libfreemkv::pes::PesFrame::deserialize(&mut cursor)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Read back through M2tsStream
|
||||
let cursor = Cursor::new(encoded);
|
||||
let mut stream = M2tsStream::open(cursor).unwrap();
|
||||
let mut decoded = vec![0u8; original.len()];
|
||||
let mut total = 0;
|
||||
loop {
|
||||
match stream.read(&mut decoded[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// BD-TS data must be byte-identical
|
||||
assert_eq!(total, original.len());
|
||||
assert_eq!(decoded, original);
|
||||
assert_eq!(restored.track, frame.track);
|
||||
assert_eq!(restored.pts, frame.pts);
|
||||
assert_eq!(restored.keyframe, frame.keyframe);
|
||||
assert_eq!(restored.data, frame.data);
|
||||
}
|
||||
|
||||
// ── IOStream trait ────────────────────────────────────────────
|
||||
// ── PES Stream trait ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn m2ts_implements_iostream() {
|
||||
fn m2ts_implements_pes_stream() {
|
||||
let dt = sample_disc_title();
|
||||
let output = Cursor::new(Vec::new());
|
||||
let stream = M2tsStream::new(output).meta(&dt);
|
||||
let stream = M2tsStream::create(output, &dt).unwrap();
|
||||
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(stream);
|
||||
let boxed: Box<dyn PesStream> = Box::new(stream);
|
||||
let meta = boxed.info();
|
||||
assert_eq!(meta.streams.len(), 4);
|
||||
|
||||
let pkt = [0u8; 192];
|
||||
boxed.write_all(&pkt).unwrap();
|
||||
boxed.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m2ts_read_returns_error_on_write_stream() {
|
||||
let dt = sample_disc_title();
|
||||
let output = Cursor::new(Vec::new());
|
||||
let stream = M2tsStream::new(output);
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(stream);
|
||||
let mut buf = [0u8; 10];
|
||||
assert!(boxed.read(&mut buf).is_err());
|
||||
let mut stream = M2tsStream::create(output, &dt).unwrap();
|
||||
assert!(stream.read().is_err());
|
||||
}
|
||||
|
||||
// ── DiscTitle::empty ──────────────────────────────────────────
|
||||
@@ -413,8 +388,8 @@ fn meta_codec_roundtrip() {
|
||||
streams.push(Stream::Video(VideoStream {
|
||||
pid: (0x1011 + i) as u16,
|
||||
codec,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -425,9 +400,9 @@ fn meta_codec_roundtrip() {
|
||||
streams.push(Stream::Audio(AudioStream {
|
||||
pid: (0x1100 + i) as u16,
|
||||
codec,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}));
|
||||
@@ -452,6 +427,7 @@ fn meta_codec_roundtrip() {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
@@ -486,6 +462,7 @@ fn meta_empty_streams() {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
@@ -505,12 +482,13 @@ fn meta_all_stream_types() {
|
||||
clips: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
streams: vec![
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -519,9 +497,9 @@ fn meta_all_stream_types() {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "7.1".into(),
|
||||
channels: AudioChannels::Surround71,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "Primary Audio".into(),
|
||||
}),
|
||||
@@ -535,9 +513,9 @@ fn meta_all_stream_types() {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1110,
|
||||
codec: Codec::Ac3,
|
||||
channels: "stereo".into(),
|
||||
channels: AudioChannels::Stereo,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: true,
|
||||
label: "Commentary".into(),
|
||||
}),
|
||||
@@ -553,7 +531,7 @@ fn meta_all_stream_types() {
|
||||
// Video preserved
|
||||
if let Stream::Video(v) = &restored.streams[0] {
|
||||
assert_eq!(v.codec, Codec::Hevc);
|
||||
assert_eq!(v.resolution, "2160p");
|
||||
assert_eq!(v.resolution, Resolution::R2160p);
|
||||
assert_eq!(v.label, "Primary");
|
||||
assert!(!v.secondary);
|
||||
} else {
|
||||
@@ -563,7 +541,7 @@ fn meta_all_stream_types() {
|
||||
// Primary audio preserved
|
||||
if let Stream::Audio(a) = &restored.streams[1] {
|
||||
assert_eq!(a.codec, Codec::TrueHd);
|
||||
assert_eq!(a.channels, "7.1");
|
||||
assert_eq!(a.channels, AudioChannels::Surround71);
|
||||
assert!(!a.secondary);
|
||||
} else {
|
||||
panic!("expected audio");
|
||||
@@ -591,21 +569,20 @@ fn meta_all_stream_types() {
|
||||
|
||||
#[test]
|
||||
fn mkvstream_write_finish() {
|
||||
let output = Cursor::new(Vec::new());
|
||||
let dt = sample_disc_title();
|
||||
let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024);
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(Cursor::new(Vec::new()));
|
||||
let mut stream = MkvStream::create(writer, &dt).unwrap();
|
||||
|
||||
// Write some fake BD-TS packets (they won't produce valid MKV frames
|
||||
// Write some fake PES frames (they won't produce valid MKV content
|
||||
// since there is no real codec data, but it should not panic)
|
||||
for i in 0..20u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
// PID 0x1011 (video)
|
||||
pkt[5] = 0x10;
|
||||
pkt[6] = 0x11;
|
||||
pkt[7] = 0x10;
|
||||
pkt[8] = i;
|
||||
stream.write_all(&pkt).unwrap();
|
||||
let frame = libfreemkv::pes::PesFrame {
|
||||
track: 0,
|
||||
pts: i as i64 * 1_000_000,
|
||||
keyframe: i == 0,
|
||||
data: vec![i; 100],
|
||||
};
|
||||
stream.write(&frame).unwrap();
|
||||
}
|
||||
|
||||
// finish should not panic even without valid codec data
|
||||
@@ -614,9 +591,9 @@ fn mkvstream_write_finish() {
|
||||
|
||||
#[test]
|
||||
fn mkvstream_meta_sets_title() {
|
||||
let output = Cursor::new(Vec::new());
|
||||
let dt = sample_disc_title();
|
||||
let stream = MkvStream::new(output).meta(&dt);
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(Cursor::new(Vec::new()));
|
||||
let stream = MkvStream::create(writer, &dt).unwrap();
|
||||
|
||||
let info = stream.info();
|
||||
assert_eq!(info.playlist, "Test Movie");
|
||||
@@ -642,30 +619,30 @@ fn mkvstream_roundtrip_bdts() {
|
||||
streams: vec![Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::Ac3,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "English".into(),
|
||||
})],
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let output = Cursor::new(Vec::new());
|
||||
let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024);
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(Cursor::new(Vec::new()));
|
||||
let mut stream = MkvStream::create(writer, &dt).unwrap();
|
||||
|
||||
// Write BD-TS packets targeting the audio PID 0x1100
|
||||
// Write PES frames targeting the audio track
|
||||
for i in 0..10u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
// PID 0x1100
|
||||
pkt[5] = 0x11;
|
||||
pkt[6] = 0x00;
|
||||
pkt[7] = 0x10;
|
||||
pkt[8] = i;
|
||||
stream.write_all(&pkt).unwrap();
|
||||
let frame = libfreemkv::pes::PesFrame {
|
||||
track: 0,
|
||||
pts: i as i64 * 1_000_000,
|
||||
keyframe: true,
|
||||
data: vec![i; 100],
|
||||
};
|
||||
stream.write(&frame).unwrap();
|
||||
}
|
||||
|
||||
stream.finish().unwrap();
|
||||
@@ -688,8 +665,8 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::H264,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -698,18 +675,18 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::Ac3,
|
||||
channels: "5.1".into(),
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "English".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1101,
|
||||
codec: Codec::DtsHdMa,
|
||||
channels: "7.1".into(),
|
||||
channels: AudioChannels::Surround71,
|
||||
language: "fra".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
label: "French".into(),
|
||||
}),
|
||||
@@ -731,10 +708,11 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let output = Cursor::new(Vec::new());
|
||||
let stream = MkvStream::new(output).meta(&dt);
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(Cursor::new(Vec::new()));
|
||||
let stream = MkvStream::create(writer, &dt).unwrap();
|
||||
|
||||
let info = stream.info();
|
||||
assert_eq!(info.streams.len(), 5, "all 5 streams should be preserved");
|
||||
@@ -781,8 +759,8 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
streams: vec![Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::H264,
|
||||
resolution: "1080p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
@@ -791,19 +769,15 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
// Build synthetic BD-TS packets containing valid H.264 NALs
|
||||
// We need PES headers wrapping: SPS (NAL type 7), PPS (NAL type 8), IDR (NAL type 5)
|
||||
let mut ts_data = Vec::new();
|
||||
|
||||
// Build elementary stream data: start codes + NALs
|
||||
let mut es_data = Vec::new();
|
||||
|
||||
// SPS NAL (type 7): minimal valid SPS
|
||||
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
|
||||
es_data.push(0x67); // NAL type 7 (SPS), nal_ref_idc=3
|
||||
// Minimal SPS payload: profile_idc=66 (Baseline), constraint flags, level_idc=30
|
||||
es_data.extend_from_slice(&[
|
||||
0x42, 0xC0, 0x1E, // profile=66, constraint_set0=1, level=30
|
||||
0xD9, 0x00, 0xA0, 0x47, 0xFE, 0x88, // minimal SPS rbsp
|
||||
@@ -817,124 +791,16 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
// IDR NAL (type 5): keyframe
|
||||
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
|
||||
es_data.push(0x65); // NAL type 5 (IDR), nal_ref_idc=3
|
||||
// Some IDR slice data
|
||||
es_data.extend_from_slice(&[0x88, 0x84, 0x00, 0x21, 0xFF, 0xFE, 0xF6, 0xE2]);
|
||||
// Pad to reasonable size
|
||||
es_data.extend_from_slice(&[0x00; 64]);
|
||||
|
||||
// Wrap in PES header with PTS
|
||||
let pts: i64 = 90000; // 1 second in 90kHz ticks
|
||||
let pts_bytes = encode_pts_test(pts);
|
||||
let pes_header_len = 9 + 5; // basic PES header (9) + PTS (5)
|
||||
let pes_length = (3 + 5 + es_data.len()) as u16; // flags(3) + PTS(5) + ES data
|
||||
|
||||
let mut pes = Vec::new();
|
||||
pes.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]); // PES start code + video stream_id
|
||||
pes.extend_from_slice(&pes_length.to_be_bytes()); // PES packet length
|
||||
pes.extend_from_slice(&[0x80, 0x80, 0x05]); // flags: PTS present, header_data_len=5
|
||||
pes.extend_from_slice(&pts_bytes);
|
||||
pes.extend_from_slice(&es_data);
|
||||
|
||||
// Wrap PES in 192-byte BD-TS packets
|
||||
let pid: u16 = 0x1011;
|
||||
let mut pes_offset = 0;
|
||||
let mut pusi = true;
|
||||
let mut cc: u8 = 0;
|
||||
|
||||
while pes_offset < pes.len() {
|
||||
let mut pkt = [0u8; 192];
|
||||
// 4-byte TP_extra_header (zeros)
|
||||
pkt[4] = 0x47; // sync byte
|
||||
pkt[5] = (pid >> 8) as u8 & 0x1F;
|
||||
if pusi {
|
||||
pkt[5] |= 0x40; // PUSI
|
||||
pusi = false;
|
||||
}
|
||||
pkt[6] = pid as u8;
|
||||
pkt[7] = 0x10 | (cc & 0x0F); // payload only + continuity counter
|
||||
cc = cc.wrapping_add(1);
|
||||
|
||||
let space = 184;
|
||||
let rem = pes.len() - pes_offset;
|
||||
let n = rem.min(space);
|
||||
|
||||
if n < space {
|
||||
// Need adaptation field for padding
|
||||
let pad = space - n;
|
||||
pkt[7] = 0x30 | (cc.wrapping_sub(1) & 0x0F); // AF + payload
|
||||
pkt[8] = (pad - 1) as u8; // adaptation_field_length
|
||||
if pad > 1 {
|
||||
pkt[9] = 0x00; // flags
|
||||
}
|
||||
for byte in pkt.iter_mut().take(8 + pad).skip(10) {
|
||||
*byte = 0xFF;
|
||||
}
|
||||
pkt[8 + pad..8 + pad + n].copy_from_slice(&pes[pes_offset..pes_offset + n]);
|
||||
} else {
|
||||
pkt[8..8 + n].copy_from_slice(&pes[pes_offset..pes_offset + n]);
|
||||
}
|
||||
|
||||
ts_data.extend_from_slice(&pkt);
|
||||
pes_offset += n;
|
||||
}
|
||||
|
||||
// Build a second PES (access unit) to trigger the first PES to be output
|
||||
// by the TS demuxer (it needs a new PUSI to emit the previous PES).
|
||||
let pts2: i64 = 90000 + 3753; // ~1 frame later
|
||||
let pts2_bytes = encode_pts_test(pts2);
|
||||
// Non-IDR slice data (NAL type 1)
|
||||
let mut es_data2 = Vec::new();
|
||||
// Just a non-IDR slice (NAL type 1)
|
||||
es_data2.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
es_data2.push(0x41); // NAL type 1 (non-IDR)
|
||||
es_data2.extend_from_slice(&[0x9A, 0x00, 0x10, 0x20]);
|
||||
es_data2.extend_from_slice(&[0x00; 32]);
|
||||
|
||||
let pes2_length = (3 + 5 + es_data2.len()) as u16;
|
||||
let mut pes2 = Vec::new();
|
||||
pes2.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]);
|
||||
pes2.extend_from_slice(&pes2_length.to_be_bytes());
|
||||
pes2.extend_from_slice(&[0x80, 0x80, 0x05]);
|
||||
pes2.extend_from_slice(&pts2_bytes);
|
||||
pes2.extend_from_slice(&es_data2);
|
||||
|
||||
// Wrap second PES in BD-TS packets
|
||||
let mut pes2_offset = 0;
|
||||
let mut pusi2 = true;
|
||||
while pes2_offset < pes2.len() {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
pkt[5] = (pid >> 8) as u8 & 0x1F;
|
||||
if pusi2 {
|
||||
pkt[5] |= 0x40;
|
||||
pusi2 = false;
|
||||
}
|
||||
pkt[6] = pid as u8;
|
||||
pkt[7] = 0x10 | (cc & 0x0F);
|
||||
cc = cc.wrapping_add(1);
|
||||
|
||||
let space = 184;
|
||||
let rem = pes2.len() - pes2_offset;
|
||||
let n = rem.min(space);
|
||||
|
||||
if n < space {
|
||||
let pad = space - n;
|
||||
pkt[7] = 0x30 | (cc.wrapping_sub(1) & 0x0F);
|
||||
pkt[8] = (pad - 1) as u8;
|
||||
if pad > 1 {
|
||||
pkt[9] = 0x00;
|
||||
}
|
||||
for byte in pkt.iter_mut().take(8 + pad).skip(10) {
|
||||
*byte = 0xFF;
|
||||
}
|
||||
pkt[8 + pad..8 + pad + n].copy_from_slice(&pes2[pes2_offset..pes2_offset + n]);
|
||||
} else {
|
||||
pkt[8..8 + n].copy_from_slice(&pes2[pes2_offset..pes2_offset + n]);
|
||||
}
|
||||
|
||||
ts_data.extend_from_slice(&pkt);
|
||||
pes2_offset += n;
|
||||
}
|
||||
|
||||
// Feed through MkvStream using a shared writer to inspect the output bytes.
|
||||
let output2 = std::sync::Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
|
||||
|
||||
@@ -953,15 +819,36 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
}
|
||||
}
|
||||
|
||||
let writer = SharedWriter(output2.clone());
|
||||
let mut stream2 = MkvStream::new(writer).meta(&dt).max_buffer(1024 * 1024);
|
||||
stream2.write_all(&ts_data).unwrap();
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek> = Box::new(SharedWriter(output2.clone()));
|
||||
let mut stream2 = MkvStream::create(writer, &dt).unwrap();
|
||||
|
||||
// Write the ES data (SPS+PPS+IDR) as a keyframe PES frame.
|
||||
let frame1 = libfreemkv::pes::PesFrame {
|
||||
track: 0,
|
||||
pts: 1_000_000_000, // 1 second in ns
|
||||
keyframe: true,
|
||||
data: es_data,
|
||||
};
|
||||
stream2.write(&frame1).unwrap();
|
||||
|
||||
// Write a second non-IDR frame
|
||||
let frame2 = libfreemkv::pes::PesFrame {
|
||||
track: 0,
|
||||
pts: 1_041_700_000, // ~1 frame later in ns
|
||||
keyframe: false,
|
||||
data: es_data2,
|
||||
};
|
||||
stream2.write(&frame2).unwrap();
|
||||
stream2.finish().unwrap();
|
||||
|
||||
let data = output2.lock().unwrap().clone().into_inner();
|
||||
|
||||
// Verify output starts with EBML magic (0x1A45DFA3)
|
||||
assert!(data.len() >= 4, "MKV output too small: {} bytes", data.len());
|
||||
assert!(
|
||||
data.len() >= 4,
|
||||
"MKV output too small: {} bytes",
|
||||
data.len()
|
||||
);
|
||||
assert_eq!(
|
||||
&data[0..4],
|
||||
&[0x1A, 0x45, 0xDF, 0xA3],
|
||||
@@ -970,52 +857,10 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
|
||||
// Verify output contains a Tracks element (0x1654AE6B)
|
||||
let tracks_needle = [0x16, 0x54, 0xAE, 0x6B];
|
||||
let has_tracks = data
|
||||
.windows(4)
|
||||
.any(|w| w == tracks_needle);
|
||||
let has_tracks = data.windows(4).any(|w| w == tracks_needle);
|
||||
assert!(has_tracks, "output should contain Tracks element");
|
||||
|
||||
// Verify codecPrivate is non-empty (not all zeros)
|
||||
// CodecPrivate element ID is 0x63A2
|
||||
let cp_needle = [0x63, 0xA2];
|
||||
let cp_pos = data
|
||||
.windows(2)
|
||||
.position(|w| w == cp_needle);
|
||||
if let Some(pos) = cp_pos {
|
||||
// After the ID, there's a size VINT, then the data
|
||||
let after_id = pos + 2;
|
||||
if after_id < data.len() {
|
||||
// Read VINT size
|
||||
let size_byte = data[after_id];
|
||||
let (cp_size, cp_data_start) = if size_byte & 0x80 != 0 {
|
||||
((size_byte & 0x7F) as usize, after_id + 1)
|
||||
} else if size_byte & 0x40 != 0 && after_id + 1 < data.len() {
|
||||
(
|
||||
(((size_byte & 0x3F) as usize) << 8) | data[after_id + 1] as usize,
|
||||
after_id + 2,
|
||||
)
|
||||
} else {
|
||||
(0, after_id + 1)
|
||||
};
|
||||
if cp_size > 0 && cp_data_start + cp_size <= data.len() {
|
||||
let cp_data = &data[cp_data_start..cp_data_start + cp_size];
|
||||
let all_zeros = cp_data.iter().all(|&b| b == 0);
|
||||
assert!(
|
||||
!all_zeros,
|
||||
"codecPrivate should not be all zeros (SPS/PPS should be filled)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_pts_test(pts: i64) -> [u8; 5] {
|
||||
let p = pts as u64;
|
||||
[
|
||||
0x21 | ((p >> 29) & 0x0E) as u8,
|
||||
((p >> 22) & 0xFF) as u8,
|
||||
0x01 | ((p >> 14) & 0xFE) as u8,
|
||||
((p >> 7) & 0xFF) as u8,
|
||||
0x01 | ((p << 1) & 0xFE) as u8,
|
||||
]
|
||||
// Note: With the PES stream API, codec_privates are populated from the
|
||||
// DiscTitle at creation time, not extracted from ES data during write.
|
||||
// This test verifies the muxer produces valid EBML structure.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user