diff --git a/CHANGELOG.md b/CHANGELOG.md index 517097a..d6c6af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## 0.10.1 (2026-04-15) + +### Architecture: streams are PES, disc.copy() for sector dumps +- **One stream per format, bidirectional PES** — MkvStream, M2tsStream, NetworkStream, StdioStream, NullStream each handle read and write +- **IsoStream merged into DiscStream** — one type for physical drives and ISO files, different SectorReader +- **Disc::copy()** — raw sector dump for disc→ISO, not a stream operation +- **IOStream deleted** — no more byte-level Read/Write on streams +- **ContentReader/OpenDisc deleted** — replaced by DiscStream + PES pipeline +- **CountingStream** — wrapper for progress tracking, no state in streams + +### Error codes only — zero English in library +- All `io::Error::new(kind, "english")` replaced with `Error` enum variants +- New error variants: StreamReadOnly, StreamWriteOnly, StreamUrlInvalid, MkvInvalid, NoStreams, etc. +- `From 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 diff --git a/Cargo.toml b/Cargo.toml index a99851b..204af59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.10.0" +version = "0.10.1" edition = "2021" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index 674755a..4fa2787 100644 --- a/README.md +++ b/README.md @@ -18,33 +18,37 @@ Part of the [freemkv](https://github.com/freemkv) project. ```toml [dependencies] -libfreemkv = "0.6" +libfreemkv = "0.10" ``` ## Quick Start ```rust -use libfreemkv::{DriveSession, Disc, ScanOptions}; +use libfreemkv::{Drive, Disc, ScanOptions}; use std::path::Path; // Open drive — profiles are bundled, auto-identified -let mut session = DriveSession::open(Path::new("/dev/sr0"))?; -session.wait_ready()?; // wait for disc -session.init()?; // unlock + firmware upload -session.probe_disc()?; // probe disc surface for optimal speeds +let mut drive = Drive::open(Path::new("/dev/sg4"))?; +drive.wait_ready()?; // wait for disc +drive.init()?; // unlock + firmware upload +drive.probe_disc()?; // probe disc surface for optimal speeds // Scan disc — UDF, playlists, streams, AACS (all automatic) -let disc = Disc::scan(&mut session, &ScanOptions::default())?; +let disc = Disc::scan(&mut drive, &ScanOptions::default())?; for title in &disc.titles { println!("{} — {} streams", title.duration_display(), title.streams.len()); } -// Read content (decrypted transparently if AACS keys available) -let mut reader = disc.open_title(&mut session, 0)?; -while let Some(unit) = reader.read_unit()? { - // 6144 bytes of content per aligned unit +// Stream pipeline — read PES frames from any source, write to any output +let opts = libfreemkv::InputOptions::default(); +let mut input = libfreemkv::input("iso://Disc.iso", &opts)?; +let title = input.info().clone(); +let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?; +while let Ok(Some(frame)) = input.read() { + output.write(&frame)?; } +output.finish()?; ``` ## What It Does @@ -63,32 +67,44 @@ while let Some(unit) = reader.read_unit()? { | Stream | Input | Output | Transport | |--------|-------|--------|-----------| | DiscStream | Yes | -- | Optical drive via SCSI | -| IsoStream | Yes | -- | Blu-ray ISO image file | +| IsoStream | Yes | Yes | Blu-ray ISO image file | | MkvStream | Yes | Yes | Matroska container | | M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header | | NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header | | StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe | | NullStream | -- | Yes | Discard sink (byte counter for benchmarks) | -All streams implement the `IOStream` trait. `open_input()` and `open_output()` resolve URL strings to stream instances. All URLs use the `scheme://path` format — bare paths are rejected. +Streams implement `IOStream` (byte-level) and `pes::Stream` (frame-level). `input()` / `output()` resolve URL strings to PES stream instances. `open_input()` / `open_output()` resolve to byte-level IOStream instances. All URLs use the `scheme://path` format — bare paths are rejected. AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption. ## Architecture ```text -DriveSession — open any drive, identify, init (optional), read sectors - ├── ScsiTransport — SG_IO (Linux), IOKit (macOS) +Drive — open, identify, init, unlock, read (with recovery) + ├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows) ├── DriveProfile — per-drive unlock parameters (bundled) └── PlatformDriver — MediaTek (supported), Renesas (planned) -Disc — scan titles, streams, AACS state +Disc — scan titles, streams, AACS/CSS state ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions ├── MPLS parser — playlists → titles + clips + streams ├── CLPI parser — clip info → EP map → sector extents + ├── IFO parser — DVD title sets, PGC chains, cell addresses ├── Labels — 5 BD-J format parsers (detect + parse) ├── AACS — key resolution + content decryption + ├── CSS — DVD CSS cipher (table-driven, no keys needed) └── KEYDB — download + verify + save + +Streams — unified PES pipeline + ├── pes::Stream — read()/write() PES frames + ├── DiscStream — sectors → decrypt → TS demux → PES + ├── IsoStream — ISO file → decrypt → TS demux → PES + ├── MkvStream — MKV mux/demux + ├── M2tsStream — BD transport stream + ├── NetworkStream — TCP with FMKV metadata header + ├── StdioStream — stdin/stdout pipe + └── NullStream — discard sink ``` See [docs/](docs/) for detailed technical documentation on each module. diff --git a/docs/README.md b/docs/README.md index 0fe2ee6..fcfc3d8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/aacs.md b/docs/aacs.md index b56eb4a..3f27f9d 100644 --- a/docs/aacs.md +++ b/docs/aacs.md @@ -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 { diff --git a/docs/api-design.md b/docs/api-design.md index d453d67..fb976e4 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -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 +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 diff --git a/docs/architecture.md b/docs/architecture.md index 674690c..32226fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/disc-to-rip.md b/docs/disc-to-rip.md index fbc9628..cb2f55e 100644 --- a/docs/disc-to-rip.md +++ b/docs/disc-to-rip.md @@ -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 | diff --git a/docs/drive-access.md b/docs/drive-access.md index 4a71cc8..1fe7719 100644 --- a/docs/drive-access.md +++ b/docs/drive-access.md @@ -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). diff --git a/docs/mkv-muxer-plan.md b/docs/mkv-muxer-plan.md deleted file mode 100644 index 32f555c..0000000 --- a/docs/mkv-muxer-plan.md +++ /dev/null @@ -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) diff --git a/src/decrypt.rs b/src/decrypt.rs index 7398504..f8e3b2c 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -54,13 +54,7 @@ pub fn decrypt_sectors( let uk = match unit_keys.get(unit_key_idx) { Some((_, k)) => *k, None => { - return Err(crate::error::Error::DecryptFailed { - reason: format!( - "unit_key_idx {} out of range (have {} keys)", - unit_key_idx, - unit_keys.len() - ), - }); + return Err(crate::error::Error::DecryptFailed); } }; let rdk = read_data_key.as_ref(); diff --git a/src/disc/mod.rs b/src/disc/mod.rs index fec1886..fdbc24b 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -15,7 +15,6 @@ mod encrypt; use crate::drive::Drive; use crate::error::{Error, Result}; use crate::sector::SectorReader; -use crate::speed::DriveSpeed; use crate::udf; use encrypt::HandshakeResult; @@ -918,73 +917,6 @@ impl ScanOptions { } } -/// A disc with an active drive session -- the main API. -/// -/// Owns both the disc metadata and the drive connection. -/// Created by `Disc::open()`. Provides `rip()` to read title data. -pub struct OpenDisc { - pub disc: Disc, - pub session: Drive, -} - -impl OpenDisc { - /// Open a drive, wait for disc, initialize, probe, and scan. - /// This is the single entry point -- one call does everything. - /// - pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> { - use std::path::Path; - - let mut session = Drive::open(Path::new(device))?; - session.wait_ready()?; - - // Init (unlock + firmware) -- non-fatal if fails - let _ = session.init(); - let _ = session.probe_disc(); - - let opts = if let Some(kp) = keydb_path { - ScanOptions::with_keydb(kp) - } else { - ScanOptions::default() - }; - - let disc = Disc::scan(&mut session, &opts)?; - Ok(Self { disc, session }) - } - - /// Rip a title to any output stream. - /// - /// Reads sectors from disc, decrypts AACS, handles errors/retries, - /// and writes decrypted BD-TS bytes to the output. - /// Knows nothing about the output format -- just calls `write_all()`. - /// - pub fn rip(&mut self, title_idx: usize, mut output: impl std::io::Write) -> Result<()> { - let mut reader = self.disc.open_title(&mut self.session, title_idx)?; - - loop { - match reader.read_batch() { - Ok(Some(batch)) => { - output.write_all(batch).map_err(|_| Error::WriteError)?; - } - Ok(None) => break, - Err(_) => { - // ContentReader handles retries internally - } - } - } - - Ok(()) - } - - /// Total bytes for a title (for progress tracking). - pub fn title_size(&self, title_idx: usize) -> u64 { - self.disc - .titles - .get(title_idx) - .map(|t| t.size_bytes) - .unwrap_or(0) - } -} - impl Disc { /// Disc capacity in GB pub fn capacity_gb(&self) -> f64 { @@ -1148,40 +1080,6 @@ impl Disc { } } -// ─── Decrypted reader ────────────────────────────────────────────────────── - -/// A reader that reads m2ts content, decrypting transparently if needed. -/// -/// Adaptive read strategy: -/// - Starts at max batch size (510 sectors ≈ 1MB) and full disc speed -/// - On read error: halves batch size, brief pause for drive recovery -/// - On repeated errors: reduces disc spin speed (scratched region) -/// - On success streak: ramps batch back up, then restores disc speed -/// - At minimum batch + still failing: retries once, then skips + zero-fills -pub struct ContentReader<'a> { - session: &'a mut Drive, - decrypt_keys: crate::decrypt::DecryptKeys, - extents: Vec<Extent>, - current_extent: usize, - current_offset: u32, - unit_key_idx: usize, - read_buf: Vec<u8>, - buf_pos: usize, - buf_len: usize, - /// Current batch size in sectors (adapts on errors) - batch_sectors: u16, - /// Maximum batch size detected from kernel limits - max_batch_sectors: u16, - /// Consecutive successful batch reads - ok_streak: u32, - /// Consecutive errors at current position - error_streak: u32, - /// Current speed tier index (0 = max, higher = slower) - /// Last time maintain_speed was called - /// Total read errors encountered - pub errors: u32, -} - impl Disc { /// Get the resolved decryption keys for this disc. /// Used by disc-to-ISO and other full-disc operations. @@ -1200,59 +1098,99 @@ impl Disc { } } - /// Open a title for reading. Decryption is automatic -- if the disc - /// is encrypted and keys were found during scan(), content is decrypted - /// on the fly. Unencrypted discs pass through unchanged. + /// Raw sector copy — write the entire disc image to a file. /// - pub fn open_title<'a>( - &'a self, - session: &'a mut Drive, - title_idx: usize, - ) -> Result<ContentReader<'a>> { - let title = self.titles.get(title_idx).ok_or(Error::DiscTitleRange { - index: title_idx, - count: self.titles.len(), - })?; + /// This is NOT a stream operation. It copies sectors 0→capacity byte-for-byte, + /// producing a valid ISO/UDF image. The disc's filesystem structure is preserved. + /// + /// If `decrypt` is true and keys are available, sectors are decrypted on the fly. + /// If `resume` is true and the file already exists, resumes from the last safe position. + /// + /// `on_progress` is called periodically with (bytes_done, total_bytes). + pub fn copy( + &self, + reader: &mut dyn SectorReader, + path: &std::path::Path, + decrypt: bool, + resume: bool, + on_progress: Option<&dyn Fn(u64, u64)>, + ) -> Result<()> { + use std::io::{Seek, SeekFrom, Write}; - // Let the drive manage its own read speed after init. - // SET_CD_SPEED is only used reactively by the error handler to slow - // down on read errors, then let the drive recover. + let total_bytes = self.capacity_sectors as u64 * 2048; + let keys = if decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None }; - // Detect kernel max transfer size for this device - let max_batch = detect_max_batch_sectors(session.device_path()); - - let decrypt_keys = if let Some(ref aacs) = self.aacs { - crate::decrypt::DecryptKeys::Aacs { - unit_keys: aacs.unit_keys.clone(), - read_data_key: aacs.read_data_key, - } - } else if let Some(ref css) = self.css { - crate::decrypt::DecryptKeys::Css { - title_key: css.title_key, + // Resume: check existing file + let (start_lba, file) = if resume { + match std::fs::metadata(path) { + Ok(meta) if meta.len() > 0 => { + let safe_sectors = (meta.len() / 2048).saturating_sub(5) as u32; + let mut f = std::fs::OpenOptions::new() + .write(true) + .open(path) + .map_err(|e| Error::IoError { source: e })?; + let resume_pos = safe_sectors as u64 * 2048; + f.set_len(resume_pos) + .map_err(|e| Error::IoError { source: e })?; + f.seek(SeekFrom::End(0)) + .map_err(|e| Error::IoError { source: e })?; + (safe_sectors, f) + } + _ => { + let f = std::fs::File::create(path) + .map_err(|e| Error::IoError { source: e })?; + (0u32, f) + } } } else { - crate::decrypt::DecryptKeys::None + let f = std::fs::File::create(path) + .map_err(|e| Error::IoError { source: e })?; + (0u32, f) }; - Ok(ContentReader { - session, - decrypt_keys, - extents: title.extents.clone(), - current_extent: 0, - current_offset: 0, - unit_key_idx: 0, - read_buf: Vec::with_capacity(max_batch as usize * 2048), - buf_pos: 0, - buf_len: 0, - batch_sectors: max_batch, - max_batch_sectors: max_batch, - ok_streak: 0, - error_streak: 0, - errors: 0, - }) + let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file); + let batch: u16 = 64; // 128 KB per read + let mut lba = start_lba; + let mut bytes_done = start_lba as u64 * 2048; + let mut buf = vec![0u8; batch as usize * 2048]; + + while lba < self.capacity_sectors { + let remaining = self.capacity_sectors - lba; + let count = remaining.min(batch as u32) as u16; + let bytes = count as usize * 2048; + + reader + .read_sectors(lba, count, &mut buf[..bytes]) + .map_err(|e| Error::IoError { + source: std::io::Error::other(e.to_string()), + })?; + + // Decrypt if requested + if decrypt { + crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?; + } + + writer + .write_all(&buf[..bytes]) + .map_err(|e| Error::IoError { source: e })?; + + lba += count as u32; + bytes_done += bytes as u64; + + if let Some(ref cb) = on_progress { + cb(bytes_done, total_bytes); + } + } + + writer.flush().map_err(|e| Error::IoError { source: e })?; + Ok(()) } } +const MAX_BATCH_SECTORS: u16 = 510; +const DEFAULT_BATCH_SECTORS: u16 = 60; +const MIN_BATCH_SECTORS: u16 = 3; + /// Detect the maximum transfer size in sectors for a device. /// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux. /// For sg devices, resolves the corresponding block device via sysfs. @@ -1293,190 +1231,6 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 { DEFAULT_BATCH_SECTORS } -/// Read strategy constants -pub(crate) const MAX_BATCH_SECTORS: u16 = 510; // absolute max (170 aligned units ≈ 1MB) -pub(crate) const DEFAULT_BATCH_SECTORS: u16 = 60; // fallback: typical kernel limit (120KB = 60 sectors) -pub(crate) const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (error recovery) -pub(crate) const RAMP_BATCH_AFTER: u32 = 5; // successes before doubling batch size -pub(crate) const RAMP_SPEED_AFTER: u32 = 50; // successes at max batch before restoring speed -pub(crate) const SLOW_SPEED_AFTER: u32 = 3; // consecutive errors before reducing disc speed - -impl<'a> ContentReader<'a> { - /// Total bytes across all extents (for progress display). - pub fn total_bytes(&self) -> u64 { - self.extents - .iter() - .map(|e| e.sector_count as u64 * 2048) - .sum() - } - - /// Read the next aligned unit (6144 bytes). - /// Automatically decrypted if AACS keys are available. - /// Returns None when all extents are exhausted. - pub fn read_unit(&mut self) -> Result<Option<Vec<u8>>> { - // Refill buffer if empty - if self.buf_pos >= self.buf_len && !self.fill_buffer()? { - return Ok(None); - } - - // Extract one aligned unit from buffer - let start = self.buf_pos * crate::aacs::ALIGNED_UNIT_LEN; - let end = start + crate::aacs::ALIGNED_UNIT_LEN; - let mut unit = self.read_buf[start..end].to_vec(); - - // Decrypt if needed - self.decrypt_unit(&mut unit)?; - self.buf_pos += 1; - Ok(Some(unit)) - } - - /// Read the next batch of aligned units, decrypted in-place. - /// Returns the decrypted data as a single contiguous slice. - /// More efficient than read_unit() -- one write_all() per batch instead of per unit. - /// Returns None when all extents are exhausted. - pub fn read_batch(&mut self) -> Result<Option<&[u8]>> { - if !self.fill_buffer()? { - return Ok(None); - } - - // Decrypt all units in the buffer in-place - let unit_len = crate::aacs::ALIGNED_UNIT_LEN; - let total_bytes = self.buf_len * unit_len; - crate::decrypt::decrypt_sectors( - &mut self.read_buf[..total_bytes], - &self.decrypt_keys, - self.unit_key_idx, - )?; - self.buf_pos = self.buf_len; - Ok(Some(&self.read_buf[..total_bytes])) - } - - /// Decrypt a single aligned unit in-place if needed. - fn decrypt_unit(&self, unit: &mut [u8]) -> Result<()> { - crate::decrypt::decrypt_sectors(unit, &self.decrypt_keys, self.unit_key_idx) - } - - /// Read sectors via standard READ(10) 0x00. - /// calibration primers. Standard reads are faster on most drives. - fn read_sectors(&mut self, lba: u32, count: u16) -> Result<()> { - self.session.read(lba, count, &mut self.read_buf)?; - Ok(()) - } - - /// Read a batch of sectors into the internal buffer. - /// - /// Error handling: - /// - First error: re-init drive (may have re-locked), halve batch - /// - Repeated errors: reduce speed, keep halving batch - /// - At minimum batch: retry once, then skip + zero-fill - /// - After sustained success: ramp batch back up, restore max speed - fn fill_buffer(&mut self) -> Result<bool> { - loop { - if self.current_extent >= self.extents.len() { - return Ok(false); - } - - let ext_start = self.extents[self.current_extent].start_lba; - let ext_sectors = self.extents[self.current_extent].sector_count; - let remaining = ext_sectors.saturating_sub(self.current_offset); - - // Align to 3 sectors (one aligned unit) - let sectors_to_read = remaining.min(self.batch_sectors as u32) as u16; - let sectors_to_read = sectors_to_read - (sectors_to_read % 3); - if sectors_to_read == 0 { - self.current_extent += 1; - self.current_offset = 0; - continue; - } - - let lba = ext_start + self.current_offset; - let byte_count = sectors_to_read as usize * 2048; - self.read_buf.resize(byte_count, 0); - - match self.read_sectors(lba, sectors_to_read) { - Ok(_) => { - self.buf_len = sectors_to_read as usize / 3; - self.buf_pos = 0; - self.current_offset += sectors_to_read as u32; - self.error_streak = 0; - - if self.current_offset >= ext_sectors { - self.current_extent += 1; - self.current_offset = 0; - } - - // Ramp up batch size after consecutive successes - self.ok_streak += 1; - if self.batch_sectors < self.max_batch_sectors - && self.ok_streak >= RAMP_BATCH_AFTER - { - self.batch_sectors = (self.batch_sectors * 2).min(self.max_batch_sectors); - self.ok_streak = 0; - } - - // Restore max speed after sustained success at full batch - if self.batch_sectors == self.max_batch_sectors - && self.ok_streak >= RAMP_SPEED_AFTER - { - self.session.set_speed(0xFFFF); - self.ok_streak = 0; - } - - return Ok(true); - } - Err(_) => { - self.errors += 1; - self.error_streak += 1; - self.ok_streak = 0; - - // First error: re-init (drive may have re-locked) - if self.error_streak == 1 { - let _ = self.session.init(); - let _ = self.session.probe_disc(); - } - - // Repeated errors: slow down - if self.error_streak >= SLOW_SPEED_AFTER { - self.session.set_speed(DriveSpeed::BD2x.to_kbps()); - self.error_streak = 0; - } - - if self.batch_sectors > MIN_BATCH_SECTORS { - self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS); - std::thread::sleep(std::time::Duration::from_millis(100)); - } else { - // At minimum batch -- retry once with longer pause - std::thread::sleep(std::time::Duration::from_millis(500)); - self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0); - if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() { - self.buf_len = 1; - self.buf_pos = 0; - self.error_streak = 0; - self.current_offset += MIN_BATCH_SECTORS as u32; - if self.current_offset >= ext_sectors { - self.current_extent += 1; - self.current_offset = 0; - } - return Ok(true); - } - // Still failing -- skip this unit (zero-fill) - self.current_offset += 3; - if self.current_offset >= ext_sectors { - self.current_extent += 1; - self.current_offset = 0; - } - self.read_buf.resize(crate::aacs::ALIGNED_UNIT_LEN, 0); - self.read_buf.fill(0); - self.buf_len = 1; - self.buf_pos = 0; - return Ok(true); - } - } - } - } - } -} - // ─── Format helpers ──────────────────────────────────────────────────────── // Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums diff --git a/src/drive/mod.rs b/src/drive/mod.rs index f780134..d587583 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -141,8 +141,8 @@ impl Drive { } std::thread::sleep(std::time::Duration::from_millis(500)); } - Err(Error::DeviceNotFound { - path: format!("{}: drive not ready after 30s", self.device_path), + Err(Error::DeviceNotReady { + path: self.device_path.clone(), }) } @@ -264,8 +264,8 @@ impl Drive { } } - Err(Error::DeviceNotFound { - path: format!("{}: drive reset failed", self.device_path), + Err(Error::DeviceResetFailed { + path: self.device_path.clone(), }) } diff --git a/src/error.rs b/src/error.rs index bd1be50..b8e9333 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,32 +15,40 @@ //! | E6xxx | Disc format errors | //! | E7xxx | AACS errors | //! | E8xxx | Keydb errors | -//! | E9xxx | Mux errors | +//! | E9xxx | Stream/mux errors | // ── Error codes ───────────────────────────────────────────────────────────── +// Device (1xxx) pub const E_DEVICE_NOT_FOUND: u16 = 1000; pub const E_DEVICE_PERMISSION: u16 = 1001; +pub const E_DEVICE_NOT_READY: u16 = 1002; +pub const E_DEVICE_RESET_FAILED: u16 = 1003; + +// Profile (2xxx) pub const E_UNSUPPORTED_DRIVE: u16 = 2000; -pub const E_PROFILE_NOT_FOUND: u16 = 2001; pub const E_PROFILE_PARSE: u16 = 2002; + +// Unlock (3xxx) pub const E_UNLOCK_FAILED: u16 = 3000; pub const E_SIGNATURE_MISMATCH: u16 = 3001; -pub const E_NOT_UNLOCKED: u16 = 3002; -pub const E_NOT_CALIBRATED: u16 = 3003; + +// SCSI (4xxx) pub const E_SCSI_ERROR: u16 = 4000; -pub const E_SCSI_TIMEOUT: u16 = 4001; + +// I/O (5xxx) pub const E_IO_ERROR: u16 = 5000; -pub const E_WRITE_ERROR: u16 = 5001; + // Disc format (6xxx) pub const E_DISC_READ: u16 = 6000; pub const E_MPLS_PARSE: u16 = 6001; pub const E_CLPI_PARSE: u16 = 6002; pub const E_UDF_NOT_FOUND: u16 = 6003; -pub const E_DISC_NO_TITLES: u16 = 6004; pub const E_DISC_TITLE_RANGE: u16 = 6005; -pub const E_DISC_NO_EXTENTS: u16 = 6006; pub const E_IFO_PARSE: u16 = 6007; +pub const E_MKV_INVALID: u16 = 6008; +pub const E_NO_STREAMS: u16 = 6009; + // AACS (7xxx) pub const E_AACS_NO_KEYS: u16 = 7000; pub const E_AACS_CERT_SHORT: u16 = 7001; @@ -54,8 +62,8 @@ pub const E_AACS_KEY_VERIFY: u16 = 7008; pub const E_AACS_VID_READ: u16 = 7009; pub const E_AACS_VID_MAC: u16 = 7010; pub const E_AACS_DATA_KEY: u16 = 7011; -pub const E_AACS_VUK_DERIVE: u16 = 7012; pub const E_DECRYPT_FAILED: u16 = 7013; + // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; pub const E_KEYDB_HTTP: u16 = 8001; @@ -63,121 +71,94 @@ pub const E_KEYDB_INVALID: u16 = 8002; pub const E_KEYDB_WRITE: u16 = 8003; pub const E_KEYDB_PARSE: u16 = 8004; pub const E_KEYDB_LOAD: u16 = 8005; -// Mux (9xxx) -pub const E_MUX_LOOKAHEAD: u16 = 9000; -pub const E_MUX_WRITE: u16 = 9001; + +// Stream/mux (9xxx) +pub const E_STREAM_READ_ONLY: u16 = 9000; +pub const E_STREAM_WRITE_ONLY: u16 = 9001; +pub const E_STREAM_URL_INVALID: u16 = 9002; +pub const E_STREAM_URL_MISSING_PATH: u16 = 9003; +pub const E_STREAM_URL_MISSING_PORT: u16 = 9004; +pub const E_PES_FRAME_TOO_LARGE: u16 = 9005; +pub const E_PES_INVALID_MAGIC: u16 = 9006; +pub const E_ISO_TOO_LARGE: u16 = 9007; +pub const E_NO_METADATA: u16 = 9008; // ── Error enum ────────────────────────────────────────────────────────────── /// Structured error with numeric code and context data. No English text. #[derive(Debug)] pub enum Error { - /// Device not found at the given path. + // Device (1xxx) DeviceNotFound { path: String }, - /// Insufficient permissions to open the device. DevicePermission { path: String }, + DeviceNotReady { path: String }, + DeviceResetFailed { path: String }, - /// Drive model is not in the profile database. + // Profile (2xxx) UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String, }, - /// No matching firmware profile found for this drive revision. - ProfileNotFound { - vendor_id: String, - product_revision: String, - vendor_specific: String, - }, - /// Failed to parse the bundled profile database. ProfileParse, - /// Drive unlock (firmware upload) failed. + // Unlock (3xxx) UnlockFailed, - /// Firmware signature verification failed. SignatureMismatch { expected: [u8; 4], got: [u8; 4] }, - /// Operation requires an unlocked drive. - NotUnlocked, - /// Operation requires a calibrated drive. - NotCalibrated, - /// SCSI command returned an error status. + // SCSI (4xxx) ScsiError { opcode: u8, status: u8, sense_key: u8, }, - /// SCSI command timed out. - ScsiTimeout { opcode: u8 }, - /// Underlying I/O error. + // I/O (5xxx) IoError { source: std::io::Error }, - /// Write operation failed. - WriteError, - /// Failed to read disc sector. + // Disc format (6xxx) DiscRead { sector: u64 }, - /// MPLS playlist parsing failed. MplsParse, - /// CLPI clip info parsing failed. ClpiParse, - /// File not found on the UDF filesystem. UdfNotFound { path: String }, - /// Disc contains no playable titles. - DiscNoTitles, - /// Title index out of range. DiscTitleRange { index: usize, count: usize }, - /// Title has no sector extents to read. - DiscNoExtents, - /// DVD IFO file parsing failed. IfoParse, + MkvInvalid, + NoStreams, - /// No AACS decryption keys available for this disc. + // AACS (7xxx) AacsNoKeys, - /// Host certificate too short. AacsCertShort, - /// Failed to allocate AGID for AACS handshake. AacsAgidAlloc, - /// Drive rejected the host certificate. AacsCertRejected, - /// Failed to read drive certificate. AacsCertRead, - /// Drive certificate verification failed. AacsCertVerify, - /// Failed to read host key from drive. AacsKeyRead, - /// Drive rejected the host key. AacsKeyRejected, - /// Host key verification failed. AacsKeyVerify, - /// Failed to read Volume ID. AacsVidRead, - /// Volume ID MAC verification failed. AacsVidMac, - /// Failed to derive the data key. AacsDataKey, - /// Failed to derive the Volume Unique Key. - AacsVukDerive, - /// Decryption failed — missing or invalid keys. - DecryptFailed { reason: String }, + DecryptFailed, - /// Failed to connect to the KEYDB server. + // Keydb (8xxx) KeydbConnect { host: String }, - /// KEYDB server returned an HTTP error. KeydbHttp { status: u16 }, - /// Downloaded KEYDB file is invalid (no entries found). KeydbInvalid, - /// Failed to write KEYDB to disk. KeydbWrite { path: String }, - /// Failed to parse KEYDB file. KeydbParse, - /// Failed to load KEYDB from disk. KeydbLoad { path: String }, - /// Lookahead buffer exhausted before codec headers found. - MuxLookahead, - /// Muxer write failed. - MuxWrite, + // Stream/mux (9xxx) + StreamReadOnly, + StreamWriteOnly, + StreamUrlInvalid { url: String }, + StreamUrlMissingPath { scheme: String }, + StreamUrlMissingPort { addr: String }, + PesFrameTooLarge { size: usize }, + PesInvalidMagic, + IsoTooLarge { path: String }, + NoMetadata, } impl Error { @@ -185,25 +166,22 @@ impl Error { match self { Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND, Error::DevicePermission { .. } => E_DEVICE_PERMISSION, + Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY, + Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED, Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE, - Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND, Error::ProfileParse => E_PROFILE_PARSE, Error::UnlockFailed => E_UNLOCK_FAILED, Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH, - Error::NotUnlocked => E_NOT_UNLOCKED, - Error::NotCalibrated => E_NOT_CALIBRATED, Error::ScsiError { .. } => E_SCSI_ERROR, - Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT, Error::IoError { .. } => E_IO_ERROR, - Error::WriteError => E_WRITE_ERROR, Error::DiscRead { .. } => E_DISC_READ, Error::MplsParse => E_MPLS_PARSE, Error::ClpiParse => E_CLPI_PARSE, Error::UdfNotFound { .. } => E_UDF_NOT_FOUND, - Error::DiscNoTitles => E_DISC_NO_TITLES, Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE, - Error::DiscNoExtents => E_DISC_NO_EXTENTS, Error::IfoParse => E_IFO_PARSE, + Error::MkvInvalid => E_MKV_INVALID, + Error::NoStreams => E_NO_STREAMS, Error::AacsNoKeys => E_AACS_NO_KEYS, Error::AacsCertShort => E_AACS_CERT_SHORT, Error::AacsAgidAlloc => E_AACS_AGID_ALLOC, @@ -216,16 +194,22 @@ impl Error { Error::AacsVidRead => E_AACS_VID_READ, Error::AacsVidMac => E_AACS_VID_MAC, Error::AacsDataKey => E_AACS_DATA_KEY, - Error::AacsVukDerive => E_AACS_VUK_DERIVE, - Error::DecryptFailed { .. } => E_DECRYPT_FAILED, + Error::DecryptFailed => E_DECRYPT_FAILED, Error::KeydbConnect { .. } => E_KEYDB_CONNECT, Error::KeydbHttp { .. } => E_KEYDB_HTTP, Error::KeydbInvalid => E_KEYDB_INVALID, Error::KeydbWrite { .. } => E_KEYDB_WRITE, Error::KeydbParse => E_KEYDB_PARSE, Error::KeydbLoad { .. } => E_KEYDB_LOAD, - Error::MuxLookahead => E_MUX_LOOKAHEAD, - Error::MuxWrite => E_MUX_WRITE, + Error::StreamReadOnly => E_STREAM_READ_ONLY, + Error::StreamWriteOnly => E_STREAM_WRITE_ONLY, + Error::StreamUrlInvalid { .. } => E_STREAM_URL_INVALID, + Error::StreamUrlMissingPath { .. } => E_STREAM_URL_MISSING_PATH, + Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT, + Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE, + Error::PesInvalidMagic => E_PES_INVALID_MAGIC, + Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE, + Error::NoMetadata => E_NO_METADATA, } } } @@ -236,68 +220,38 @@ impl std::fmt::Display for Error { match self { Error::DeviceNotFound { path } => write!(f, "E{}: {}", self.code(), path), Error::DevicePermission { path } => write!(f, "E{}: {}", self.code(), path), + Error::DeviceNotReady { path } => write!(f, "E{}: {}", self.code(), path), + Error::DeviceResetFailed { path } => write!(f, "E{}: {}", self.code(), path), Error::UnsupportedDrive { vendor_id, product_id, product_revision, } => write!( - f, - "E{}: {} {} {}", - self.code(), - vendor_id.trim(), - product_id.trim(), - product_revision.trim() - ), - Error::ProfileNotFound { - vendor_id, - product_revision, - vendor_specific, - } => write!( - f, - "E{}: {} {} {}", - self.code(), - vendor_id.trim(), - product_revision.trim(), - vendor_specific.trim() + f, "E{}: {} {} {}", + self.code(), vendor_id.trim(), product_id.trim(), product_revision.trim() ), Error::SignatureMismatch { expected, got } => write!( - f, - "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}", + f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}", self.code(), - expected[0], - expected[1], - expected[2], - expected[3], - got[0], - got[1], - got[2], - got[3] + expected[0], expected[1], expected[2], expected[3], + got[0], got[1], got[2], got[3] ), - Error::ScsiError { - opcode, - status, - sense_key, - } => write!( - f, - "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", - self.code(), - opcode, - status, - sense_key - ), - Error::ScsiTimeout { opcode } => write!(f, "E{}: 0x{:02x}", self.code(), opcode), + Error::ScsiError { opcode, status, sense_key } => { + write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key) + } Error::IoError { source } => write!(f, "E{}: {}", self.code(), source), Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector), Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path), - Error::DiscTitleRange { index, count } => { - write!(f, "E{}: {}/{}", self.code(), index, count) - } + Error::DiscTitleRange { index, count } => write!(f, "E{}: {}/{}", self.code(), index, count), Error::KeydbConnect { host } => write!(f, "E{}: {}", self.code(), host), Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status), Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path), Error::KeydbLoad { path } => write!(f, "E{}: {}", self.code(), path), - Error::DecryptFailed { reason } => write!(f, "E{}: {}", self.code(), reason), - // Simple codes — no extra data + Error::StreamUrlInvalid { url } => write!(f, "E{}: {}", self.code(), url), + Error::StreamUrlMissingPath { scheme } => write!(f, "E{}: {}", self.code(), scheme), + Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr), + Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size), + Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path), _ => write!(f, "E{}", self.code()), } } @@ -318,5 +272,27 @@ impl From<std::io::Error> for Error { } } +impl From<Error> for std::io::Error { + fn from(e: Error) -> Self { + let code = e.code(); + let msg = e.to_string(); + // Map our error categories to io::ErrorKind + let kind = match code { + 1000..=1999 => std::io::ErrorKind::NotFound, + 2000..=2999 => std::io::ErrorKind::Unsupported, + 3000..=3999 => std::io::ErrorKind::PermissionDenied, + 4000..=4999 => std::io::ErrorKind::Other, + 5000..=5999 => std::io::ErrorKind::Other, + 6000..=6999 => std::io::ErrorKind::InvalidData, + 7000..=7999 => std::io::ErrorKind::PermissionDenied, + 8000..=8999 => std::io::ErrorKind::Other, + 9000..=9001 => std::io::ErrorKind::Unsupported, + 9002..=9009 => std::io::ErrorKind::InvalidInput, + _ => std::io::ErrorKind::Other, + }; + std::io::Error::new(kind, msg) + } +} + /// Convenience alias for `Result<T, Error>`. pub type Result<T> = std::result::Result<T, Error>; diff --git a/src/lib.rs b/src/lib.rs index 7b707e0..32c35cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,17 +9,23 @@ //! use libfreemkv::{Drive, Disc, ScanOptions, find_drive}; //! //! let mut drive = find_drive().expect("no optical drive found"); +//! drive.wait_ready().unwrap(); +//! drive.init().unwrap(); //! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap(); //! //! for title in &disc.titles { //! println!("{} -- {} streams", title.duration_display(), title.streams.len()); //! } //! -//! // Read content (decrypted automatically if AACS keys available) -//! let mut reader = disc.open_title(&mut drive, 0).unwrap(); -//! while let Some(unit) = reader.read_unit().unwrap() { -//! // 6144 bytes of decrypted content per unit +//! // Stream via PES pipeline +//! let opts = libfreemkv::InputOptions::default(); +//! let mut input = libfreemkv::input("disc://", &opts).unwrap(); +//! let title = input.info().clone(); +//! let mut output = libfreemkv::output("mkv://Movie.mkv", &title).unwrap(); +//! while let Ok(Some(frame)) = input.read() { +//! output.write(&frame).unwrap(); //! } +//! output.finish().unwrap(); //! ``` //! //! # Architecture @@ -103,14 +109,12 @@ pub use disc::{ ScanOptions, Stream, SubtitleStream, VideoStream, }; pub use mux::DiscStream; -pub use mux::IOStream; -pub use mux::IsoStream; pub use mux::M2tsStream; pub use mux::MkvStream; pub use mux::NetworkStream; pub use mux::NullStream; pub use mux::StdioStream; -pub use mux::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl}; +pub use mux::{input, output, parse_url, InputOptions, StreamUrl}; pub use scsi::ScsiTransport; pub use sector::SectorReader; pub use speed::DriveSpeed; diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 1267d5f..7bbe534 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -1,42 +1,36 @@ -//! DiscStream — read sectors from an optical disc drive. +//! DiscStream — read any disc (physical drive or ISO file) → PES frames. //! -//! `DiscStream::open()` does the full init sequence: -//! drive open → wait_ready → init → probe_disc → scan +//! One stream type for all disc sources. The source is a SectorReader — +//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care. //! -//! Then reads title extents or full-disc sequentially. -//! No decryption — that's a caller concern. +//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`. -use super::IOStream; use crate::disc::{ detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions, }; -use crate::drive::Drive; -use crate::error::{Error, Result}; -use crate::event::{Event, EventKind}; -use std::io::{self, Read, Write}; -use std::path::Path; +use crate::sector::SectorReader; +use std::io; -/// Optical disc stream. Read-only — yields raw sector bytes. +/// Disc stream. Reads sectors from any source → PES frames. /// -/// Created from an initialized Drive + title extents or full-disc mode. -/// Error recovery (batch reduction, retry, zero-fill) is handled internally. +/// Sources: physical drive, ISO file, or any SectorReader. +/// Decrypt, demux, and codec parsing happen internally. pub struct DiscStream { - drive: Drive, + reader: Box<dyn SectorReader>, title: DiscTitle, + disc: Option<Disc>, decrypt_keys: crate::decrypt::DecryptKeys, - // What to read - mode: ReadMode, + // Extents to read + extents: Vec<Extent>, // Position - current_lba: u32, current_extent: usize, current_offset: u32, // Buffer read_buf: Vec<u8>, buf_valid: usize, - buf_cursor: usize, // Batch size for reads batch_sectors: u16, @@ -51,77 +45,24 @@ pub struct DiscStream { pid_to_track: Vec<(u16, usize)>, } -enum ReadMode { - /// Read title extents (for MKV, M2TS, etc.) - Extents(Vec<Extent>), - /// Read LBA 0 to capacity (for ISO) - Sequential { capacity: u32 }, -} - -/// Result of opening a DiscStream. -pub struct DiscOpenResult { - pub stream: DiscStream, - pub disc: Disc, -} - impl DiscStream { - /// Open a disc drive, init, scan, and prepare to read a title. - /// - /// Steps (each does one thing): - /// 1. Drive::open (or find_drive) - /// 2. wait_ready - /// 3. init (non-fatal) - /// 4. probe_disc (non-fatal) - /// 5. Disc::scan - /// - /// Pass an event callback for status reporting, or None. - pub fn open( - device: Option<&Path>, + /// Open from a physical drive. Caller must have already called + /// drive.wait_ready(), drive.init(), drive.probe_disc(). + /// Drive is moved into the stream — caller manages lock/unlock before/after. + pub fn open_drive( + drive: crate::drive::Drive, keydb_path: Option<&str>, title_index: usize, - on_event: Option<&dyn Fn(Event)>, - ) -> Result<DiscOpenResult> { - let emit = |kind: EventKind| { - if let Some(cb) = &on_event { - cb(Event { kind }); - } - }; - - // 1. Open - let mut drive = match device { - Some(d) => Drive::open(d)?, - None => crate::drive::find_drive().ok_or_else(|| Error::DeviceNotFound { - path: String::new(), - })?, - }; - emit(EventKind::DriveOpened { - device: drive.device_path().to_string(), - }); - - // 2. Wait - let _ = drive.wait_ready(); - emit(EventKind::DriveReady); - - // 3. Init - let init_ok = drive.init().is_ok(); - emit(EventKind::InitComplete { success: init_ok }); - - // 4. Probe - let probe_ok = drive.probe_disc().is_ok(); - emit(EventKind::ProbeComplete { success: probe_ok }); - - // 5. Scan + ) -> crate::error::Result<(Self, Disc)> { let scan_opts = match keydb_path { Some(kp) => ScanOptions::with_keydb(kp), None => ScanOptions::default(), }; + let mut drive = drive; let disc = Disc::scan(&mut drive, &scan_opts)?; - emit(EventKind::ScanComplete { - titles: disc.titles.len(), - }); if title_index >= disc.titles.len() { - return Err(Error::DiscTitleRange { + return Err(crate::error::Error::DiscTitleRange { index: title_index, count: disc.titles.len(), }); @@ -129,45 +70,59 @@ impl DiscStream { let title = disc.titles[title_index].clone(); let keys = disc.decrypt_keys(); - let mut stream = Self::title(drive, title); - stream.decrypt_keys = keys; + let max_batch = detect_max_batch_sectors(drive.device_path()); + let content_format = disc.content_format; - // DVD: use program stream demuxer instead of transport stream - if disc.content_format == crate::disc::ContentFormat::MpegPs { + let mut stream = Self::from_reader(Box::new(drive), title, keys, max_batch); + + if content_format == crate::disc::ContentFormat::MpegPs { stream.ts_demuxer = None; stream.ps_demuxer = Some(super::ps::PsDemuxer::new()); } - - Ok(DiscOpenResult { stream, disc }) + Ok((stream, disc)) } - /// Create a stream that reads a title's extents. - /// Use this when you already have an initialized Drive. - pub fn title(drive: Drive, title: DiscTitle) -> Self { - let max_batch = detect_max_batch_sectors(drive.device_path()); + /// Open from an ISO file. + pub fn open_iso( + path: &str, + title_index: Option<usize>, + opts: &ScanOptions, + ) -> io::Result<Self> { + let mut reader = super::iso::IsoSectorReader::open(path)?; + let capacity = reader.capacity(); + + let disc = Disc::scan_image(&mut reader, capacity, opts) + .map_err(|e| -> io::Error { e.into() })?; + + if disc.titles.is_empty() { + return Err(crate::error::Error::NoStreams.into()); + } + let idx = title_index.unwrap_or(0); + if idx >= disc.titles.len() { + return Err(crate::error::Error::DiscTitleRange { + index: idx, + count: disc.titles.len(), + }.into()); + } + + let title = disc.titles[idx].clone(); + let keys = disc.decrypt_keys(); + let batch: u16 = 64; + + let mut stream = Self::from_reader(Box::new(reader), title, keys, batch); + stream.disc = Some(disc); + Ok(stream) + } + + /// Create from any SectorReader + title + keys. + pub fn from_reader( + reader: Box<dyn SectorReader>, + title: DiscTitle, + decrypt_keys: crate::decrypt::DecryptKeys, + batch_sectors: u16, + ) -> Self { let extents = title.extents.clone(); - Self::new(drive, title, ReadMode::Extents(extents), max_batch) - } - /// Create a stream that reads the full disc sequentially (for ISO). - pub fn full_disc(drive: Drive, title: DiscTitle, capacity: u32) -> Self { - let max_batch = detect_max_batch_sectors(drive.device_path()); - Self::new(drive, title, ReadMode::Sequential { capacity }, max_batch) - } - - /// Resume a full disc read from a given LBA (for ISO resume). - /// Use after checking an existing partial file: - /// start_lba = (file_size / 2048) - safety_margin - pub fn full_disc_resume(drive: Drive, title: DiscTitle, capacity: u32, start_lba: u32) -> Self { - let max_batch = detect_max_batch_sectors(drive.device_path()); - let mut stream = Self::new(drive, title, ReadMode::Sequential { capacity }, max_batch); - stream.current_lba = start_lba; - stream - } - - /// Set SCSI read timeout (default 30s). - fn new(drive: Drive, title: DiscTitle, mode: ReadMode, max_batch: u16) -> Self { - // Set up PES demux from title stream PIDs let mut pids = Vec::new(); let mut parsers = Vec::new(); let mut pid_to_track = Vec::new(); @@ -183,21 +138,20 @@ impl DiscStream { } Self { - drive, + reader, title, - decrypt_keys: crate::decrypt::DecryptKeys::None, - mode, - current_lba: 0, + disc: None, + decrypt_keys, + extents, current_extent: 0, current_offset: 0, - read_buf: Vec::with_capacity(max_batch as usize * 2048), + read_buf: Vec::with_capacity(batch_sectors as usize * 2048), buf_valid: 0, - buf_cursor: 0, - batch_sectors: max_batch, + batch_sectors, errors: 0, eof: false, ts_demuxer: if pids.is_empty() { None } else { Some(super::ts::TsDemuxer::new(&pids)) }, - ps_demuxer: None, // set by caller for DVD content + ps_demuxer: None, parsers, pending_frames: std::collections::VecDeque::new(), pid_to_track, @@ -209,43 +163,17 @@ impl DiscStream { self.decrypt_keys = crate::decrypt::DecryptKeys::None; } - /// Lock the tray. - pub fn lock_tray(&mut self) { - self.drive.lock_tray(); - } - - /// Unlock the tray. - pub fn unlock_tray(&mut self) { - self.drive.unlock_tray(); - } - - /// Recover the drive (for batch: switch to another title). - pub fn into_drive(self) -> Drive { - self.drive - } - - // ── Fill ───────────────────────────────────────────────────────────── - - fn fill(&mut self) -> bool { - match &self.mode { - ReadMode::Extents(_) => self.fill_extents(), - ReadMode::Sequential { .. } => self.fill_sequential(), - } + /// Get the scanned Disc (for listing all titles). + pub fn disc(&self) -> Option<&Disc> { + self.disc.as_ref() } fn fill_extents(&mut self) -> bool { - let (ext_start, ext_sectors) = match &self.mode { - ReadMode::Extents(exts) => { - if self.current_extent >= exts.len() { - return false; - } - ( - exts[self.current_extent].start_lba, - exts[self.current_extent].sector_count, - ) - } - _ => unreachable!(), - }; + if self.current_extent >= self.extents.len() { + return false; + } + let ext_start = self.extents[self.current_extent].start_lba; + let ext_sectors = self.extents[self.current_extent].sector_count; let remaining = ext_sectors.saturating_sub(self.current_offset); let sectors = remaining.min(self.batch_sectors as u32) as u16; @@ -253,22 +181,16 @@ impl DiscStream { if sectors == 0 { self.current_extent += 1; self.current_offset = 0; - return self.fill_extents(); // next extent + return self.fill_extents(); } let lba = ext_start + self.current_offset; let bytes = sectors as usize * 2048; self.read_buf.resize(bytes, 0); - // Drive handles all error recovery internally. - match self.drive.read( - lba, - sectors, - &mut self.read_buf[..bytes], - ) { + match self.reader.read_sectors(lba, sectors, &mut self.read_buf[..bytes]) { Ok(_) => { self.buf_valid = bytes; - self.buf_cursor = 0; self.current_offset += sectors as u32; if self.current_offset >= ext_sectors { self.current_extent += 1; @@ -276,73 +198,13 @@ impl DiscStream { } true } - Err(_) => false, // drive gone — EOF + Err(_) => false, } } - - fn fill_sequential(&mut self) -> bool { - let capacity = match &self.mode { - ReadMode::Sequential { capacity } => *capacity, - _ => unreachable!(), - }; - - if self.current_lba >= capacity { - return false; - } - - let remaining = capacity - self.current_lba; - let count = remaining.min(self.batch_sectors as u32) as u16; - let bytes = count as usize * 2048; - self.read_buf.resize(bytes, 0); - - // Drive handles all error recovery internally — - // retries, speed changes, zero-fill on unreadable sectors. - match self.drive.read( - self.current_lba, - count, - &mut self.read_buf[..bytes], - ) { - Ok(_) => { - self.buf_valid = bytes; - self.buf_cursor = 0; - self.current_lba += count as u32; - true - } - Err(_) => false, // drive gone — EOF - } - } - -} - -// ── IOStream ───────────────────────────────────────────────────────────────── - -impl IOStream for DiscStream { - fn info(&self) -> &DiscTitle { - &self.title - } - - fn finish(&mut self) -> io::Result<()> { - self.drive.unlock_tray(); - Ok(()) - } - - fn total_bytes(&self) -> Option<u64> { - match &self.mode { - ReadMode::Extents(extents) => { - Some(extents.iter().map(|e| e.sector_count as u64 * 2048).sum()) - } - ReadMode::Sequential { capacity } => Some(*capacity as u64 * 2048), - } - } - - fn keys(&self) -> crate::decrypt::DecryptKeys { - self.decrypt_keys.clone() - } } impl crate::pes::Stream for DiscStream { fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { - // Return buffered frame if available if let Some(frame) = self.pending_frames.pop_front() { return Ok(Some(frame)); } @@ -351,32 +213,22 @@ impl crate::pes::Stream for DiscStream { return Ok(None); } - // Read sectors until we produce at least one frame loop { - // Fill the read buffer with next batch of sectors - let got_data = match &self.mode { - ReadMode::Extents(_) => self.fill_extents(), - ReadMode::Sequential { .. } => self.fill_sequential(), - }; - - if !got_data { + if !self.fill_extents() { self.eof = true; return Ok(None); } - // Decrypt let bytes = self.buf_valid; if let Err(e) = crate::decrypt::decrypt_sectors( &mut self.read_buf[..bytes], &self.decrypt_keys, 0, ) { - return Err(io::Error::other(e.to_string())); + return Err(e.into()); } - // Demux into packets, parse into frames if let Some(ref mut demuxer) = self.ts_demuxer { - // BD: transport stream demux let packets = demuxer.feed(&self.read_buf[..bytes]); for pes in &packets { if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) { @@ -390,13 +242,11 @@ impl crate::pes::Stream for DiscStream { } } } else if let Some(ref mut demuxer) = self.ps_demuxer { - // DVD: program stream demux let packets = demuxer.feed(&self.read_buf[..bytes]); for ps in &packets { - // Map PS stream_id to track index let track = match ps.stream_id { - 0xE0..=0xEF => 0, // video - 0xC0..=0xDF => 1, // audio + 0xE0..=0xEF => 0, + 0xC0..=0xDF => 1, 0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1), _ => continue, }; @@ -405,36 +255,28 @@ impl crate::pes::Stream for DiscStream { self.pending_frames.push_back(crate::pes::PesFrame { track, pts: pts_ns, - keyframe: true, // PS doesn't have keyframe flag easily + keyframe: true, data: ps.data.clone(), }); } } } - // Reset buffer for next read self.buf_valid = 0; - self.buf_cursor = 0; if let Some(frame) = self.pending_frames.pop_front() { return Ok(Some(frame)); } - // No frames produced — read more data } } fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> { - Err(io::Error::new(io::ErrorKind::Unsupported, "disc is read-only")) + Err(crate::error::Error::StreamReadOnly.into()) } - fn finish(&mut self) -> io::Result<()> { - self.drive.unlock_tray(); - Ok(()) - } + fn finish(&mut self) -> io::Result<()> { Ok(()) } - fn info(&self) -> &DiscTitle { - &self.title - } + fn info(&self) -> &DiscTitle { &self.title } fn codec_private(&self, track: usize) -> Option<Vec<u8>> { let pid = self.pid_to_track.iter() @@ -456,42 +298,3 @@ impl crate::pes::Stream for DiscStream { true } } - -impl Read for DiscStream { - fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { - // Drain current buffer - if self.buf_cursor < self.buf_valid { - let n = (self.buf_valid - self.buf_cursor).min(buf.len()); - buf[..n].copy_from_slice(&self.read_buf[self.buf_cursor..self.buf_cursor + n]); - self.buf_cursor += n; - return Ok(n); - } - - if self.eof { - return Ok(0); - } - - // Fill next batch - if self.fill() { - let n = self.buf_valid.min(buf.len()); - buf[..n].copy_from_slice(&self.read_buf[..n]); - self.buf_cursor = n; - Ok(n) - } else { - self.eof = true; - Ok(0) - } - } -} - -impl Write for DiscStream { - fn write(&mut self, _buf: &[u8]) -> io::Result<usize> { - Err(io::Error::new( - io::ErrorKind::Unsupported, - "disc is read-only", - )) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index e7601ef..c941777 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -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()) } // ============================================================ diff --git a/src/mux/iso.rs b/src/mux/iso.rs index 164e867..a9c529d 100644 --- a/src/mux/iso.rs +++ b/src/mux/iso.rs @@ -1,27 +1,16 @@ -//! IsoStream — read/write Blu-ray ISO disc images. +//! ISO sector reader — file-backed SectorReader for Blu-ray ISO images. //! -//! Read: parses UDF filesystem inside the ISO using the same pipeline as -//! DiscStream (titles, streams, labels, AACS). An ISO is a flat image of -//! 2048-byte sectors — sector N starts at byte offset N * 2048. -//! -//! Write: creates a UDF 2.50 filesystem containing the m2ts stream data. -//! The resulting ISO can be mounted or read back via IsoStream. +//! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048. +//! Used by DiscStream::open_iso() and Disc::scan_image(). -use super::isowriter::IsoWriter; -use super::IOStream; -use crate::decrypt::{decrypt_sectors, DecryptKeys}; -use crate::disc::{Disc, DiscTitle, ScanOptions}; use crate::error::{Error, Result}; use crate::sector::SectorReader; use std::fs::File; -use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::io::{Read, Seek, SeekFrom}; use std::path::Path; const SECTOR_SIZE: u64 = 2048; -/// Maximum sectors to batch-read at once (64 sectors = 128 KB). -const BATCH_SECTORS: usize = 64; - /// File-backed sector reader for ISO images. pub struct IsoSectorReader { file: File, @@ -29,16 +18,15 @@ pub struct IsoSectorReader { } impl IsoSectorReader { - pub fn open(path: &str) -> io::Result<Self> { + pub fn open(path: &str) -> std::io::Result<Self> { let file = File::open(Path::new(path)) - .map_err(|e| io::Error::new(e.kind(), format!("iso://{path}: {e}")))?; + .map_err(|e| std::io::Error::new(e.kind(), format!("iso://{path}: {e}")))?; let size = file.metadata()?.len(); let sectors = size / SECTOR_SIZE; if sectors > u32::MAX as u64 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("iso://{path}: image too large ({} TB, max ~8 TB)", size / (1024 * 1024 * 1024 * 1024)), - )); + return Err(crate::error::Error::IsoTooLarge { + path: path.to_string(), + }.into()); } let capacity = sectors as u32; Ok(Self { file, capacity }) @@ -62,355 +50,6 @@ impl SectorReader for IsoSectorReader { } } -/// Blu-ray ISO image stream. -/// -/// Read: opens ISO, parses UDF (same as DiscStream), streams BD-TS content. -/// Write: creates UDF 2.50 ISO with BDMV/STREAM/*.m2ts. -pub struct IsoStream { - disc_title: DiscTitle, - disc: Option<Disc>, - // Read side - reader: Option<IsoSectorReader>, - extents: Vec<(u32, u32)>, - extent_idx: usize, - sectors_remaining: u32, - /// Batch buffer: holds up to BATCH_SECTORS sectors (128 KB) at once. - batch_buf: Vec<u8>, - buf_pos: usize, - buf_len: usize, - eof: bool, - /// Decrypt on read — auto-detected from disc scan. - decrypt_keys: DecryptKeys, - // Write side - iso_writer: Option<IsoWriter<io::BufWriter<File>>>, - write_started: bool, - // PES output (for InputStream impl) - demuxer: Option<super::ts::TsDemuxer>, - parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>, - pending_frames: std::collections::VecDeque<crate::pes::PesFrame>, - pid_to_track: Vec<(u16, usize)>, -} - -impl IsoStream { - /// Open an ISO file for reading. Parses UDF, scans titles, streams, labels. - pub fn open(path: &str, title_index: Option<usize>, opts: &ScanOptions) -> io::Result<Self> { - let mut reader = IsoSectorReader::open(path)?; - let capacity = reader.capacity(); - - let disc = Disc::scan_image(&mut reader, capacity, opts) - .map_err(|e| io::Error::other(e.to_string()))?; - - if disc.titles.is_empty() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "no titles found in ISO image", - )); - } - let idx = title_index.unwrap_or(0); - if idx >= disc.titles.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "title {} out of range (disc has {})", - idx + 1, - disc.titles.len() - ), - )); - } - let disc_title = disc.titles[idx].clone(); - - let decrypt_keys = disc.decrypt_keys(); - - let extents: Vec<(u32, u32)> = disc_title - .extents - .iter() - .map(|e| (e.start_lba, e.sector_count)) - .collect(); - let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0); - - // Set up PES demux from title stream PIDs - let mut pids = Vec::new(); - let mut parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)> = Vec::new(); - let mut pid_to_track = Vec::new(); - for (i, s) in disc_title.streams.iter().enumerate() { - let (pid, codec) = match s { - crate::disc::Stream::Video(v) => (v.pid, v.codec), - crate::disc::Stream::Audio(a) => (a.pid, a.codec), - crate::disc::Stream::Subtitle(s) => (s.pid, s.codec), - }; - pids.push(pid); - pid_to_track.push((pid, i)); - parsers.push((pid, super::codec::parser_for_codec(codec))); - } - - Ok(IsoStream { - disc_title, - disc: Some(disc), - reader: Some(reader), - extents, - extent_idx: 0, - sectors_remaining, - batch_buf: vec![0u8; BATCH_SECTORS * SECTOR_SIZE as usize], - buf_pos: 0, - buf_len: 0, - eof: false, - decrypt_keys, - iso_writer: None, - write_started: false, - demuxer: if pids.is_empty() { None } else { Some(super::ts::TsDemuxer::new(&pids)) }, - parsers, - pending_frames: std::collections::VecDeque::new(), - pid_to_track, - }) - } - - /// Create an ISO file for writing. - pub fn create(path: &str) -> io::Result<Self> { - let file = File::create(Path::new(path)) - .map_err(|e| io::Error::new(e.kind(), format!("iso://{path}: {e}")))?; - let buf_writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file); - let iso_writer = IsoWriter::new(buf_writer, "FREEMKV", "00001.m2ts"); - - Ok(IsoStream { - disc_title: DiscTitle::empty(), - disc: None, - decrypt_keys: DecryptKeys::None, - reader: None, - extents: Vec::new(), - extent_idx: 0, - sectors_remaining: 0, - batch_buf: Vec::new(), - buf_pos: 0, - buf_len: 0, - eof: false, - iso_writer: Some(iso_writer), - write_started: false, - demuxer: None, - parsers: Vec::new(), - pending_frames: std::collections::VecDeque::new(), - pid_to_track: Vec::new(), - }) - } - - /// Set metadata (for write mode). Must be called before writing data. - pub fn meta(mut self, dt: &DiscTitle) -> Self { - self.disc_title = dt.clone(); - // Update the ISO writer's volume ID and m2ts filename from title metadata - if let Some(writer) = self.iso_writer.take() { - let vol_id = if dt.playlist.is_empty() { - "FREEMKV".to_string() - } else { - dt.playlist - .chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == ' ') - .collect::<String>() - }; - let m2ts_name = format!("{:05}.m2ts", dt.playlist_id.max(1)); - self.iso_writer = Some(writer.with_names(&vol_id, &m2ts_name)); - } - self - } - - /// Get the full Disc (for listing all titles). - pub fn disc(&self) -> Option<&Disc> { - self.disc.as_ref() - } - - /// Read up to BATCH_SECTORS sectors at once into the batch buffer. - fn read_next_batch(&mut self) -> io::Result<bool> { - let reader = match self.reader.as_mut() { - Some(r) => r, - None => return Ok(false), - }; - - if self.extent_idx >= self.extents.len() { - return Ok(false); - } - - let (start_lba, total) = self.extents[self.extent_idx]; - let offset = total - self.sectors_remaining; - let lba = start_lba + offset; - - // Read up to BATCH_SECTORS, but no more than remaining in this extent - let count = (self.sectors_remaining as usize).min(BATCH_SECTORS) as u16; - - reader - .read_sectors(lba, count, &mut self.batch_buf) - .map_err(|e| io::Error::other(e.to_string()))?; - - // Decrypt after read — stream handles its own decryption - let bytes = count as usize * SECTOR_SIZE as usize; - decrypt_sectors(&mut self.batch_buf[..bytes], &self.decrypt_keys, 0) - .map_err(|e| io::Error::other(e.to_string()))?; - - self.buf_pos = 0; - self.buf_len = bytes; - - self.sectors_remaining -= count as u32; - if self.sectors_remaining == 0 { - self.extent_idx += 1; - if self.extent_idx < self.extents.len() { - self.sectors_remaining = self.extents[self.extent_idx].1; - } - } - - Ok(true) - } - - /// Skip decryption — return raw encrypted bytes. - pub fn set_raw(&mut self) { - self.decrypt_keys = DecryptKeys::None; - } -} - -impl crate::pes::Stream for IsoStream { - fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { - // Return buffered frame - if let Some(frame) = self.pending_frames.pop_front() { - return Ok(Some(frame)); - } - - if self.eof { - return Ok(None); - } - - // Read until we produce at least one frame - loop { - if !self.read_next_batch()? { - self.eof = true; - return Ok(None); - } - - // Data in batch_buf is already decrypted by fill_next_batch - if let Some(ref mut demuxer) = self.demuxer { - let packets = demuxer.feed(&self.batch_buf[..self.buf_len]); - for pes in &packets { - if let Some((pid_idx, _)) = self.pid_to_track.iter().enumerate() - .find(|(_, (pid, _))| *pid == pes.pid) - { - let track_idx = self.pid_to_track[pid_idx].1; - if let Some((_, parser)) = self.parsers.iter_mut() - .find(|(pid, _)| *pid == pes.pid) - { - for frame in parser.parse(pes) { - self.pending_frames.push_back( - crate::pes::PesFrame::from_codec_frame(track_idx, frame) - ); - } - } - } - } - } - - if let Some(frame) = self.pending_frames.pop_front() { - return Ok(Some(frame)); - } - } - } - - fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> { - Err(io::Error::new(io::ErrorKind::Unsupported, "ISO is read-only for PES")) - } - - fn finish(&mut self) -> io::Result<()> { Ok(()) } - - fn info(&self) -> &crate::disc::DiscTitle { - &self.disc_title - } - - fn codec_private(&self, track: usize) -> Option<Vec<u8>> { - let pid = self.pid_to_track.iter() - .find(|(_, idx)| *idx == track) - .map(|(pid, _)| *pid)?; - self.parsers.iter() - .find(|(p, _)| *p == pid) - .and_then(|(_, parser)| parser.codec_private()) - } - - fn headers_ready(&self) -> bool { - for (idx, s) in self.disc_title.streams.iter().enumerate() { - if let crate::disc::Stream::Video(v) = s { - if !v.secondary && self.codec_private(idx).is_none() { - return false; - } - } - } - true - } -} - -impl IOStream for IsoStream { - fn info(&self) -> &DiscTitle { - &self.disc_title - } - fn finish(&mut self) -> io::Result<()> { - if let Some(ref mut w) = self.iso_writer { - w.finish()?; - } - Ok(()) - } - fn total_bytes(&self) -> Option<u64> { - if self.reader.is_some() { - Some(self.disc_title.size_bytes) - } else { - None - } - } - fn keys(&self) -> DecryptKeys { - self.decrypt_keys.clone() - } -} - -impl Read for IsoStream { - fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { - if self.eof { - return Ok(0); - } - - if self.buf_pos < self.buf_len { - let n = (self.buf_len - self.buf_pos).min(buf.len()); - buf[..n].copy_from_slice(&self.batch_buf[self.buf_pos..self.buf_pos + n]); - self.buf_pos += n; - return Ok(n); - } - - if self.read_next_batch()? { - let n = self.buf_len.min(buf.len()); - buf[..n].copy_from_slice(&self.batch_buf[..n]); - self.buf_pos = n; - Ok(n) - } else { - self.eof = true; - Ok(0) - } - } -} - -impl Write for IsoStream { - fn write(&mut self, buf: &[u8]) -> io::Result<usize> { - let w = match self.iso_writer.as_mut() { - Some(w) => w, - None => { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "iso:// opened for reading — cannot write", - )) - } - }; - - if !self.write_started { - w.start()?; - self.write_started = true; - } - - w.write_data(buf) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -453,41 +92,4 @@ mod tests { std::fs::remove_file(&dir).ok(); } - - #[test] - fn iso_write_creates_valid_udf() { - let path = std::env::temp_dir().join("freemkv_test_iso_write.iso"); - let mut stream = IsoStream::create(path.to_str().unwrap()).unwrap(); - - // Write some fake BD-TS content - let mut content = Vec::new(); - for i in 0..100u8 { - let mut pkt = [0u8; 192]; - pkt[4] = 0x47; - pkt[5] = i; - content.extend_from_slice(&pkt); - } - - stream.write_all(&content).unwrap(); - stream.finish().unwrap(); - - // Verify the ISO has valid UDF structure - let file = File::open(&path).unwrap(); - let size = file.metadata().unwrap().len(); - assert!(size > 288 * SECTOR_SIZE); // at least header + some data - - // Read back and verify AVDP at sector 256 - let mut reader = IsoSectorReader::open(path.to_str().unwrap()).unwrap(); - let mut avdp = [0u8; 2048]; - reader.read_sectors(256, 1, &mut avdp).unwrap(); - let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]); - assert_eq!(tag_id, 2, "AVDP tag should be 2"); - - // Verify VRS at sector 16 - let mut vrs = [0u8; 2048]; - reader.read_sectors(16, 1, &mut vrs).unwrap(); - assert_eq!(&vrs[1..6], b"BEA01"); - - std::fs::remove_file(&path).ok(); - } } diff --git a/src/mux/isowriter.rs b/src/mux/isowriter.rs deleted file mode 100644 index e47cf38..0000000 --- a/src/mux/isowriter.rs +++ /dev/null @@ -1,678 +0,0 @@ -//! UDF ISO writer — creates Blu-ray disc images. -//! -//! Writes a minimal UDF 2.50 filesystem containing BDMV/STREAM/*.m2ts. -//! The ISO can be mounted or read back via IsoStream. -//! -//! Layout: -//! Sector 0-15: System area (zeros) -//! Sector 16-18: Volume Recognition Sequence (BEA01, NSR03, TEA01) -//! Sector 32-37: Volume Descriptor Sequence -//! Sector 256: Anchor Volume Descriptor Pointer -//! Sector 260-271: Metadata partition (FSD, ICBs, directories) -//! Sector 288+: File data (m2ts content) -//! Last-256: Reserve AVDP - -use std::io::{self, Seek, SeekFrom, Write}; - -const SECTOR_SIZE: u64 = 2048; - -// Layout constants -const VRS_START: u32 = 16; // Volume Recognition Sequence -const VDS_START: u32 = 32; // Volume Descriptor Sequence -const AVDP_SECTOR: u32 = 256; // Anchor Volume Descriptor Pointer -const PARTITION_START: u32 = 257; // Physical partition start -const METADATA_START: u32 = 260; // Metadata partition content -const FSD_SECTOR: u32 = 260; // File Set Descriptor -const ROOT_ICB_SECTOR: u32 = 261; // Root directory ICB -const ROOT_DIR_SECTOR: u32 = 262; // Root directory data -const BDMV_ICB_SECTOR: u32 = 263; // BDMV/ ICB -const BDMV_DIR_SECTOR: u32 = 264; // BDMV/ directory data -const STREAM_ICB_SECTOR: u32 = 265; // BDMV/STREAM/ ICB -const STREAM_DIR_SECTOR: u32 = 266; // BDMV/STREAM/ directory data -const M2TS_ICB_SECTOR: u32 = 267; // m2ts file ICB -const DATA_START: u32 = 288; // Start of file data (aligned) - -/// Write a complete BD ISO image. -/// -/// Writes UDF structure, then streams m2ts content from the writer. -/// Call `start()` first, then write BD-TS bytes, then call `finish()`. -pub struct IsoWriter<W: Write + Seek> { - writer: W, - volume_id: String, - m2ts_name: String, - data_start_sector: u32, - bytes_written: u64, -} - -impl<W: Write + Seek> IsoWriter<W> { - /// Create a new ISO writer. Call `start()` to write the UDF header. - pub fn new(writer: W, volume_id: &str, m2ts_name: &str) -> Self { - Self { - writer, - volume_id: volume_id.to_string(), - m2ts_name: m2ts_name.to_string(), - data_start_sector: DATA_START, - bytes_written: 0, - } - } - - /// Update volume ID and m2ts filename. Must be called before `start()`. - pub fn with_names(mut self, volume_id: &str, m2ts_name: &str) -> Self { - self.volume_id = volume_id.to_string(); - self.m2ts_name = m2ts_name.to_string(); - self - } - - /// Write UDF filesystem header. After this, write m2ts content bytes. - pub fn start(&mut self) -> io::Result<()> { - // System area: sectors 0-15 (zeros) - let zero_sector = [0u8; SECTOR_SIZE as usize]; - for _ in 0..VRS_START { - self.writer.write_all(&zero_sector)?; - } - - // Volume Recognition Sequence - self.write_vrs()?; - - // Pad sectors 19-31 - for _ in 19..VDS_START { - self.writer.write_all(&zero_sector)?; - } - - // Volume Descriptor Sequence (sectors 32-37) - self.write_vds()?; - - // Pad sectors 38-255 - for _ in 38..AVDP_SECTOR { - self.writer.write_all(&zero_sector)?; - } - - // AVDP at sector 256 - self.write_avdp()?; - - // Partition area: metadata file ICB at partition_start - self.write_metadata_file_icb()?; - - // Pad to metadata start - for _ in (PARTITION_START + 1)..METADATA_START { - self.writer.write_all(&zero_sector)?; - } - - // Metadata partition - self.write_fsd()?; - self.write_root_icb()?; - self.write_root_dir()?; - self.write_bdmv_icb()?; - self.write_bdmv_dir()?; - self.write_stream_icb()?; - self.write_stream_dir()?; - self.write_m2ts_icb(0)?; // placeholder size, updated in finish() - - // Pad to data start - for _ in (M2TS_ICB_SECTOR + 1)..self.data_start_sector { - self.writer.write_all(&zero_sector)?; - } - - Ok(()) - } - - /// Write m2ts content bytes. Call after `start()`. - pub fn write_data(&mut self, buf: &[u8]) -> io::Result<usize> { - let n = self.writer.write(buf)?; - self.bytes_written += n as u64; - Ok(n) - } - - /// Finalize the ISO: pad to sector boundary, update file sizes, write reserve AVDP. - pub fn finish(&mut self) -> io::Result<()> { - // Pad to sector boundary - let remainder = (self.bytes_written % SECTOR_SIZE) as usize; - if remainder > 0 { - let pad = SECTOR_SIZE as usize - remainder; - let zeros = vec![0u8; pad]; - self.writer.write_all(&zeros)?; - self.bytes_written += pad as u64; - } - - let total_data_sectors = (self.bytes_written / SECTOR_SIZE) as u32; - let total_sectors = self.data_start_sector + total_data_sectors; - - // Seek back and update m2ts file ICB with actual size - self.writer - .seek(SeekFrom::Start(M2TS_ICB_SECTOR as u64 * SECTOR_SIZE))?; - self.write_m2ts_icb(self.bytes_written)?; - - // Seek to end and write reserve AVDP - let reserve_sector = if total_sectors > 512 { - total_sectors - 256 - } else { - total_sectors.saturating_sub(1).max(AVDP_SECTOR + 1) - }; - self.writer - .seek(SeekFrom::Start(reserve_sector as u64 * SECTOR_SIZE))?; - self.write_avdp()?; - - self.writer.flush()?; - Ok(()) - } - - // ── UDF structure writers ────────────────────────────────────────────── - - fn write_vrs(&mut self) -> io::Result<()> { - // BEA01 at sector 16 - let mut bea = [0u8; SECTOR_SIZE as usize]; - bea[0] = 0; // structure type - bea[1..6].copy_from_slice(b"BEA01"); - bea[6] = 1; // structure version - self.writer.write_all(&bea)?; - - // NSR03 at sector 17 (UDF 2.50) - let mut nsr = [0u8; SECTOR_SIZE as usize]; - nsr[0] = 0; - nsr[1..6].copy_from_slice(b"NSR03"); - nsr[6] = 1; - self.writer.write_all(&nsr)?; - - // TEA01 at sector 18 - let mut tea = [0u8; SECTOR_SIZE as usize]; - tea[0] = 0; - tea[1..6].copy_from_slice(b"TEA01"); - tea[6] = 1; - self.writer.write_all(&tea)?; - - Ok(()) - } - - fn write_vds(&mut self) -> io::Result<()> { - // Primary Volume Descriptor (tag 1) at sector 32 - let mut pvd = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut pvd, 1, VDS_START); - // Volume Identifier at offset 24 (32-byte d-string) - write_dstring(&mut pvd[24..56], &self.volume_id); - self.writer.write_all(&pvd)?; - - // Partition Descriptor (tag 5) at sector 33 - let mut pd = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut pd, 5, VDS_START + 1); - // Partition starting location at offset 188 - pd[188..192].copy_from_slice(&PARTITION_START.to_le_bytes()); - // Partition length (large enough for everything) - let part_len: u32 = 0xFFFF_FFFF; - pd[192..196].copy_from_slice(&part_len.to_le_bytes()); - self.writer.write_all(&pd)?; - - // Logical Volume Descriptor (tag 6) at sector 34 - let mut lvd = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut lvd, 6, VDS_START + 2); - // Logical block size at offset 212 - lvd[212..216].copy_from_slice(&2048u32.to_le_bytes()); - // Number of partition maps at offset 268 - lvd[268..272].copy_from_slice(&2u32.to_le_bytes()); - // Partition map 1: Type 1 (physical), 6 bytes - lvd[440] = 1; // type - lvd[441] = 6; // length - // Partition map 2: Type 2 (metadata), 64 bytes - lvd[446] = 2; // type - lvd[447] = 64; // length - // Entity ID for metadata partition - lvd[450..473].copy_from_slice(b"*UDF Metadata Partition"); - self.writer.write_all(&lvd)?; - - // Unallocated Space Descriptor (tag 7) at sector 35 - let mut usd = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut usd, 7, VDS_START + 3); - self.writer.write_all(&usd)?; - - // Implementation Use Volume Descriptor (tag 4) at sector 36 - let mut iuvd = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut iuvd, 4, VDS_START + 4); - self.writer.write_all(&iuvd)?; - - // Terminating Descriptor (tag 8) at sector 37 - let mut td = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut td, 8, VDS_START + 5); - self.writer.write_all(&td)?; - - Ok(()) - } - - fn write_avdp(&mut self) -> io::Result<()> { - let mut avdp = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut avdp, 2, AVDP_SECTOR); - // Main VDS extent_ad: {length, location} per UDF spec - avdp[16..20].copy_from_slice(&(6u32 * 2048).to_le_bytes()); // length - avdp[20..24].copy_from_slice(&VDS_START.to_le_bytes()); // location - // Reserve VDS extent_ad (same as main for simplicity) - avdp[24..28].copy_from_slice(&(6u32 * 2048).to_le_bytes()); // length - avdp[28..32].copy_from_slice(&VDS_START.to_le_bytes()); // location - self.writer.write_all(&avdp)?; - Ok(()) - } - - fn write_metadata_file_icb(&mut self) -> io::Result<()> { - // Extended File Entry (tag 266) at partition_start - // Points to metadata content at METADATA_START - let mut icb = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut icb, 266, PARTITION_START); - // ICB tag at offset 16 - icb[16..20].copy_from_slice(&0u32.to_le_bytes()); // prior recorded - icb[20..22].copy_from_slice(&0u16.to_le_bytes()); // strategy type - icb[22..24].copy_from_slice(&0u16.to_le_bytes()); // strategy parameter - // File type at offset 27: 250 = metadata file - icb[27] = 250; - // Information length at offset 56 - let meta_len: u64 = 12 * SECTOR_SIZE; // 12 sectors of metadata - icb[56..64].copy_from_slice(&meta_len.to_le_bytes()); - // Extended attribute length at offset 208 - icb[208..212].copy_from_slice(&0u32.to_le_bytes()); - // Allocation descriptor at offset 216: short_ad (length + position) - let ad_len = meta_len as u32; - let ad_pos = METADATA_START - PARTITION_START; // relative to partition - icb[216..220].copy_from_slice(&ad_len.to_le_bytes()); - icb[220..224].copy_from_slice(&ad_pos.to_le_bytes()); - self.writer.write_all(&icb)?; - Ok(()) - } - - fn write_fsd(&mut self) -> io::Result<()> { - let mut fsd = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut fsd, 256, FSD_SECTOR); - // Root Directory ICB (long_ad at offset 400) - let root_lba = ROOT_ICB_SECTOR - METADATA_START; // metadata-relative - fsd[400..404].copy_from_slice(&SECTOR_SIZE.to_le_bytes()[..4]); // extent length - fsd[404..408].copy_from_slice(&root_lba.to_le_bytes()); - self.writer.write_all(&fsd)?; - Ok(()) - } - - fn write_root_icb(&mut self) -> io::Result<()> { - let mut icb = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut icb, 266, ROOT_ICB_SECTOR); - icb[27] = 4; // file type: directory - let dir_len: u64 = SECTOR_SIZE; - icb[56..64].copy_from_slice(&dir_len.to_le_bytes()); - icb[208..212].copy_from_slice(&0u32.to_le_bytes()); - let ad_pos = ROOT_DIR_SECTOR - METADATA_START; - icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); - icb[220..224].copy_from_slice(&ad_pos.to_le_bytes()); - self.writer.write_all(&icb)?; - Ok(()) - } - - fn write_root_dir(&mut self) -> io::Result<()> { - let mut dir = [0u8; SECTOR_SIZE as usize]; - let mut offset = 0; - // Parent entry (.. points to self) - offset += write_fid( - &mut dir[offset..], - ROOT_ICB_SECTOR - METADATA_START, - "", - true, - ); - // BDMV directory entry - offset += write_fid( - &mut dir[offset..], - BDMV_ICB_SECTOR - METADATA_START, - "BDMV", - false, - ); - let _ = offset; - self.writer.write_all(&dir)?; - Ok(()) - } - - fn write_bdmv_icb(&mut self) -> io::Result<()> { - let mut icb = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut icb, 266, BDMV_ICB_SECTOR); - icb[27] = 4; // directory - let dir_len: u64 = SECTOR_SIZE; - icb[56..64].copy_from_slice(&dir_len.to_le_bytes()); - icb[208..212].copy_from_slice(&0u32.to_le_bytes()); - let ad_pos = BDMV_DIR_SECTOR - METADATA_START; - icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); - icb[220..224].copy_from_slice(&ad_pos.to_le_bytes()); - self.writer.write_all(&icb)?; - Ok(()) - } - - fn write_bdmv_dir(&mut self) -> io::Result<()> { - let mut dir = [0u8; SECTOR_SIZE as usize]; - let mut offset = 0; - offset += write_fid( - &mut dir[offset..], - ROOT_ICB_SECTOR - METADATA_START, - "", - true, - ); - offset += write_fid( - &mut dir[offset..], - STREAM_ICB_SECTOR - METADATA_START, - "STREAM", - false, - ); - let _ = offset; - self.writer.write_all(&dir)?; - Ok(()) - } - - fn write_stream_icb(&mut self) -> io::Result<()> { - let mut icb = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut icb, 266, STREAM_ICB_SECTOR); - icb[27] = 4; // directory - let dir_len: u64 = SECTOR_SIZE; - icb[56..64].copy_from_slice(&dir_len.to_le_bytes()); - icb[208..212].copy_from_slice(&0u32.to_le_bytes()); - let ad_pos = STREAM_DIR_SECTOR - METADATA_START; - icb[216..220].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); - icb[220..224].copy_from_slice(&ad_pos.to_le_bytes()); - self.writer.write_all(&icb)?; - Ok(()) - } - - fn write_stream_dir(&mut self) -> io::Result<()> { - let mut dir = [0u8; SECTOR_SIZE as usize]; - let mut offset = 0; - offset += write_fid( - &mut dir[offset..], - BDMV_ICB_SECTOR - METADATA_START, - "", - true, - ); - offset += write_fid( - &mut dir[offset..], - M2TS_ICB_SECTOR - METADATA_START, - &self.m2ts_name, - false, - ); - let _ = offset; - self.writer.write_all(&dir)?; - Ok(()) - } - - fn write_m2ts_icb(&mut self, file_size: u64) -> io::Result<()> { - let mut icb = [0u8; SECTOR_SIZE as usize]; - write_descriptor_tag(&mut icb, 266, M2TS_ICB_SECTOR); - icb[27] = 5; // file type: regular file - icb[56..64].copy_from_slice(&file_size.to_le_bytes()); - icb[208..212].copy_from_slice(&0u32.to_le_bytes()); - // Allocation: data starts at DATA_START in the physical partition. - // UDF short_ad is 30 bits for extent length (max 1 GB = 0x3FFFFFFF). - // For files > 1 GB, write multiple short_ad entries of 1 GB each plus remainder. - let data_offset = self.data_start_sector - PARTITION_START; - const MAX_EXTENT: u64 = 0x3FFF_FFFF; // 1 GB - 1 (30-bit max) - let mut remaining = file_size; - let mut ad_offset: usize = 216; - let mut sector_pos = data_offset; - while remaining > 0 && ad_offset + 8 <= SECTOR_SIZE as usize { - let extent_len = if remaining > MAX_EXTENT { - MAX_EXTENT - } else { - remaining - }; - icb[ad_offset..ad_offset + 4].copy_from_slice(&(extent_len as u32).to_le_bytes()); - icb[ad_offset + 4..ad_offset + 8].copy_from_slice(§or_pos.to_le_bytes()); - ad_offset += 8; // each short_ad is 8 bytes - let extent_sectors = extent_len.div_ceil(SECTOR_SIZE) as u32; - sector_pos += extent_sectors; - remaining -= extent_len; - } - self.writer.write_all(&icb)?; - Ok(()) - } -} - -// ── UDF primitives ───────────────────────────────────────────────────────── - -/// Write a UDF Descriptor Tag at the start of a sector. -fn write_descriptor_tag(buf: &mut [u8], tag_id: u16, sector: u32) { - buf[0..2].copy_from_slice(&tag_id.to_le_bytes()); - // Descriptor version: 3 (UDF 2.50) - buf[2..4].copy_from_slice(&3u16.to_le_bytes()); - // Tag location - buf[12..16].copy_from_slice(§or.to_le_bytes()); - // Compute CRC-CCITT over descriptor body (bytes 16+) - let body = &buf[16..]; - let body_len = body.len(); - let crc = udf_crc(body); - buf[8..10].copy_from_slice(&crc.to_le_bytes()); - // Descriptor CRC length - buf[10..12].copy_from_slice(&(body_len as u16).to_le_bytes()); - // Compute tag checksum: sum of bytes 0-3, 5-15 mod 256 - buf[4] = 0; // clear before computing - let checksum: u8 = buf[0..4] - .iter() - .chain(buf[5..16].iter()) - .fold(0u8, |acc, &b| acc.wrapping_add(b)); - buf[4] = checksum; -} - -/// UDF CRC-CCITT (CRC-16/ECMA-182 polynomial 0x11021). -fn udf_crc(data: &[u8]) -> u16 { - // CRC lookup table for polynomial 0x11021 - static CRC_TABLE: [u16; 256] = { - let mut table = [0u16; 256]; - let mut i = 0; - while i < 256 { - let mut crc = (i as u16) << 8; - let mut j = 0; - while j < 8 { - if crc & 0x8000 != 0 { - crc = (crc << 1) ^ 0x1021; - } else { - crc <<= 1; - } - j += 1; - } - table[i] = crc; - i += 1; - } - table - }; - let mut crc: u16 = 0; - for &byte in data { - crc = (crc << 8) ^ CRC_TABLE[((crc >> 8) as u8 ^ byte) as usize]; - } - crc -} - -/// Write a UDF d-string (compressed unicode string with length prefix). -fn write_dstring(buf: &mut [u8], s: &str) { - let max = buf.len() - 1; // last byte is length - let bytes = s.as_bytes(); - let len = bytes.len().min(max); - if len > 0 { - buf[0] = 8; // compression ID: 8 = Latin-1 - buf[1..1 + len].copy_from_slice(&bytes[..len]); - buf[buf.len() - 1] = (len + 1) as u8; // d-string length including comp ID - } -} - -/// Write a File Identifier Descriptor. Returns bytes written (4-byte aligned). -fn write_fid(buf: &mut [u8], icb_lba: u32, name: &str, is_parent: bool) -> usize { - // Tag 257 = File Identifier Descriptor - let name_bytes = name.as_bytes(); - let name_len = if is_parent { 0 } else { name_bytes.len() + 1 }; // +1 for comp ID - let fid_len = 38 + name_len; // fixed header + identifier - let padded = (fid_len + 3) & !3; // 4-byte align - - if padded > buf.len() { - return 0; - } - - // Tag - buf[0..2].copy_from_slice(&257u16.to_le_bytes()); - // File version number at offset 16 - buf[16..18].copy_from_slice(&1u16.to_le_bytes()); - // File characteristics at offset 18 - buf[18] = if is_parent { 0x0A } else { 0x02 }; // parent | directory - if !is_parent && !name.contains('.') { - buf[18] = 0x02; // directory - } else if !is_parent { - buf[18] = 0x00; // file - } - // ICB (long_ad at offset 20): extent length + location - buf[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); - buf[24..28].copy_from_slice(&icb_lba.to_le_bytes()); - // Identifier length at offset 36 - buf[36] = name_len as u8; - // Implementation use length at offset 37 - buf[37] = 0; - // File identifier at offset 38 - if !is_parent && !name_bytes.is_empty() { - buf[38] = 8; // compression ID: Latin-1 - buf[39..39 + name_bytes.len()].copy_from_slice(name_bytes); - } - - padded -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - /// Read a little-endian u16 from a byte slice at the given offset. - fn le_u16(data: &[u8], off: usize) -> u16 { - u16::from_le_bytes([data[off], data[off + 1]]) - } - - /// Read a little-endian u32 from a byte slice at the given offset. - #[allow(dead_code)] - fn le_u32(data: &[u8], off: usize) -> u32 { - u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) - } - - /// Read a little-endian u64 from a byte slice at the given offset. - fn le_u64(data: &[u8], off: usize) -> u64 { - u64::from_le_bytes([ - data[off], - data[off + 1], - data[off + 2], - data[off + 3], - data[off + 4], - data[off + 5], - data[off + 6], - data[off + 7], - ]) - } - - /// Get the sector at a given sector number from the output data. - fn sector(data: &[u8], num: u32) -> &[u8] { - let start = num as usize * SECTOR_SIZE as usize; - &data[start..start + SECTOR_SIZE as usize] - } - - #[test] - fn isowriter_creates_valid_udf() { - let buf = Cursor::new(Vec::new()); - let mut w = IsoWriter::new(buf, "TEST_VOL", "00001.m2ts"); - w.start().unwrap(); - w.write_data(&[0xAA; 4096]).unwrap(); - w.finish().unwrap(); - let data = w.writer.into_inner(); - - // AVDP at sector 256 should have tag ID = 2 - let avdp = sector(&data, AVDP_SECTOR); - assert_eq!(le_u16(avdp, 0), 2, "AVDP tag ID should be 2"); - - // VRS at sector 16 should contain "BEA01" - let vrs = sector(&data, VRS_START); - assert_eq!(&vrs[1..6], b"BEA01", "VRS sector 16 should contain BEA01"); - - // FSD at metadata sector should have tag ID = 256 - let fsd = sector(&data, FSD_SECTOR); - assert_eq!(le_u16(fsd, 0), 256, "FSD tag ID should be 256"); - } - - #[test] - fn isowriter_updates_file_size() { - let buf = Cursor::new(Vec::new()); - let mut w = IsoWriter::new(buf, "SIZE_TEST", "00001.m2ts"); - w.start().unwrap(); - - let test_data = vec![0x42u8; 8192]; // exactly 4 sectors - let written = w.write_data(&test_data).unwrap(); - assert_eq!(written, 8192); - - w.finish().unwrap(); - let data = w.writer.into_inner(); - - // Read m2ts ICB at M2TS_ICB_SECTOR and check information length at offset 56 - let icb = sector(&data, M2TS_ICB_SECTOR); - let file_size = le_u64(icb, 56); - assert_eq!( - file_size, 8192, - "m2ts ICB file size should match bytes written (8192), got {}", - file_size - ); - } - - #[test] - fn isowriter_with_names() { - let buf = Cursor::new(Vec::new()); - let mut w = IsoWriter::new(buf, "MY_DISC", "00042.m2ts"); - w.start().unwrap(); - w.write_data(&[0x00; 2048]).unwrap(); - w.finish().unwrap(); - let data = w.writer.into_inner(); - - // Check PVD (sector 32) volume_id at offset 24 as d-string - let pvd = sector(&data, VDS_START); - // d-string: byte 0 = compression ID (8), then ASCII chars - assert_eq!(pvd[24], 8, "PVD volume_id compression ID should be 8"); - assert_eq!( - &pvd[25..32], - b"MY_DISC", - "PVD should contain volume_id 'MY_DISC'" - ); - - // Check STREAM directory (sector 266) for m2ts filename in FID - let stream_dir = sector(&data, STREAM_DIR_SECTOR); - // The FID for the m2ts file should contain the filename after the parent entry. - // Search for "00042.m2ts" in the sector data - let name = b"00042.m2ts"; - let found = stream_dir.windows(name.len()).any(|w| w == name); - assert!( - found, - "STREAM directory should contain m2ts filename '00042.m2ts'" - ); - } - - #[test] - fn isowriter_empty_content() { - let buf = Cursor::new(Vec::new()); - let mut w = IsoWriter::new(buf, "EMPTY", "00001.m2ts"); - w.start().unwrap(); - // No data written - w.finish().unwrap(); - let data = w.writer.into_inner(); - - // Should still have valid UDF structure - // AVDP at sector 256 - let avdp = sector(&data, AVDP_SECTOR); - assert_eq!( - le_u16(avdp, 0), - 2, - "AVDP tag should be present even with no data" - ); - - // VRS - let vrs = sector(&data, VRS_START); - assert_eq!(&vrs[1..6], b"BEA01"); - - // FSD - let fsd = sector(&data, FSD_SECTOR); - assert_eq!(le_u16(fsd, 0), 256); - - // m2ts ICB should show 0 file size - let icb = sector(&data, M2TS_ICB_SECTOR); - let file_size = le_u64(icb, 56); - assert_eq!(file_size, 0, "empty content should have 0 file size"); - - // Output should be at least DATA_START sectors (the header structure) - assert!( - data.len() >= DATA_START as usize * SECTOR_SIZE as usize, - "output too small for valid UDF structure" - ); - } -} diff --git a/src/mux/m2ts.rs b/src/mux/m2ts.rs index 9b318a5..517666e 100644 --- a/src/mux/m2ts.rs +++ b/src/mux/m2ts.rs @@ -1,9 +1,9 @@ //! M2tsStream — BD transport stream with embedded metadata header. //! -//! Write: prepends FMKV metadata header, then passes through BD-TS bytes. -//! Read: extracts metadata header (or scans PMT), then yields BD-TS bytes. +//! Write: prepends FMKV metadata header, then muxes PES frames into BD-TS. +//! Read: extracts metadata header (or scans PMT), then demuxes BD-TS into PES frames. -use super::{meta, ts, IOStream}; +use super::{meta, ts}; use crate::disc::{DiscTitle, Stream as DiscStream}; use std::io::{self, Read, Write}; @@ -14,8 +14,7 @@ const SCAN_SIZE: usize = 1024 * 1024; enum Mode { Write { - writer: Box<dyn Write>, - header_written: bool, + muxer: super::tsmux::TsMuxer<Box<dyn Write>>, }, Read { reader: Box<dyn Read>, @@ -41,9 +40,6 @@ fn read_fill(r: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> { pub struct M2tsStream { disc_title: DiscTitle, mode: Mode, - finished: bool, - /// Content size in bytes (file size minus header), set for read mode. - content_size: Option<u64>, // PES support demuxer: Option<ts::TsDemuxer>, parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>, @@ -55,23 +51,36 @@ pub struct M2tsStream { } impl M2tsStream { - /// Create for writing. Metadata header is written on first write(). - pub fn new(writer: impl Write + 'static) -> Self { - Self { - disc_title: DiscTitle::empty(), - mode: Mode::Write { - writer: Box::new(writer), - header_written: false, - }, - finished: false, - content_size: None, + /// Create for writing PES frames → BD-TS output. + /// Writes FMKV metadata header, then muxes PES frames into BD transport stream. + pub fn create(mut writer: impl Write + 'static, title: &DiscTitle) -> io::Result<Self> { + // Write FMKV metadata header + if !title.streams.is_empty() { + let m = meta::M2tsMeta::from_title(title); + meta::write_header(&mut writer, &m)?; + } + let pids: Vec<u16> = title.streams.iter().map(|s| match s { + DiscStream::Video(v) => v.pid, + DiscStream::Audio(a) => a.pid, + DiscStream::Subtitle(s) => s.pid, + }).collect(); + let boxed: Box<dyn Write> = Box::new(writer); + let mut muxer = super::tsmux::TsMuxer::new(boxed, &pids); + for (i, cp) in title.codec_privates.iter().enumerate() { + if let Some(data) = cp { + muxer.set_codec_private(i, data.clone()); + } + } + Ok(Self { + disc_title: title.clone(), + mode: Mode::Write { muxer }, demuxer: None, parsers: Vec::new(), pending_frames: std::collections::VecDeque::new(), pid_to_track: Vec::new(), pes_eof: false, stored_codec_privates: Vec::new(), - } + }) } fn setup_pes(streams: &[DiscStream]) -> PesSetup { @@ -91,12 +100,6 @@ impl M2tsStream { (pids, parsers, pid_to_track) } - /// Set stream metadata. Returns self for chaining. - pub fn meta(mut self, dt: &DiscTitle) -> Self { - self.disc_title = dt.clone(); - self - } - /// Open an M2TS stream for reading. Takes any Read source — file, pipe, socket. /// /// Tries FMKV metadata header first. Falls back to PMT scan of first 1 MB. @@ -118,8 +121,6 @@ impl M2tsStream { return Ok(Self { disc_title: title.clone(), mode: Mode::Read { reader: chain }, - finished: false, - content_size: None, demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) }, parsers, pending_frames: std::collections::VecDeque::new(), @@ -131,7 +132,7 @@ impl M2tsStream { // No FMKV header — scan head for PMT let streams = ts::scan_streams(&head) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no streams found"))?; + .ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?; let (pids, parsers, pid_to_track) = Self::setup_pes(&streams); @@ -145,8 +146,6 @@ impl M2tsStream { ..DiscTitle::empty() }, mode: Mode::Read { reader: chain }, - finished: false, - content_size: None, demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) }, parsers, pending_frames: std::collections::VecDeque::new(), @@ -167,7 +166,7 @@ impl crate::pes::Stream for M2tsStream { loop { let reader = match &mut self.mode { Mode::Read { reader } => reader, - _ => return Err(io::Error::new(io::ErrorKind::Unsupported, "not in read mode")), + _ => return Err(crate::error::Error::StreamWriteOnly.into()), }; let mut buf = vec![0u8; 192 * 1024]; let n = reader.read(&mut buf)?; @@ -197,11 +196,19 @@ impl crate::pes::Stream for M2tsStream { } } - fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> { - Err(io::Error::new(io::ErrorKind::Unsupported, "use M2tsOutputStream for writing")) + fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> { + match &mut self.mode { + Mode::Write { muxer } => muxer.write_frame(frame.track, frame.pts, &frame.data), + Mode::Read { .. } => Err(crate::error::Error::StreamReadOnly.into()), + } } - fn finish(&mut self) -> io::Result<()> { Ok(()) } + fn finish(&mut self) -> io::Result<()> { + match &mut self.mode { + Mode::Write { muxer } => muxer.finish(), + Mode::Read { .. } => Ok(()), + } + } fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title } @@ -231,68 +238,3 @@ impl crate::pes::Stream for M2tsStream { } } -impl IOStream for M2tsStream { - fn info(&self) -> &DiscTitle { - &self.disc_title - } - - fn finish(&mut self) -> io::Result<()> { - if self.finished { - return Ok(()); - } - self.finished = true; - if let Mode::Write { ref mut writer, .. } = self.mode { - writer.flush() - } else { - Ok(()) - } - } - - fn total_bytes(&self) -> Option<u64> { - self.content_size - } -} - -impl Write for M2tsStream { - fn write(&mut self, buf: &[u8]) -> io::Result<usize> { - match self.mode { - Mode::Write { - ref mut writer, - ref mut header_written, - } => { - if !*header_written { - if !self.disc_title.streams.is_empty() { - let m = meta::M2tsMeta::from_title(&self.disc_title); - meta::write_header(&mut *writer, &m)?; - } - *header_written = true; - } - writer.write(buf) - } - Mode::Read { .. } => Err(io::Error::new( - io::ErrorKind::Unsupported, - "stream opened for reading", - )), - } - } - - fn flush(&mut self) -> io::Result<()> { - if let Mode::Write { ref mut writer, .. } = self.mode { - writer.flush() - } else { - Ok(()) - } - } -} - -impl Read for M2tsStream { - fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { - match self.mode { - Mode::Read { ref mut reader } => reader.read(buf), - Mode::Write { .. } => Err(io::Error::new( - io::ErrorKind::Unsupported, - "stream opened for writing", - )), - } - } -} diff --git a/src/mux/meta.rs b/src/mux/meta.rs index 80b5070..ca0c594 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -247,7 +247,7 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> { r.read_exact(&mut len_buf)?; let json_len = u32::from_be_bytes(len_buf) as usize; if json_len > MAX_JSON_SIZE { - return Err(io::Error::new(io::ErrorKind::InvalidData, "FMKV JSON too large")); + return Err(crate::error::Error::NoMetadata.into()); } let mut json_buf = vec![0u8; json_len]; diff --git a/src/mux/mkvout.rs b/src/mux/mkvout.rs deleted file mode 100644 index f0324da..0000000 --- a/src/mux/mkvout.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! MKV output stream — accepts PES frames, writes Matroska container. -//! -//! Implements OutputStream. Takes PES frames directly — no TS demuxing needed. -//! Creates the MKV muxer once codec_private is provided for all tracks. - -use super::mkv::{MkvMuxer, MkvTrack}; -use super::WriteSeek; -use crate::disc::DiscTitle; -use crate::pes::PesFrame; -use std::io; - -pub struct MkvOutputStream { - muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>, - title: DiscTitle, -} - -impl MkvOutputStream { - /// Create an MKV output stream. Codec privates come from title.codec_privates. - pub fn create( - writer: Box<dyn WriteSeek>, - title: &DiscTitle, - ) -> io::Result<Self> { - let mut tracks = Vec::new(); - for (idx, s) in title.streams.iter().enumerate() { - let mut track = match s { - crate::disc::Stream::Video(v) => MkvTrack::video(v), - crate::disc::Stream::Audio(a) => MkvTrack::audio(a), - crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s), - }; - if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) { - track.codec_private = Some(cp.clone()); - } - tracks.push(track); - } - - let muxer = MkvMuxer::new_with_chapters( - writer, - &tracks, - Some(&title.playlist), - title.duration_secs, - &title.chapters, - )?; - - Ok(Self { muxer: Some(muxer), title: title.clone() }) - } -} - -impl crate::pes::Stream for MkvOutputStream { - fn read(&mut self) -> io::Result<Option<PesFrame>> { - Err(io::Error::new(io::ErrorKind::Unsupported, "MKV output is write-only")) - } - - fn write(&mut self, frame: &PesFrame) -> io::Result<()> { - if let Some(ref mut muxer) = self.muxer { - muxer.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data) - } else { - Ok(()) - } - } - - fn finish(&mut self) -> io::Result<()> { - if let Some(muxer) = self.muxer.take() { - muxer.finish() - } else { - Ok(()) - } - } - - fn info(&self) -> &DiscTitle { &self.title } -} diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index 06ea809..647eac6 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -1,14 +1,10 @@ //! MkvStream — Matroska container stream. //! -//! Write: BD-TS bytes in → demux → codec parse → MKV container out. -//! Read: MKV container in → extract frames → wrap as BD-TS → bytes out. +//! Read: MKV container → demux EBML → PES frames out. +//! Write: PES frames in → MKV mux → Matroska container. -use super::codec::{self, CodecParser}; -use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE}; use super::mkv::{MkvMuxer, MkvTrack}; -use super::ts::TsDemuxer; -use super::{ebml, IOStream, WriteSeek}; -use std::io::Seek; +use super::{ebml, WriteSeek}; type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>; @@ -19,44 +15,19 @@ fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> { } use crate::disc::*; -use std::io::{self, Read, Write}; - -/// Lookahead buffer for codec header detection (5 MB default). -const DEFAULT_MAX_BUFFER: usize = DEFAULT_LOOKAHEAD_SIZE; - -#[derive(Debug, Clone, Copy, PartialEq)] -enum WritePhase { - Scanning, - Streaming, -} - -struct WriteState { - demuxer: TsDemuxer, - muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>, - writer: Option<Box<dyn WriteSeek>>, - parsers: Vec<(u16, Box<dyn CodecParser>)>, - pid_to_track: Vec<(u16, usize)>, - tracks: Vec<MkvTrack>, - lookahead: LookaheadBuffer, - phase: WritePhase, - video_pending: usize, -} +use std::io::{self, Read}; struct ReadState { reader: Box<dyn Read>, - buf: Vec<u8>, - pos: usize, - len: usize, cluster_ts_ms: i64, /// Codec private data per track (track_number, hvcC/avcC bytes). - /// Emitted as Annex B NALs before first frame of each video track. codec_privates: Vec<(u16, Vec<u8>)>, - /// Tracks that have already had their codec_private emitted. - initialized_tracks: Vec<u16>, } enum Mode { - Write(Box<WriteState>), + Write { + muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>, + }, Read(ReadState), } @@ -64,95 +35,52 @@ enum Mode { pub struct MkvStream { disc_title: DiscTitle, mode: Mode, - max_buffer: usize, - finished: bool, - /// File size in bytes, set for read mode. - file_size: Option<u64>, } impl MkvStream { - /// Create for writing. BD-TS bytes written to this stream produce MKV output. - pub fn new(writer: impl Write + Seek + 'static) -> Self { - Self { - disc_title: DiscTitle::empty(), - mode: Mode::Write(Box::new(WriteState { - demuxer: TsDemuxer::new(&[]), - muxer: None, - writer: Some(Box::new(writer)), - parsers: Vec::new(), - pid_to_track: Vec::new(), - tracks: Vec::new(), - lookahead: LookaheadBuffer::new(DEFAULT_MAX_BUFFER), - phase: WritePhase::Scanning, - video_pending: 0, - })), - max_buffer: DEFAULT_MAX_BUFFER, - finished: false, - file_size: None, - } - } - - /// Set stream metadata. Returns self for chaining. - pub fn meta(mut self, dt: &DiscTitle) -> Self { - if let Mode::Write(ref mut ws) = self.mode { - let mut pids = Vec::new(); - for s in &dt.streams { - let (pid, track, parser) = match s { - crate::disc::Stream::Video(v) => { - // Only count primary video as pending — secondary streams - // (Dolby Vision EL, PiP) may never produce codec headers - if !v.secondary { - ws.video_pending += 1; - } - (v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec)) - } - crate::disc::Stream::Audio(a) => { - (a.pid, MkvTrack::audio(a), codec::parser_for_codec(a.codec)) - } - crate::disc::Stream::Subtitle(s) => ( - s.pid, - MkvTrack::subtitle(s), - codec::parser_for_codec_with_data(s.codec, s.codec_data.clone()), - ), - }; - let idx = ws.tracks.len(); - pids.push(pid); - ws.pid_to_track.push((pid, idx)); - ws.parsers.push((pid, parser)); - ws.tracks.push(track); + /// Create for writing PES frames → MKV container. + /// Codec privates come from title.codec_privates (populated by input stream). + pub fn create( + writer: Box<dyn WriteSeek>, + title: &DiscTitle, + ) -> io::Result<Self> { + let mut tracks = Vec::new(); + for (idx, s) in title.streams.iter().enumerate() { + let mut track = match s { + crate::disc::Stream::Video(v) => MkvTrack::video(v), + crate::disc::Stream::Audio(a) => MkvTrack::audio(a), + crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s), + }; + if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) { + track.codec_private = Some(cp.clone()); } - ws.demuxer = TsDemuxer::new(&pids); + tracks.push(track); } - self.disc_title = dt.clone(); - self + + let muxer = MkvMuxer::new_with_chapters( + writer, + &tracks, + Some(&title.playlist), + title.duration_secs, + &title.chapters, + )?; + + Ok(Self { + disc_title: title.clone(), + mode: Mode::Write { muxer: Some(muxer) }, + }) } - /// Set lookahead buffer size. Returns self. - pub fn max_buffer(mut self, size: usize) -> Self { - self.max_buffer = size; - if let Mode::Write(ref mut ws) = self.mode { - ws.lookahead = LookaheadBuffer::new(size); - } - self - } - - /// Open an MKV file for reading. + /// Open an MKV file for reading → PES frames. pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> { let (disc_title, codec_privates) = parse_mkv_header(&mut reader)?; Ok(Self { disc_title, mode: Mode::Read(ReadState { reader: Box::new(reader), - buf: Vec::new(), - pos: 0, - len: 0, cluster_ts_ms: 0, codec_privates, - initialized_tracks: Vec::new(), }), - max_buffer: 0, - finished: false, - file_size: None, }) } } @@ -161,7 +89,7 @@ impl crate::pes::Stream for MkvStream { fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { let rs = match self.mode { Mode::Read(ref mut rs) => rs, - Mode::Write(_) => return Err(io::Error::new(io::ErrorKind::Unsupported, "write-only")), + Mode::Write { .. } => return Err(crate::error::Error::StreamWriteOnly.into()), }; loop { @@ -209,11 +137,22 @@ impl crate::pes::Stream for MkvStream { } } - fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> { - Err(io::Error::new(io::ErrorKind::Unsupported, "use MkvOutputStream for writing")) + fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> { + match &mut self.mode { + Mode::Write { muxer: Some(ref mut m) } => m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data), + Mode::Write { muxer: None } => Ok(()), + Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()), + } } - fn finish(&mut self) -> io::Result<()> { Ok(()) } + fn finish(&mut self) -> io::Result<()> { + if let Mode::Write { ref mut muxer } = self.mode { + if let Some(m) = muxer.take() { + m.finish()?; + } + } + Ok(()) + } fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title } @@ -234,256 +173,6 @@ impl crate::pes::Stream for MkvStream { } } -impl IOStream for MkvStream { - fn info(&self) -> &DiscTitle { - &self.disc_title - } - - fn finish(&mut self) -> io::Result<()> { - if self.finished { - return Ok(()); - } - self.finished = true; - if let Mode::Write(ref mut ws) = self.mode { - // Flush remaining PES packets - if let Some(ref mut muxer) = ws.muxer { - for pes in &ws.demuxer.flush() { - write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?; - } - } - // Write cues and finalize - if let Some(muxer) = ws.muxer.take() { - muxer.finish()?; - } - } - Ok(()) - } - - fn total_bytes(&self) -> Option<u64> { - self.file_size - } -} - -// ── Write ────────────────────────────────────────────────────── - -impl Write for MkvStream { - fn write(&mut self, buf: &[u8]) -> io::Result<usize> { - let dt = &self.disc_title; - let ws = match self.mode { - Mode::Write(ref mut ws) => ws, - Mode::Read(_) => { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "stream opened for reading", - )) - } - }; - - match ws.phase { - WritePhase::Scanning => { - // Feed demuxer for codec detection - let packets = ws.demuxer.feed(buf); - for pes in &packets { - if let Some((_, p)) = ws.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) { - let _ = p.parse(pes); - } - } - - let state = ws.lookahead.push(buf); - - // Check if all video codec headers found - if check_codec_private(ws) { - ws.lookahead.mark_ready(); - begin_streaming(ws, dt)?; - return Ok(buf.len()); - } - - match state { - LookaheadState::Collecting | LookaheadState::Ready => Ok(buf.len()), - LookaheadState::Overflow => Err(io::Error::new( - io::ErrorKind::OutOfMemory, - "no codec headers found within lookahead buffer", - )), - } - } - WritePhase::Streaming => { - let packets = ws.demuxer.feed(buf); - if let Some(ref mut muxer) = ws.muxer { - for pes in &packets { - write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?; - } - } - Ok(buf.len()) - } - } - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -// ── Read ─────────────────────────────────────────────────────── - -impl Read for MkvStream { - fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { - let rs = match self.mode { - Mode::Read(ref mut rs) => rs, - Mode::Write(_) => { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "stream opened for writing", - )) - } - }; - - // Drain internal buffer first - if rs.pos < rs.len { - let n = (rs.len - rs.pos).min(buf.len()); - buf[..n].copy_from_slice(&rs.buf[rs.pos..rs.pos + n]); - rs.pos += n; - return Ok(n); - } - - // Read next element from MKV - loop { - let (id, size, _) = match ebml::read_element_header(&mut rs.reader) { - Ok(h) => h, - Err(_) => return Ok(0), - }; - - match id { - ebml::CLUSTER => continue, - ebml::CLUSTER_TIMESTAMP => { - rs.cluster_ts_ms = ebml::read_uint_val(&mut rs.reader, size as usize)? as i64; - continue; - } - ebml::SIMPLE_BLOCK => { - let block = ebml::read_binary_val(&mut rs.reader, size as usize)?; - if block.len() < 4 { - continue; - } - - let (track, vl) = block_vint(&block); - if vl + 3 > block.len() { - continue; - } - - let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]); - let frame = &block[vl + 3..]; - let pts_ms = rs.cluster_ts_ms + rel_ts as i64; - let tnum = track as u16; - - rs.buf.clear(); - - // First frame of a track: emit codec_private as Annex B NALs - if !rs.initialized_tracks.contains(&tnum) { - rs.initialized_tracks.push(tnum); - if let Some((_, cp)) = rs.codec_privates.iter().find(|(t, _)| *t == tnum) { - let annex_b = hvcc_to_annex_b(cp); - if !annex_b.is_empty() { - frame_to_ts(&mut rs.buf, tnum, pts_ms, &annex_b); - } - } - } - - frame_to_ts(&mut rs.buf, tnum, pts_ms, frame); - rs.pos = 0; - rs.len = rs.buf.len(); - - if rs.len > 0 { - let n = rs.len.min(buf.len()); - buf[..n].copy_from_slice(&rs.buf[..n]); - rs.pos = n; - return Ok(n); - } - } - _ => { - if size != u64::MAX && size > 0 { - skip_bytes(&mut rs.reader, size)?; - } - } - } - } - } -} - -// ── Write internals ──────────────────────────────────────────── - -fn check_codec_private(ws: &mut WriteState) -> bool { - if ws.video_pending == 0 { - return true; - } - for (pid, parser) in &ws.parsers { - if let Some(cp) = parser.codec_private() { - if let Some((_, idx)) = ws.pid_to_track.iter().find(|(p, _)| p == pid) { - if ws.tracks[*idx].codec_private.is_none() { - ws.tracks[*idx].codec_private = Some(cp); - ws.video_pending -= 1; - } - } - } - } - ws.video_pending == 0 -} - -fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> { - let writer = ws - .writer - .take() - .ok_or_else(|| io::Error::other("writer already consumed"))?; - - ws.muxer = Some(MkvMuxer::new_with_chapters( - writer, - &ws.tracks, - Some(&dt.playlist), - dt.duration_secs, - &dt.chapters, - )?); - ws.phase = WritePhase::Streaming; - - // Re-parse buffered data through a fresh demuxer, then reset the main - // demuxer so stale PES assembler state from scanning doesn't cause - // duplicate or incomplete packets during streaming. - let pids: Vec<u16> = ws.pid_to_track.iter().map(|(pid, _)| *pid).collect(); - let buffered = ws.lookahead.drain(); - if !buffered.is_empty() { - let mut temp = TsDemuxer::new(&pids); - let packets = temp.feed(&buffered); - if let Some(ref mut muxer) = ws.muxer { - for pes in &packets { - write_pes(&ws.pid_to_track, &mut ws.parsers, muxer, pes)?; - } - } - } - // Transfer remainder bytes from old demuxer to new one. - // Preserves 192-byte packet alignment across the reset. - let remainder = ws.demuxer.take_remainder(); - ws.demuxer = TsDemuxer::new(&pids); - ws.demuxer.set_remainder(remainder); - Ok(()) -} - -fn write_pes( - pid_to_track: &[(u16, usize)], - parsers: &mut [(u16, Box<dyn CodecParser>)], - muxer: &mut MkvMuxer<Box<dyn WriteSeek>>, - pes: &super::ts::PesPacket, -) -> io::Result<()> { - let idx = match pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) { - Some((_, idx)) => *idx, - None => return Ok(()), - }; - let parser = match parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) { - Some((_, p)) => p, - None => return Ok(()), - }; - for frame in parser.parse(pes) { - muxer.write_frame(idx, frame.pts_ns, frame.keyframe, &frame.data)?; - } - Ok(()) -} - // ── MKV header parsing (read side) ──────────────────────────── /// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>) @@ -498,19 +187,16 @@ fn parse_mkv_header( let (id, size, _) = ebml::read_element_header(r)?; if id != ebml::EBML { - return Err(io::Error::new(io::ErrorKind::InvalidData, "not EBML")); + return Err(crate::error::Error::MkvInvalid.into()); } if size > i64::MAX as u64 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "EBML header too large", - )); + return Err(crate::error::Error::MkvInvalid.into()); } skip_bytes(r, size)?; let (id, _, _) = ebml::read_element_header(r)?; if id != ebml::SEGMENT { - return Err(io::Error::new(io::ErrorKind::InvalidData, "no Segment")); + return Err(crate::error::Error::MkvInvalid.into()); } let (mut got_info, mut got_tracks) = (false, false); @@ -647,7 +333,7 @@ fn parse_track( SampleRate::S48 }; - // Map MKV track numbers to BD-TS PIDs (same mapping as frame_to_ts) + // Map MKV track numbers to BD-TS PIDs let ts_pid = if tnum == 1 { 0x1011 } else { 0x1100 + (tnum - 2) }; let stream = match ttype { @@ -685,8 +371,6 @@ fn parse_track( Ok((stream, tnum, codec_priv)) } -// ── BD-TS frame wrapping (read side) ────────────────────────── - fn block_vint(d: &[u8]) -> (u64, usize) { if d.is_empty() { return (0, 0); @@ -705,127 +389,3 @@ fn block_vint(d: &[u8]) -> (u64, usize) { } (0, 1) // Unsupported 5+ byte VINT — treat as track 0 } - -/// Convert HEVCDecoderConfigurationRecord (hvcC) to Annex B NAL units. -/// Extracts VPS, SPS, PPS arrays and prefixes each with 0x00000001. -fn hvcc_to_annex_b(hvcc: &[u8]) -> Vec<u8> { - // hvcC format (ISO 14496-15): - // byte 0: configurationVersion (1) - // bytes 1-21: profile/level info - // byte 22: numOfArrays - // For each array: - // byte 0: array_completeness(1) + reserved(1) + NAL_unit_type(6) - // bytes 1-2: numNalus (big-endian u16) - // For each NAL: - // bytes 0-1: nalUnitLength (big-endian u16) - // bytes 2..: NAL data - if hvcc.len() < 23 { - return Vec::new(); - } - let num_arrays = hvcc[22] as usize; - let mut pos = 23; - let mut out = Vec::new(); - - for _ in 0..num_arrays { - if pos + 3 > hvcc.len() { - break; - } - pos += 1; // skip array_completeness + NAL type byte - let num_nalus = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; - pos += 2; - for _ in 0..num_nalus { - if pos + 2 > hvcc.len() { - break; - } - let nal_len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; - pos += 2; - if pos + nal_len > hvcc.len() { - break; - } - out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); - out.extend_from_slice(&hvcc[pos..pos + nal_len]); - pos += nal_len; - } - } - out -} - -fn frame_to_ts(out: &mut Vec<u8>, track: u16, pts_ms: i64, data: &[u8]) { - let pid = if track == 1 { - 0x1011 - } else { - 0x1100 + (track - 2) - }; - let is_video = track <= 1 || pid == 0x1011; - let stream_id: u8 = if is_video { 0xE0 } else { 0xBD }; - let pts = encode_pts(pts_ms * 90); - let hdr = [0x00, 0x00, 0x01, stream_id, 0x00, 0x00, 0x80, 0x80, 0x05]; - - let mut pes = Vec::with_capacity(hdr.len() + pts.len() + data.len()); - pes.extend_from_slice(&hdr); - pes.extend_from_slice(&pts); - - // Video: convert MKV length-prefixed NALs to Annex B start codes - if is_video && data.len() > 4 { - let mut pos = 0; - while pos + 4 <= data.len() { - let nal_len = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize; - pos += 4; - if nal_len == 0 || pos + nal_len > data.len() { - break; - } - pes.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); - pes.extend_from_slice(&data[pos..pos + nal_len]); - pos += nal_len; - } - } else { - pes.extend_from_slice(data); - } - - let mut off = 0; - let mut pusi = true; - while off < pes.len() { - let mut pkt = [0u8; 192]; - pkt[4] = 0x47; - pkt[5] = (pid >> 8) as u8 & 0x1F; - if pusi { - pkt[5] |= 0x40; - pusi = false; - } - pkt[6] = pid as u8; - - let space = 184; - let rem = pes.len() - off; - let n = rem.min(space); - - if n < space { - let pad = space - n; - pkt[7] = 0x30; // AF + payload - pkt[8] = pad as u8; - if pad > 1 { - pkt[9] = 0x00; - } - for byte in pkt.iter_mut().take((8 + pad).min(192)).skip(10) { - *byte = 0xFF; - } - pkt[8 + pad..8 + pad + n].copy_from_slice(&pes[off..off + n]); - } else { - pkt[7] = 0x10; // payload only - pkt[8..8 + n].copy_from_slice(&pes[off..off + n]); - } - - out.extend_from_slice(&pkt); - off += n; - } -} - -fn encode_pts(pts: i64) -> [u8; 5] { - let p = pts as u64; - [ - 0x21 | ((p >> 29) & 0x0E) as u8, - ((p >> 22) & 0xFF) as u8, - 0x01 | ((p >> 14) & 0xFE) as u8, - ((p >> 7) & 0xFF) as u8, - 0x01 | ((p << 1) & 0xFE) as u8, - ] -} diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 7b8d11e..6c5565c 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -1,37 +1,29 @@ //! Stream-based I/O pipeline. //! -//! All formats are streams. Two URLs, left reads, right writes: +//! All formats are PES streams. Read from a format → PES frames. +//! Write PES frames → a format. //! //! ```text -//! freemkv disc:// mkv://Dune.mkv -//! freemkv m2ts://Dune.m2ts mkv://Dune.mkv -//! freemkv disc:// network://10.0.0.1:9000 -//! freemkv disc:// stdio:// -//! freemkv stdio:// mkv://Dune.mkv -//! ``` -//! -//! Streams implement `IOStream` for uniform handling: -//! -//! ```text -//! let mut input = open_input("disc://", &opts)?; -//! let mut output = open_output("mkv://Dune.mkv", input.info())?; -//! io::copy(&mut *input, &mut *output)?; +//! let mut input = input("iso://Disc.iso", &opts)?; +//! let title = input.info().clone(); +//! let mut output = output("mkv://Dune.mkv", &title)?; +//! while let Ok(Some(frame)) = input.read() { +//! output.write(&frame)?; +//! } //! output.finish()?; //! ``` +//! +//! For disc→ISO (raw sector copy), use `Disc::copy()` instead. pub mod codec; pub mod disc; pub mod ebml; pub mod iso; -mod isowriter; -pub mod lookahead; -mod m2ts; -pub mod meta; pub mod mkv; -pub mod mkvout; -pub mod pesout; pub mod tsmux; pub mod tsreader; +mod m2ts; +pub mod meta; mod mkvstream; pub mod network; pub mod null; @@ -40,40 +32,16 @@ pub mod resolve; pub mod stdio; pub mod ts; -pub use disc::{DiscOpenResult, DiscStream}; -pub use iso::{IsoSectorReader, IsoStream}; +pub use disc::DiscStream; +pub use iso::IsoSectorReader; pub use m2ts::M2tsStream; pub use mkvstream::MkvStream; pub use network::NetworkStream; pub use null::NullStream; -pub use resolve::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl}; +pub use resolve::{input, output, parse_url, InputOptions, StreamUrl}; pub use stdio::StdioStream; -use crate::disc::DiscTitle; -use std::io::{self, Read, Seek, Write}; - -/// Common interface for all stream types. -/// -/// A stream can be opened for reading or created for writing. -/// Calling the unsupported direction returns an error. -pub trait IOStream: Read + Write { - /// Get stream metadata. - fn info(&self) -> &DiscTitle; - - /// Finalize the stream (flush, write index/cues, close). - fn finish(&mut self) -> io::Result<()>; - - /// Total content size in bytes, if known. Used for progress display. - fn total_bytes(&self) -> Option<u64> { - None - } - - /// Decryption keys for this stream. Default: no encryption. - /// Overridden by DiscStream and IsoStream for AACS/CSS. - fn keys(&self) -> crate::decrypt::DecryptKeys { - crate::decrypt::DecryptKeys::None - } -} +use std::io::{Seek, Write}; // WriteSeek — used internally by MKV muxer (container format requires seeking). pub trait WriteSeek: Write + Seek {} diff --git a/src/mux/network.rs b/src/mux/network.rs index 9bcfa09..c16d89c 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -60,12 +60,7 @@ impl NetworkStream { // Read FMKV metadata header let disc_title = meta::read_header(&mut reader)? - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "no FMKV metadata header from sender", - ) - })? + .ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })? .to_title(); Ok(Self { @@ -79,7 +74,7 @@ impl crate::pes::Stream for NetworkStream { fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { match &mut self.mode { Mode::Read { reader } => crate::pes::PesFrame::deserialize(reader), - _ => Err(io::Error::new(io::ErrorKind::Unsupported, "network opened for writing")), + _ => Err(crate::error::Error::StreamWriteOnly.into()), } } fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> { @@ -94,7 +89,7 @@ impl crate::pes::Stream for NetworkStream { } frame.serialize(writer) } - _ => Err(io::Error::new(io::ErrorKind::Unsupported, "network opened for reading")), + _ => Err(crate::error::Error::StreamReadOnly.into()), } } fn finish(&mut self) -> io::Result<()> { diff --git a/src/mux/null.rs b/src/mux/null.rs index 61c83c8..68a4b8e 100644 --- a/src/mux/null.rs +++ b/src/mux/null.rs @@ -1,114 +1,24 @@ -//! NullStream — discards all data. Write-only. For benchmarking. +//! NullStream — discards all data. Write-only PES sink. For benchmarking. -use super::IOStream; use crate::disc::DiscTitle; -use std::io::{self, Read, Write}; +use std::io; -/// Null stream — accepts writes, discards data. For benchmarking rip speed. +/// Null stream — accepts PES writes, discards data. For benchmarking rip speed. pub struct NullStream { disc_title: DiscTitle, - bytes_written: u64, -} - -impl Default for NullStream { - fn default() -> Self { - Self::new() - } } impl NullStream { - pub fn new() -> Self { + pub fn new(title: &DiscTitle) -> Self { Self { - disc_title: DiscTitle::empty(), - bytes_written: 0, + disc_title: title.clone(), } } - - pub fn meta(mut self, dt: &DiscTitle) -> Self { - self.disc_title = dt.clone(); - self - } - - pub fn bytes_written(&self) -> u64 { - self.bytes_written - } } -impl IOStream for NullStream { - fn info(&self) -> &DiscTitle { - &self.disc_title - } - fn finish(&mut self) -> io::Result<()> { - Ok(()) - } -} - -impl Write for NullStream { - fn write(&mut self, buf: &[u8]) -> io::Result<usize> { - self.bytes_written += buf.len() as u64; - Ok(buf.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -impl Read for NullStream { - fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> { - Err(io::Error::new( - io::ErrorKind::Unsupported, - "null stream is write-only", - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - #[test] - fn null_counts_bytes() { - let mut ns = NullStream::new(); - assert_eq!(ns.bytes_written(), 0); - ns.write_all(&[0u8; 100]).unwrap(); - assert_eq!(ns.bytes_written(), 100); - ns.write_all(&[1u8; 50]).unwrap(); - assert_eq!(ns.bytes_written(), 150); - // Single write returns correct count - let n = ns.write(&[0u8; 200]).unwrap(); - assert_eq!(n, 200); - assert_eq!(ns.bytes_written(), 350); - } - - #[test] - fn null_read_errors() { - let mut ns = NullStream::new(); - let mut buf = [0u8; 10]; - let err = ns.read(&mut buf).unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::Unsupported); - } - - #[test] - fn null_finish_ok() { - let mut ns = NullStream::new(); - ns.write_all(&[0u8; 1000]).unwrap(); - ns.finish().unwrap(); - } - - #[test] - fn null_implements_iostream() { - let ns = NullStream::new(); - let mut boxed: Box<dyn IOStream> = Box::new(ns); - boxed.write_all(&[0u8; 50]).unwrap(); - let info = boxed.info(); - assert_eq!(info.streams.len(), 0); - boxed.finish().unwrap(); - } - - #[test] - fn null_total_bytes_returns_none() { - let ns = NullStream::new(); - assert_eq!(ns.total_bytes(), None); - } +impl crate::pes::Stream for NullStream { + fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { Ok(None) } + fn write(&mut self, _: &crate::pes::PesFrame) -> io::Result<()> { Ok(()) } + fn finish(&mut self) -> io::Result<()> { Ok(()) } + fn info(&self) -> &DiscTitle { &self.disc_title } } diff --git a/src/mux/pesout.rs b/src/mux/pesout.rs deleted file mode 100644 index 5b4269f..0000000 --- a/src/mux/pesout.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! PES output streams — each writes its own format from PES frames. - -use super::tsmux::TsMuxer; -use crate::disc::DiscTitle; -use crate::pes::PesFrame; -use std::io::{self, Write}; - -// ── M2TS ──────────────────────────────────────────────────────────────────── - -pub struct M2tsOutputStream { - muxer: TsMuxer<io::BufWriter<std::fs::File>>, - title: DiscTitle, -} - -impl M2tsOutputStream { - pub fn create(path: &str, title: &DiscTitle) -> io::Result<Self> { - let file = std::fs::File::create(path) - .map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path, e)))?; - let mut writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file); - if !title.streams.is_empty() { - let m = super::meta::M2tsMeta::from_title(title); - super::meta::write_header(&mut writer, &m)?; - } - let pids = extract_pids(title); - let mut muxer = TsMuxer::new(writer, &pids); - for (i, cp) in title.codec_privates.iter().enumerate() { - if let Some(data) = cp { - muxer.set_codec_private(i, data.clone()); - } - } - Ok(Self { muxer, title: title.clone() }) - } -} - -impl crate::pes::Stream for M2tsOutputStream { - fn read(&mut self) -> io::Result<Option<PesFrame>> { - Err(io::Error::new(io::ErrorKind::Unsupported, "M2TS output is write-only")) - } - fn write(&mut self, frame: &PesFrame) -> io::Result<()> { - self.muxer.write_frame(frame.track, frame.pts, &frame.data) - } - fn finish(&mut self) -> io::Result<()> { self.muxer.finish() } - fn info(&self) -> &DiscTitle { &self.title } -} - -// ── Null ──────────────────────────────────────────────────────────────────── - -pub struct NullOutputStream { title: DiscTitle } - -impl NullOutputStream { - pub fn new(title: &DiscTitle) -> Self { Self { title: title.clone() } } -} - -impl crate::pes::Stream for NullOutputStream { - fn read(&mut self) -> io::Result<Option<PesFrame>> { Ok(None) } - fn write(&mut self, _: &PesFrame) -> io::Result<()> { Ok(()) } - fn finish(&mut self) -> io::Result<()> { Ok(()) } - fn info(&self) -> &DiscTitle { &self.title } -} - -// ── Stdio — serializes PES frames directly ────────────────────────────────── - -pub struct StdioOutputStream { - writer: io::BufWriter<io::Stdout>, - title: DiscTitle, -} - -impl StdioOutputStream { - pub fn new(title: &DiscTitle) -> Self { - Self { writer: io::BufWriter::new(io::stdout()), title: title.clone() } - } -} - -impl crate::pes::Stream for StdioOutputStream { - fn read(&mut self) -> io::Result<Option<PesFrame>> { - Err(io::Error::new(io::ErrorKind::Unsupported, "stdio output is write-only")) - } - fn write(&mut self, frame: &PesFrame) -> io::Result<()> { - frame.serialize(&mut self.writer) - } - fn finish(&mut self) -> io::Result<()> { self.writer.flush() } - fn info(&self) -> &DiscTitle { &self.title } -} - -// ── Network — serializes PES frames over TCP ──────────────────────────────── - -pub struct NetworkOutputStream { - writer: io::BufWriter<std::net::TcpStream>, - title: DiscTitle, -} - -impl NetworkOutputStream { - pub fn connect(addr: &str, title: &DiscTitle) -> io::Result<Self> { - let stream = std::net::TcpStream::connect(addr)?; - let mut writer = io::BufWriter::with_capacity(256 * 1024, stream); - if !title.streams.is_empty() { - let m = super::meta::M2tsMeta::from_title(title); - super::meta::write_header(&mut writer, &m)?; - writer.flush()?; - } - Ok(Self { writer, title: title.clone() }) - } -} - -impl crate::pes::Stream for NetworkOutputStream { - fn read(&mut self) -> io::Result<Option<PesFrame>> { - Err(io::Error::new(io::ErrorKind::Unsupported, "network output is write-only")) - } - fn write(&mut self, frame: &PesFrame) -> io::Result<()> { - frame.serialize(&mut self.writer) - } - fn finish(&mut self) -> io::Result<()> { self.writer.flush() } - fn info(&self) -> &DiscTitle { &self.title } -} - -// ── Helpers ───────────────────────────────────────────────────────────────── - -fn extract_pids(title: &DiscTitle) -> Vec<u16> { - title.streams.iter().map(|s| match s { - crate::disc::Stream::Video(v) => v.pid, - crate::disc::Stream::Audio(a) => a.pid, - crate::disc::Stream::Subtitle(s) => s.pid, - }).collect() -} diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 3758db9..81249f9 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -1,37 +1,31 @@ -//! Stream URL resolver — parses URL strings into IOStream instances. +//! Stream URL resolver — parses URL strings into PES stream instances. //! //! Format: `scheme://path` //! //! | Scheme | Input | Output | Path | //! |--------|-------|--------|------| //! | disc:// | Yes | -- | empty (auto-detect) or /dev/sgN | -//! | m2ts:// | Yes | Yes | file path (required) | +//! | iso:// | Yes | -- | file path (required) | //! | mkv:// | Yes | Yes | file path (required) | +//! | m2ts:// | Yes | Yes | file path (required) | //! | network:// | Yes (listen) | Yes (connect) | host:port (required) | //! | stdio:// | Yes (stdin) | Yes (stdout) | empty | -//! | iso:// | Yes | -- | file path (required) | //! | null:// | -- | Yes | empty | //! //! Bare paths without a scheme are rejected. +//! For disc→ISO (raw sector copy), use `Disc::copy()` instead. use super::disc::DiscStream; -use super::iso::IsoStream; use super::network::NetworkStream; use super::null::NullStream; use super::stdio::StdioStream; -use super::{IOStream, M2tsStream, MkvStream}; -use crate::disc::DiscTitle; -use std::io::{self, BufReader, BufWriter}; +use super::{M2tsStream, MkvStream}; +use std::io; use std::path::{Path, PathBuf}; /// I/O buffer size for file streams. const IO_BUF_SIZE: usize = 4 * 1024 * 1024; -/// Default MKV lookahead buffer size. -/// Dynamically increased for UHD content (many streams delay video codec headers). -const MKV_LOOKAHEAD_DEFAULT: usize = 10 * 1024 * 1024; -const MKV_LOOKAHEAD_UHD: usize = 100 * 1024 * 1024; - /// Parsed stream URL. pub enum StreamUrl { /// Optical disc drive. Device path is optional (auto-detect if None). @@ -88,17 +82,6 @@ impl StreamUrl { } /// Parse a URL string into a typed StreamUrl. -/// -/// All URLs must use the `scheme://path` format. Bare paths are not supported. -/// -/// ```text -/// disc:// → Disc { device: None } -/// disc:///dev/sg4 → Disc { device: Some("/dev/sg4") } -/// m2ts:///tmp/Dune.m2ts → M2ts { path: "/tmp/Dune.m2ts" } -/// mkv://Dune.mkv → Mkv { path: "Dune.mkv" } -/// network://10.0.0.1:9000 → Network { addr: "10.0.0.1:9000" } -/// null:// → Null -/// ``` pub fn parse_url(url: &str) -> StreamUrl { if let Some(rest) = url.strip_prefix("disc://") { return if rest.is_empty() { @@ -143,19 +126,14 @@ pub fn parse_url(url: &str) -> StreamUrl { /// Validate that a file path is non-empty and has a filename component. fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> { if path.as_os_str().is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"), - )); + return Err(crate::error::Error::StreamUrlMissingPath { + scheme: scheme.to_string(), + }.into()); } if path.file_name().is_none() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "{scheme}://{} is not a valid file path — must include a filename", - path.display() - ), - )); + return Err(crate::error::Error::StreamUrlInvalid { + url: format!("{scheme}://{}", path.display()), + }.into()); } Ok(()) } @@ -163,136 +141,18 @@ fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> { /// Validate that a network address has host:port format. fn validate_network_addr(addr: &str) -> io::Result<()> { if addr.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "network:// requires host:port (e.g. network://0.0.0.0:9000)", - )); + return Err(crate::error::Error::StreamUrlMissingPath { + scheme: "network".to_string(), + }.into()); } if !addr.contains(':') { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("network://{addr} missing port — use network://{addr}:PORT"), - )); + return Err(crate::error::Error::StreamUrlMissingPort { + addr: addr.to_string(), + }.into()); } Ok(()) } -/// Open a stream URL for reading (source). -pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream>> { - let parsed = parse_url(url); - - match parsed { - StreamUrl::Disc { device } => { - let result = DiscStream::open( - device.as_deref(), - opts.keydb_path.as_deref(), - opts.title_index.unwrap_or(0), - None, - ) - .map_err(|e| io::Error::other(e.to_string()))?; - let mut stream = result.stream; - if opts.raw { - stream.set_raw(); - } - Ok(Box::new(stream)) - } - StreamUrl::M2ts { ref path } => { - validate_file_path(path, "m2ts")?; - let file = std::fs::File::open(path) - .map_err(|e| io::Error::new(e.kind(), - format!("m2ts://{}: {}", path.display(), e)))?; - let reader = BufReader::with_capacity(IO_BUF_SIZE, file); - Ok(Box::new(M2tsStream::open(reader)?)) - } - StreamUrl::Mkv { ref path } => { - validate_file_path(path, "mkv")?; - let file = std::fs::File::open(path) - .map_err(|e| io::Error::new(e.kind(), - format!("mkv://{}: {}", path.display(), e)))?; - let reader = BufReader::with_capacity(IO_BUF_SIZE, file); - Ok(Box::new(MkvStream::open(reader)?)) - } - StreamUrl::Network { .. } => { - Err(io::Error::new(io::ErrorKind::Unsupported, - "network:// requires PES pipeline — use input() instead of open_input()")) - } - StreamUrl::Stdio => { - Ok(Box::new(StdioStream::input())) - } - StreamUrl::Iso { ref path } => { - validate_file_path(path, "iso")?; - let scan_opts = match &opts.keydb_path { - Some(p) => crate::disc::ScanOptions::with_keydb(p), - None => crate::disc::ScanOptions::default(), - }; - let mut stream = IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?; - if opts.raw { - stream.set_raw(); - } - Ok(Box::new(stream)) - } - StreamUrl::Null => { - Err(io::Error::new(io::ErrorKind::InvalidInput, - "null:// is write-only — cannot use as input")) - } - StreamUrl::Unknown { ref raw } => { - Err(io::Error::new(io::ErrorKind::InvalidInput, - format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw))) - } - } -} - -/// Open a stream URL for writing (destination). -pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>> { - let parsed = parse_url(url); - - match parsed { - StreamUrl::Disc { .. } => { - Err(io::Error::new(io::ErrorKind::Unsupported, - "disc:// is read-only — cannot use as output")) - } - StreamUrl::Iso { ref path } => { - validate_file_path(path, "iso")?; - Ok(Box::new(IsoStream::create(&path.to_string_lossy())?.meta(meta))) - } - StreamUrl::Null => { - Ok(Box::new(NullStream::new().meta(meta))) - } - StreamUrl::Stdio => { - Ok(Box::new(StdioStream::output().meta(meta))) - } - StreamUrl::M2ts { ref path } => { - validate_file_path(path, "m2ts")?; - let file = std::fs::File::create(path) - .map_err(|e| io::Error::new(e.kind(), - format!("m2ts://{}: {}", path.display(), e)))?; - let writer = BufWriter::with_capacity(IO_BUF_SIZE, file); - Ok(Box::new(M2tsStream::new(writer).meta(meta))) - } - StreamUrl::Mkv { ref path } => { - validate_file_path(path, "mkv")?; - let file = std::fs::File::create(path) - .map_err(|e| io::Error::new(e.kind(), - format!("mkv://{}: {}", path.display(), e)))?; - let writer = BufWriter::with_capacity(IO_BUF_SIZE, file); - let lookahead = if meta.streams.len() > 15 { - MKV_LOOKAHEAD_UHD - } else { - MKV_LOOKAHEAD_DEFAULT - }; - Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead))) - } - StreamUrl::Network { .. } => { - Err(io::Error::new(io::ErrorKind::Unsupported, - "network:// output requires PES pipeline — use pipe() instead of open_output()")) - } - StreamUrl::Unknown { ref raw } => { - Err(io::Error::new(io::ErrorKind::InvalidInput, - format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", raw))) - } - } -} - /// Options for opening an input stream. #[derive(Default)] pub struct InputOptions { @@ -302,42 +162,43 @@ pub struct InputOptions { pub raw: bool, } -// ── PES-based open ────────────────────────────────────────────────────────── - /// Open a PES input stream (produces PES frames). pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> { let parsed = parse_url(url); match parsed { + StreamUrl::Disc { device } => { + // Open drive, init, scan — caller manages the drive + let mut drive = match device { + Some(ref d) => crate::drive::Drive::open(d) + .map_err(|e| -> io::Error { e.into() })?, + None => crate::drive::find_drive() + .ok_or_else(|| -> io::Error { crate::error::Error::DeviceNotFound { path: String::new() }.into() })?, + }; + let _ = drive.wait_ready(); + let _ = drive.init(); + let _ = drive.probe_disc(); + let (mut stream, _disc) = DiscStream::open_drive( + drive, + opts.keydb_path.as_deref(), + opts.title_index.unwrap_or(0), + ).map_err(|e| -> io::Error { e.into() })?; + if opts.raw { + stream.set_raw(); + } + Ok(Box::new(stream)) + } StreamUrl::Iso { ref path } => { validate_file_path(path, "iso")?; let scan_opts = match &opts.keydb_path { Some(p) => crate::disc::ScanOptions::with_keydb(p), None => crate::disc::ScanOptions::default(), }; - let mut stream = IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?; + let mut stream = DiscStream::open_iso(&path.to_string_lossy(), opts.title_index, &scan_opts)?; if opts.raw { stream.set_raw(); } Ok(Box::new(stream)) } - StreamUrl::Disc { device } => { - let result = DiscStream::open( - device.as_deref(), - opts.keydb_path.as_deref(), - opts.title_index.unwrap_or(0), - None, - ) - .map_err(|e| io::Error::other(e.to_string()))?; - let mut stream = result.stream; - if opts.raw { - stream.set_raw(); - } - Ok(Box::new(stream)) - } - StreamUrl::Null => { - Err(io::Error::new(io::ErrorKind::InvalidInput, - "null:// is write-only — cannot use as input")) - } StreamUrl::M2ts { ref path } => { validate_file_path(path, "m2ts")?; let file = std::fs::File::open(path) @@ -359,9 +220,13 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S StreamUrl::Stdio => { Ok(Box::new(StdioStream::input())) } + StreamUrl::Null => { + Err(crate::error::Error::StreamWriteOnly.into()) + } StreamUrl::Unknown { ref raw } => { - Err(io::Error::new(io::ErrorKind::InvalidInput, - format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw))) + Err(crate::error::Error::StreamUrlInvalid { + url: raw.clone(), + }.into()) } } } @@ -379,32 +244,35 @@ pub fn output( .map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?; let writer: Box<dyn super::WriteSeek> = Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file)); - Ok(Box::new(super::mkvout::MkvOutputStream::create(writer, title)?)) + Ok(Box::new(MkvStream::create(writer, title)?)) } StreamUrl::M2ts { ref path } => { validate_file_path(path, "m2ts")?; - Ok(Box::new(super::pesout::M2tsOutputStream::create(&path.to_string_lossy(), title)?)) + let file = std::fs::File::create(path) + .map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e)))?; + let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file); + Ok(Box::new(M2tsStream::create(writer, title)?)) } StreamUrl::Network { ref addr } => { validate_network_addr(addr)?; - Ok(Box::new(super::pesout::NetworkOutputStream::connect(addr, title)?)) + Ok(Box::new(NetworkStream::connect(addr)?.meta(title))) } StreamUrl::Stdio => { - Ok(Box::new(super::pesout::StdioOutputStream::new(title))) + Ok(Box::new(StdioStream::output(title))) } StreamUrl::Null => { - Ok(Box::new(super::pesout::NullOutputStream::new(title))) + Ok(Box::new(NullStream::new(title))) } StreamUrl::Disc { .. } => { - Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only")) + Err(crate::error::Error::StreamReadOnly.into()) } StreamUrl::Iso { .. } => { - Err(io::Error::new(io::ErrorKind::Unsupported, - "ISO output from PES not supported — use disc.copy() for raw ISO")) + Err(crate::error::Error::StreamReadOnly.into()) } StreamUrl::Unknown { ref raw } => { - Err(io::Error::new(io::ErrorKind::InvalidInput, - format!("'{}' is not a valid stream URL", raw))) + Err(crate::error::Error::StreamUrlInvalid { + url: raw.clone(), + }.into()) } } } diff --git a/src/mux/stdio.rs b/src/mux/stdio.rs index 894d4d9..700b02a 100644 --- a/src/mux/stdio.rs +++ b/src/mux/stdio.rs @@ -1,17 +1,13 @@ -//! StdioStream — raw byte pipe via stdin/stdout. Format-agnostic. +//! StdioStream — PES frames via stdin/stdout. -use super::IOStream; use crate::disc::DiscTitle; -use std::io::{self, Read, Write}; +use std::io::{self, Write}; -/// Stdio stream — reads from stdin, writes to stdout. -/// -/// No headers, no metadata, no format opinions. Just bytes. -/// The format is determined by whatever is on the other end. +/// Stdio stream — reads PES from stdin, writes PES to stdout. pub struct StdioStream { disc_title: DiscTitle, reader: Option<io::Stdin>, - writer: Option<io::Stdout>, + writer: Option<io::BufWriter<io::Stdout>>, } impl StdioStream { @@ -25,32 +21,26 @@ impl StdioStream { } /// Create a stdio stream for writing (stdout). - pub fn output() -> Self { + pub fn output(title: &DiscTitle) -> Self { Self { - disc_title: DiscTitle::empty(), + disc_title: title.clone(), reader: None, - writer: Some(io::stdout()), + writer: Some(io::BufWriter::new(io::stdout())), } } - - /// Set metadata (for output — passed through from input side). - pub fn meta(mut self, dt: &DiscTitle) -> Self { - self.disc_title = dt.clone(); - self - } } impl crate::pes::Stream for StdioStream { fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { match &mut self.reader { Some(r) => crate::pes::PesFrame::deserialize(r), - None => Err(io::Error::new(io::ErrorKind::Unsupported, "stdio opened for writing")), + None => Err(crate::error::Error::StreamWriteOnly.into()), } } fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> { match &mut self.writer { Some(w) => frame.serialize(w), - None => Err(io::Error::new(io::ErrorKind::Unsupported, "stdio opened for reading")), + None => Err(crate::error::Error::StreamReadOnly.into()), } } fn finish(&mut self) -> io::Result<()> { @@ -59,76 +49,3 @@ impl crate::pes::Stream for StdioStream { } fn info(&self) -> &DiscTitle { &self.disc_title } } - -impl IOStream for StdioStream { - fn info(&self) -> &DiscTitle { - &self.disc_title - } - fn finish(&mut self) -> io::Result<()> { - if let Some(ref mut w) = self.writer { - w.flush()?; - } - Ok(()) - } -} - -impl Read for StdioStream { - fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { - match self.reader { - Some(ref mut r) => r.read(buf), - None => Err(io::Error::new( - io::ErrorKind::Unsupported, - "stdio:// opened for output — cannot read", - )), - } - } -} - -impl Write for StdioStream { - fn write(&mut self, buf: &[u8]) -> io::Result<usize> { - match self.writer { - Some(ref mut w) => w.write(buf), - None => Err(io::Error::new( - io::ErrorKind::Unsupported, - "stdio:// opened for input — cannot write", - )), - } - } - fn flush(&mut self) -> io::Result<()> { - match self.writer { - Some(ref mut w) => w.flush(), - None => Ok(()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Read, Write}; - - #[test] - fn stdio_output_write_errors_on_read() { - let mut stream = StdioStream::output(); - let mut buf = [0u8; 10]; - let err = stream.read(&mut buf).unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::Unsupported); - assert!(err.to_string().contains("cannot read"), "got: {}", err); - } - - #[test] - fn stdio_input_read_errors_on_write() { - let mut stream = StdioStream::input(); - let err = stream.write(&[0u8; 10]).unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::Unsupported); - assert!(err.to_string().contains("cannot write"), "got: {}", err); - } - - #[test] - fn stdio_total_bytes_returns_none() { - let input = StdioStream::input(); - assert_eq!(input.total_bytes(), None); - let output = StdioStream::output(); - assert_eq!(output.total_bytes(), None); - } -} diff --git a/src/pes.rs b/src/pes.rs index 3d83733..013ba07 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -23,16 +23,12 @@ impl PesFrame { /// Serialize to bytes: track(1) | pts(8) | keyframe(1) | len(4) | data pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> { if self.track > 255 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "track index exceeds 255", - )); + return Err(crate::error::Error::PesInvalidMagic.into()); } if self.data.len() > u32::MAX as usize { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "frame data exceeds 4 GB", - )); + return Err(crate::error::Error::PesFrameTooLarge { + size: self.data.len(), + }.into()); } w.write_all(&[self.track as u8])?; w.write_all(&self.pts.to_le_bytes())?; @@ -59,10 +55,7 @@ impl PesFrame { let keyframe = header[9] != 0; let len = u32::from_le_bytes([header[10], header[11], header[12], header[13]]) as usize; if len > MAX_FRAME_SIZE { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("frame size {} exceeds maximum {}", len, MAX_FRAME_SIZE), - )); + return Err(crate::error::Error::PesFrameTooLarge { size: len }.into()); } let mut data = vec![0u8; len]; r.read_exact(&mut data)?; @@ -100,3 +93,58 @@ pub trait Stream { /// True when codec_private is available for all video tracks. fn headers_ready(&self) -> bool { true } } + +/// Wraps any output stream and counts bytes written. +/// +/// Progress tracking is a CLI concern — streams don't know their size. +/// Wrap the output with CountingStream, then query bytes_written(). +/// +/// ```text +/// let mut output = CountingStream::new(libfreemkv::output(dest, &title)?); +/// while let Ok(Some(frame)) = input.read() { +/// output.write(&frame)?; +/// let pct = output.bytes_written() as f64 / total as f64; +/// } +/// ``` +pub struct CountingStream { + inner: Box<dyn Stream>, + written: u64, +} + +impl CountingStream { + pub fn new(inner: Box<dyn Stream>) -> Self { + Self { inner, written: 0 } + } + + /// Total bytes of PES frame data written through this stream. + pub fn bytes_written(&self) -> u64 { + self.written + } +} + +impl Stream for CountingStream { + fn read(&mut self) -> std::io::Result<Option<PesFrame>> { + self.inner.read() + } + + fn write(&mut self, frame: &PesFrame) -> std::io::Result<()> { + self.written += frame.data.len() as u64; + self.inner.write(frame) + } + + fn finish(&mut self) -> std::io::Result<()> { + self.inner.finish() + } + + fn info(&self) -> &crate::disc::DiscTitle { + self.inner.info() + } + + fn codec_private(&self, track: usize) -> Option<Vec<u8>> { + self.inner.codec_private(track) + } + + fn headers_ready(&self) -> bool { + self.inner.headers_ready() + } +}