diff --git a/CHANGELOG.md b/CHANGELOG.md index 9328b7d..1ce806e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,148 @@ # Changelog +## 0.13.0 (2026-04-24) + +### Zero English in library — typed variants for every error path + +Audit pass against the `CLAUDE.md` rule (no English text in library code). +Found nine call sites that violated the contract by stuffing English into +`io::Error::new(kind, "…")` or by abusing `Error::DeviceNotFound { path }` +as a free-form description field. Each is now a typed variant with +structured fields; the CLI / autorip translates to localized text. + +New `Error` variants and codes: + +- `ScsiInterfaceUnavailable { path }` — `E1004` (macOS + `SCSITaskDeviceInterface` couldn't be obtained) +- `DeviceLocked { path, kr }` — `E1005` (replaces an English + "exclusive access denied. Try: diskutil unmountDisk" message) +- `IoKitPluginFailed { path, kr }` — `E1006` +- `UnsupportedPlatform { target }` — `E2003` (built on an OS without an + SCSI backend) +- `PlatformNotImplemented { platform }` — `E2004` (replaces the + `product_revision: "Renesas not yet implemented"` string-stuffing in + `drive::mod`) +- `MapfileInvalid { kind }` — `E6011` (ddrescue mapfile parse, with a + stable `&'static str` kind: `"status_char"` or `"hex"`) +- `DiscUrlNotDirect` — `E9009` (replaces the full English sentence + `"Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources"` + that `mux::input(disc://…)` returned to callers) + +Migrated call sites: + +- `mux/resolve.rs` — disc URL → `DiscUrlNotDirect`; the four + `format!("m2ts://…")` / `format!("mkv://…")` IO error wraps now + propagate the inner `io::Error` unchanged (the URL-prefix wrap added + no semantic information). +- `mux/iso.rs` — same `format!("iso://…")` wrap dropped. +- `sector.rs` — image-too-large now uses the existing `IsoTooLarge` + variant instead of `format!("…image too large, max ~8 TB")`. +- `disc/mapfile.rs` — bad-status-char and bad-hex parser errors now use + `MapfileInvalid { kind }`. +- `scsi/mod.rs` — unsupported-platform path uses `UnsupportedPlatform`. +- `scsi/macos.rs` — IOKit plugin failure → `IoKitPluginFailed`, + `SCSITaskDeviceInterface` missing → `ScsiInterfaceUnavailable`, + exclusive-access denied → `DeviceLocked` with the IOReturn code as a + structured `kr` field. The `find_scsi_service` four-stage failure path + is now a single `DeviceNotFound { path }` (none of the prior + per-stage English descriptions were user-actionable individually). +- `drive/mod.rs` — Renesas platform → `PlatformNotImplemented`. + +### Stripped English from `labels` module + +`labels::apply()` previously pushed `"Commentary"`, `"Descriptive Audio"`, +`"Score"`, `"IME"`, and `" (Secondary)"` directly into +`AudioStream.label`. Those English strings then leaked into MKV titles +and into autorip's UI. The data was already structured upstream +(`LabelPurpose` enum on `StreamLabel`); the lib was downcasting it for +the caller's convenience. + +- `AudioStream` gains `purpose: LabelPurpose` (re-exported from + `crate::disc` next to the struct, alongside `LabelQualifier`). +- `SubtitleStream` gains `qualifier: LabelQualifier`. +- `apply()` writes structured fields, never English. `label` keeps + codec-formatting only (`"Dolby TrueHD 5.1"`). +- `generate_audio_label` drops the `" (Secondary)"` suffix; `secondary` + is already a `bool` field, callers render it. +- `generate_video_label` drops the `"Secondary Video"` fallback. + `"Dolby Vision EL"` kept (brand identifier, not translatable). + +### API hygiene + +- **mux module visibility tightened**. `pub mod ebml`, `m2ts`, `mkv`, + `network`, `null`, `ps`, `stdio`, `ts`, `tsmux` are now `pub(crate)`. + Their *types* are still re-exported from `lib.rs` — the modules + themselves were leaking low-level EBML primitives, TS muxer + internals, and network/stdio implementations that no external caller + used. `mux::codec`, `mux::disc`, `mux::iso`, `mux::resolve`, and + `mux::meta` stay public (genuine APIs). +- **Stream trait rustdoc**. The keystone PES `Stream` trait (in + `pes.rs`) had per-method docs but no trait-level doc. Now explains + read-vs-write split, error contracts, the role of `info()` / + `codec_private()` / `headers_ready()`. +- **lib.rs re-export sections**. Eight grouped sections with a + paragraph each (Drive lifecycle, Errors, Decryption, Disc structure, + Streams, Lower-level surfaces) so `cargo doc` tells callers when to + reach for what. +- **Dropped `ScanOptions::with_keydb()`**. The `_with_X` constructor + pattern was banned by `CLAUDE.md` (one method per action). Use the + struct literal: `ScanOptions { keydb_path: Some(p.into()) }`. Five + external call sites (autorip ×3, freemkv CLI ×3) and three test + fixtures migrated. +- **`pid_index` allocation documented**. The `TsDemuxer::new` flat + lookup table was flagged by audit as "unbounded for adversarial + PIDs"; on closer reading it's bounded by `u16::MAX × 2 bytes ≈ 128 KB`. + Doc comment now states the bound explicitly so future contributors + don't re-flag it. + +### Dead-code sweep + +Pre-PES-rewrite leftovers that were `pub` but unreachable: + +- Deleted `mux/lookahead.rs` entirely (orphan file — never had a `mod` + declaration; only used by its own tests). +- Deleted `mux/tsreader.rs` (`TsDemuxReader` struct + four methods, + used nowhere). +- Deleted `mux::ebml::write_int`, `read_vint`, `SEEK_HEAD`, `SEEK`, + `SEEK_ID`, `SEEK_POSITION` (unused). +- Deleted `mux::ts::scan_first_pts`, `scan_last_pts`, `scan_duration`, + `SCAN_HEAD_SIZE`, `SCAN_TAIL_SIZE`, `take_remainder`, `set_remainder` + (unused since the v0.10 PES rewrite). +- Deleted `MkvMuxer::codec_private_slots` / + `codec_private_filled` fields and `fill_codec_private` method — + deferred-codecPrivate path was never exercised once codec_privates + flowed through `DiscTitle`. + +`cargo clippy --all-targets -- -D warnings` is clean. + +### Tests + +- New `error::tests` module — code distinctness, Display has no English + words, `io::ErrorKind` mapping for every new variant. +- 233 lib tests (was 230), all green. + +### Breaking changes + +Source-compatible for callers who use `Error` opaquely (handle +`Result` and `error.code()` only). The following are breaking: + +- `ScanOptions::with_keydb()` removed — use struct literal. +- `mux::ebml`, `mux::mkv`, `mux::ts`, etc. modules no longer accessible + externally — use the re-exported types from the crate root instead. +- `AudioStream` and `SubtitleStream` gained required fields (`purpose`, + `qualifier`). Construction-by-struct-literal must include them. +- `Error::UnsupportedDrive { product_revision: "Renesas not yet + implemented" }` no longer produced — match `PlatformNotImplemented`. + +### Magic-number policy + +Per the v0.13 audit directive: new code in this release uses named +documented constants (e.g. `POLL_INTERVAL_SECS` in autorip, the wedge +signature literals in `ripper.rs`, the `BD_TS_PID_SPACE` floor in +`ts.rs`'s table allocation). A comprehensive retrofit of pre-existing +magic numbers across the older codebase is queued as follow-up work for +0.13.1+ — too large to absorb into this release without scope creep. + ## 0.12.0 (2026-04-24) ### Rust 2024 edition migration diff --git a/Cargo.toml b/Cargo.toml index 55da28e..94e5e9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.12.2" +version = "0.13.0" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/benches/sgio_read.rs b/benches/sgio_read.rs index 74b2ed9..2ff4d15 100644 --- a/benches/sgio_read.rs +++ b/benches/sgio_read.rs @@ -44,9 +44,15 @@ fn main() { let mut fail = 0u32; let mut bytes: u64 = 0; + // Recovery flag: true matches pre-0.11.13 bench behavior — full SCSI + // ECC retry loop on errors (slower, what the rip path used before the + // adaptive batch sizer landed). Flip to `false` for the fast-fail path + // that current rips use; benches are configurable via this constant. + const READ_WITH_RECOVERY: bool = true; + for i in 0..1000u32 { let lba = i * batch as u32; - match drive.read(lba, batch, &mut buf) { + match drive.read(lba, batch, &mut buf, READ_WITH_RECOVERY) { Ok(_) => { writer.write_all(&buf).unwrap(); ok += 1; diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index bc65549..c3c7e98 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -132,6 +132,7 @@ impl Disc { codec, language: s.language.clone(), forced: false, + qualifier: crate::disc::LabelQualifier::None, codec_data: None, })) } else { @@ -142,6 +143,7 @@ impl Disc { language: s.language.clone(), sample_rate: SampleRate::from_audio_rate(s.audio_rate), secondary: s.stream_type == 5, + purpose: crate::disc::LabelPurpose::Normal, label: String::new(), })) } @@ -151,6 +153,7 @@ impl Disc { codec, language: s.language.clone(), forced: false, + qualifier: crate::disc::LabelQualifier::None, codec_data: None, })), // Stream type 4 = IG, unknown types -- skip diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 104d80c..6f0206c 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -48,6 +48,7 @@ impl Disc { language: a.language.clone(), sample_rate: SampleRate::from_hz(a.sample_rate), secondary: false, + purpose: crate::disc::LabelPurpose::Normal, label: String::new(), }) }) @@ -92,6 +93,7 @@ impl Disc { codec: Codec::DvdSub, language: s.language.clone(), forced: false, + qualifier: crate::disc::LabelQualifier::None, codec_data: codec_data.clone(), }) }) diff --git a/src/disc/mapfile.rs b/src/disc/mapfile.rs index db88115..d709c17 100644 --- a/src/disc/mapfile.rs +++ b/src/disc/mapfile.rs @@ -149,10 +149,13 @@ impl Mapfile { .next() .and_then(SectorStatus::from_char) .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("bad status char in mapfile: {}", fields[2]), - ) + // No English text — the variant carries a stable + // language-neutral kind identifier (`status_char`). + let e: io::Error = crate::error::Error::MapfileInvalid { + kind: "status_char", + } + .into(); + e })?; entries.push(MapEntry { pos, size, status }); } @@ -318,8 +321,12 @@ impl Mapfile { fn parse_hex(s: &str) -> io::Result { let s = s.strip_prefix("0x").unwrap_or(s); - u64::from_str_radix(s, 16) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad hex {s}: {e}"))) + u64::from_str_radix(s, 16).map_err(|_| { + // Underlying ParseIntError dropped — its Display is OS-locale text. + // The typed variant carries `kind = "hex"` which is stable. + let e: io::Error = crate::error::Error::MapfileInvalid { kind: "hex" }.into(); + e + }) } #[cfg(test)] diff --git a/src/disc/mod.rs b/src/disc/mod.rs index af949c2..18e04f5 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -20,6 +20,11 @@ use crate::udf; use encrypt::HandshakeResult; +// Re-export label classification enums alongside AudioStream / SubtitleStream +// so the public surface keeps the structured metadata together. Callers map +// these to display text in their own locale. +pub use crate::labels::{LabelPurpose, LabelQualifier}; + // ─── Public types ─────────────────────────────────────────────────────────── /// A scanned Blu-ray disc. @@ -180,7 +185,11 @@ pub struct AudioStream { pub sample_rate: SampleRate, /// Whether this is a secondary stream (commentary) pub secondary: bool, - /// Extra label + /// Stream purpose (commentary / descriptive / score / IME / normal). + /// Callers translate this to display text in their own locale. + pub purpose: LabelPurpose, + /// Codec / variant text (e.g. "Dolby TrueHD 5.1", "(US)"). + /// NEVER contains English purpose words — see `purpose` for that. pub label: String, } @@ -195,6 +204,9 @@ pub struct SubtitleStream { pub language: String, /// Whether this is a forced subtitle pub forced: bool, + /// Subtitle qualifier (SDH / descriptive service / forced / none). + /// Callers translate this to display text in their own locale. + pub qualifier: LabelQualifier, /// Pre-formatted codec private data (e.g. VobSub .idx palette header) pub codec_data: Option>, } @@ -889,13 +901,6 @@ pub struct ScanOptions { } impl ScanOptions { - /// Create options with a specific KEYDB path. - pub fn with_keydb(path: impl Into) -> Self { - ScanOptions { - keydb_path: Some(path.into()), - } - } - /// Resolve KEYDB path: explicit path first, then standard locations. fn resolve_keydb(&self) -> Option { if let Some(p) = &self.keydb_path { diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 205652d..a858965 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -815,10 +815,8 @@ fn create_driver( match platform { profile::Platform::Mt1959A => Ok(Box::new(Mt1959::new(profile.clone(), false))), profile::Platform::Mt1959B => Ok(Box::new(Mt1959::new(profile.clone(), true))), - profile::Platform::Renesas => Err(Error::UnsupportedDrive { - vendor_id: profile.identity.vendor_id.trim().to_string(), - product_id: String::new(), - product_revision: "Renesas not yet implemented".to_string(), + profile::Platform::Renesas => Err(Error::PlatformNotImplemented { + platform: "renesas".to_string(), }), } } diff --git a/src/error.rs b/src/error.rs index 449616f..ff8a44f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -24,10 +24,15 @@ 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; +pub const E_SCSI_INTERFACE_UNAVAILABLE: u16 = 1004; +pub const E_DEVICE_LOCKED: u16 = 1005; +pub const E_IOKIT_PLUGIN_FAILED: u16 = 1006; // Profile (2xxx) pub const E_UNSUPPORTED_DRIVE: u16 = 2000; pub const E_PROFILE_PARSE: u16 = 2002; +pub const E_UNSUPPORTED_PLATFORM: u16 = 2003; +pub const E_PLATFORM_NOT_IMPLEMENTED: u16 = 2004; // Unlock (3xxx) pub const E_UNLOCK_FAILED: u16 = 3000; @@ -49,6 +54,7 @@ pub const E_DISC_TITLE_RANGE: u16 = 6005; pub const E_IFO_PARSE: u16 = 6007; pub const E_MKV_INVALID: u16 = 6008; pub const E_NO_STREAMS: u16 = 6009; +pub const E_MAPFILE_INVALID: u16 = 6011; // AACS (7xxx) pub const E_AACS_NO_KEYS: u16 = 7000; @@ -84,6 +90,7 @@ 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; +pub const E_DISC_URL_NOT_DIRECT: u16 = 9009; // ── Error enum ────────────────────────────────────────────────────────────── @@ -103,6 +110,24 @@ pub enum Error { DeviceResetFailed { path: String, }, + /// Platform-specific SCSI interface couldn't be obtained from the OS + /// (macOS: `SCSITaskDeviceInterface` unavailable). The `path` field + /// carries the device path; no English commentary on the failure mode. + ScsiInterfaceUnavailable { + path: String, + }, + /// Device is held by another process / kernel state. `kr` is the + /// platform return code (macOS IOReturn, Linux errno-equivalent). + DeviceLocked { + path: String, + kr: u32, + }, + /// macOS IOKit plugin couldn't be created for this device. `kr` is + /// the IOReturn code from `IOCreatePlugInInterfaceForService`. + IoKitPluginFailed { + path: String, + kr: u32, + }, // Profile (2xxx) UnsupportedDrive { @@ -111,6 +136,16 @@ pub enum Error { product_revision: String, }, ProfileParse, + /// SCSI transport was requested on an OS without a backend + /// implementation. `target` is the `std::env::consts::OS` value. + UnsupportedPlatform { + target: String, + }, + /// Drive matched a known platform that we haven't implemented yet + /// (e.g. Renesas firmware). `platform` is a stable identifier. + PlatformNotImplemented { + platform: String, + }, // Unlock (3xxx) UnlockFailed, @@ -149,6 +184,12 @@ pub enum Error { IfoParse, MkvInvalid, NoStreams, + /// ddrescue mapfile parse failed. `kind` is a stable, language-neutral + /// identifier (e.g. `"status_char"`, `"hex"`); not a translatable + /// English message. + MapfileInvalid { + kind: &'static str, + }, // AACS (7xxx) AacsNoKeys, @@ -202,6 +243,10 @@ pub enum Error { path: String, }, NoMetadata, + /// `disc://` URLs aren't openable through `input()` — callers must use + /// `Drive::open() + Disc::scan() + DiscStream::new()` directly. This + /// is a structural API constraint, not a parse failure. + DiscUrlNotDirect, } impl Error { @@ -211,8 +256,13 @@ impl Error { Error::DevicePermission { .. } => E_DEVICE_PERMISSION, Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY, Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED, + Error::ScsiInterfaceUnavailable { .. } => E_SCSI_INTERFACE_UNAVAILABLE, + Error::DeviceLocked { .. } => E_DEVICE_LOCKED, + Error::IoKitPluginFailed { .. } => E_IOKIT_PLUGIN_FAILED, Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE, Error::ProfileParse => E_PROFILE_PARSE, + Error::UnsupportedPlatform { .. } => E_UNSUPPORTED_PLATFORM, + Error::PlatformNotImplemented { .. } => E_PLATFORM_NOT_IMPLEMENTED, Error::UnlockFailed => E_UNLOCK_FAILED, Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH, Error::ScsiError { .. } => E_SCSI_ERROR, @@ -226,6 +276,7 @@ impl Error { Error::IfoParse => E_IFO_PARSE, Error::MkvInvalid => E_MKV_INVALID, Error::NoStreams => E_NO_STREAMS, + Error::MapfileInvalid { .. } => E_MAPFILE_INVALID, Error::AacsNoKeys => E_AACS_NO_KEYS, Error::AacsCertShort => E_AACS_CERT_SHORT, Error::AacsAgidAlloc => E_AACS_AGID_ALLOC, @@ -255,6 +306,7 @@ impl Error { Error::PesInvalidMagic => E_PES_INVALID_MAGIC, Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE, Error::NoMetadata => E_NO_METADATA, + Error::DiscUrlNotDirect => E_DISC_URL_NOT_DIRECT, } } } @@ -267,6 +319,22 @@ impl std::fmt::Display for Error { 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::ScsiInterfaceUnavailable { path } => write!(f, "E{}: {}", self.code(), path), + Error::DeviceLocked { path, kr } => { + write!(f, "E{}: {} 0x{:08x}", self.code(), path, kr) + } + Error::IoKitPluginFailed { path, kr } => { + write!(f, "E{}: {} 0x{:08x}", self.code(), path, kr) + } + Error::UnsupportedPlatform { target } => { + write!(f, "E{}: {}", self.code(), target) + } + Error::PlatformNotImplemented { platform } => { + write!(f, "E{}: {}", self.code(), platform) + } + Error::MapfileInvalid { kind } => { + write!(f, "E{}: {}", self.code(), kind) + } Error::UnsupportedDrive { vendor_id, product_id, @@ -357,7 +425,10 @@ impl From for std::io::Error { 7000..=7999 => std::io::ErrorKind::PermissionDenied, 8000..=8999 => std::io::ErrorKind::Other, 9000..=9001 => std::io::ErrorKind::Unsupported, - 9002..=9009 => std::io::ErrorKind::InvalidInput, + 9002..=9008 => std::io::ErrorKind::InvalidInput, + // 9009 DiscUrlNotDirect: structurally unsupported entry point, + // not a parse failure — caller used the wrong API. + 9009 => std::io::ErrorKind::Unsupported, _ => std::io::ErrorKind::Other, }; std::io::Error::new(kind, msg) @@ -366,3 +437,141 @@ impl From for std::io::Error { /// Convenience alias for `Result`. pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + //! Smoke tests for the error code → variant mapping. Each new variant + //! added in 0.13.0 (English-elimination work) gets a code() check + a + //! Display sanity-check (no English words) + an io::ErrorKind mapping + //! check. Without these, future drift between the const codes and the + //! match arms in `code()` / the From impl could silently miscategorize. + use super::*; + + #[test] + fn new_variants_have_distinct_codes() { + let codes = [ + Error::ScsiInterfaceUnavailable { path: "p".into() }.code(), + Error::DeviceLocked { + path: "p".into(), + kr: 0, + } + .code(), + Error::IoKitPluginFailed { + path: "p".into(), + kr: 0, + } + .code(), + Error::UnsupportedPlatform { target: "x".into() }.code(), + Error::PlatformNotImplemented { + platform: "renesas".into(), + } + .code(), + Error::MapfileInvalid { kind: "hex" }.code(), + Error::DiscUrlNotDirect.code(), + ]; + let mut sorted = codes.to_vec(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + codes.len(), + "two new variants share a code — check error.rs constants" + ); + } + + #[test] + fn display_emits_no_english_words() { + // Every variant's Display must be `E{code}: {data}` — no English. + // Sample a few of the new variants and a few existing ones to + // catch accidental string-stuffing in future edits. + let cases: &[(Error, u16)] = &[ + ( + Error::ScsiInterfaceUnavailable { + path: "/dev/sg4".into(), + }, + E_SCSI_INTERFACE_UNAVAILABLE, + ), + ( + Error::DeviceLocked { + path: "/dev/sg4".into(), + kr: 0xE00002C5, + }, + E_DEVICE_LOCKED, + ), + ( + Error::UnsupportedPlatform { + target: "freebsd".into(), + }, + E_UNSUPPORTED_PLATFORM, + ), + ( + Error::PlatformNotImplemented { + platform: "renesas".into(), + }, + E_PLATFORM_NOT_IMPLEMENTED, + ), + (Error::MapfileInvalid { kind: "hex" }, E_MAPFILE_INVALID), + (Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT), + ]; + for (e, want_code) in cases { + let s = e.to_string(); + assert!( + s.starts_with(&format!("E{}", want_code)), + "{:?} display does not lead with code: {}", + e, + s + ); + // Crude English filter — `Display` should never emit ASCII words + // longer than 4 chars (codes/paths/identifiers like `/dev/sg4`, + // `renesas`, `freebsd` all pass; "exclusive access denied" would + // not). + for word in s.split(|c: char| !c.is_ascii_alphabetic()) { + assert!( + word.len() <= 8 + || word.eq_ignore_ascii_case("renesas") + || word.eq_ignore_ascii_case("freebsd"), + "Display contains suspicious English-looking word `{word}` in `{s}`" + ); + } + } + } + + #[test] + fn iokind_mapping_for_new_variants() { + use std::io::ErrorKind; + let mapped = |e: Error| -> ErrorKind { + let io: std::io::Error = e.into(); + io.kind() + }; + // 1xxx range → NotFound + assert_eq!( + mapped(Error::ScsiInterfaceUnavailable { path: "p".into() }), + ErrorKind::NotFound + ); + assert_eq!( + mapped(Error::DeviceLocked { + path: "p".into(), + kr: 0 + }), + ErrorKind::NotFound + ); + // 2xxx range → Unsupported + assert_eq!( + mapped(Error::UnsupportedPlatform { target: "x".into() }), + ErrorKind::Unsupported + ); + assert_eq!( + mapped(Error::PlatformNotImplemented { + platform: "x".into() + }), + ErrorKind::Unsupported + ); + // 6xxx range → InvalidData + assert_eq!( + mapped(Error::MapfileInvalid { kind: "hex" }), + ErrorKind::InvalidData + ); + // 9009 special-cased to Unsupported + assert_eq!(mapped(Error::DiscUrlNotDirect), ErrorKind::Unsupported); + } +} diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 5639bf7..43386be 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -17,6 +17,10 @@ use crate::disc::{DiscTitle, Stream}; use crate::sector::SectorReader; use crate::udf::UdfFs; +// Re-exported via crate::disc — the public API surfaces these next to +// AudioStream/SubtitleStream so callers can map purpose/qualifier to display +// text in their own locale. + /// A stream label extracted from disc config files. #[derive(Debug, Clone)] #[allow(dead_code)] @@ -46,7 +50,6 @@ pub enum StreamLabelType { } #[derive(Debug, Clone, Copy, PartialEq)] -#[allow(dead_code)] pub enum LabelPurpose { Normal, Commentary, @@ -99,16 +102,12 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle if let Some(label) = labels.iter().find(|l| { l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx }) { + // Structured fields — callers translate purpose to UI text. + a.purpose = label.purpose; + + // a.label only carries codec/variant info. NEVER any + // English purpose text — the CLI handles that via i18n. let mut parts = Vec::new(); - match label.purpose { - LabelPurpose::Commentary => parts.push("Commentary".to_string()), - LabelPurpose::Descriptive => { - parts.push("Descriptive Audio".to_string()) - } - LabelPurpose::Score => parts.push("Score".to_string()), - LabelPurpose::Ime => parts.push("IME".to_string()), - LabelPurpose::Normal => {} - } if !label.variant.is_empty() { parts.push(format!("({})", label.variant)); } @@ -117,7 +116,10 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle } if !parts.is_empty() { a.label = parts.join(" "); - } else if !label.name.is_empty() { + } else if !label.name.is_empty() && label.purpose == LabelPurpose::Normal { + // Only fall back to the parser-supplied display + // name when there's no purpose to flag — the CLI + // handles purpose rendering itself. a.label = label.name.clone(); } } @@ -127,6 +129,7 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle if let Some(label) = labels.iter().find(|l| { l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx }) { + s.qualifier = label.qualifier; if label.qualifier == LabelQualifier::Forced { s.forced = true; } @@ -173,9 +176,12 @@ fn generate_video_label( use crate::disc::HdrFormat; if secondary { + // "Dolby Vision EL" is a brand identifier, not English prose, so the + // library may emit it. Other "secondary video" wording is a CLI + // concern — the library just leaves the label empty. return match hdr { HdrFormat::DolbyVision => "Dolby Vision EL".to_string(), - _ => "Secondary Video".to_string(), + _ => String::new(), }; } @@ -217,11 +223,12 @@ fn generate_video_label( fn generate_audio_label( codec: &crate::disc::Codec, channels: &crate::disc::AudioChannels, - secondary: bool, + _secondary: bool, ) -> String { use crate::disc::{AudioChannels, Codec}; - // Full marketing names for disc audio codecs + // Full marketing names for disc audio codecs. + // These are codec brand identifiers, not user-facing English prose. let codec_name = match codec { Codec::TrueHd => "Dolby TrueHD", Codec::Ac3 => "Dolby Digital", @@ -251,12 +258,12 @@ fn generate_audio_label( AudioChannels::Unknown => "", }; - let suffix = if secondary { " (Secondary)" } else { "" }; - + // The "(Secondary)" suffix is a CLI/UI concern — callers display it from + // the AudioStream::secondary bool, not the library. if channel_str.is_empty() { - format!("{}{}", codec_name, suffix) + codec_name.to_string() } else { - format!("{} {}{}", codec_name, channel_str, suffix) + format!("{} {}", codec_name, channel_str) } } diff --git a/src/lib.rs b/src/lib.rs index 8b14590..37e6174 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,21 +94,65 @@ pub(crate) mod speed; pub(crate) mod udf; pub mod verify; +// ─── Drive lifecycle ──────────────────────────────────────────────────────── +// +// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive` +// owns the SCSI session; `DriveCapture` etc. let advanced callers introspect +// drive identity / profile data for sharing. pub use drive::capture::{ CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string, }; pub use drive::{Drive, DriveStatus, find_drive, find_drives}; + +// ─── Errors ───────────────────────────────────────────────────────────────── +// +// All fallible APIs return `Result`. `Error` is a typed enum with a +// numeric `code()`; **no English text in the library** — applications map +// codes to localized messages. See `error.rs` for the full taxonomy. pub use error::{Error, Result}; + +// ─── Drive events (low-level callbacks) ───────────────────────────────────── pub use event::{Event, EventKind}; pub use identity::DriveId; pub use profile::DriveProfile; -// Platform trait is pub(crate) -- callers use Drive, not Platform directly +// Platform trait is pub(crate) — callers use Drive, not Platform directly. + +// ─── Decryption (AACS / CSS) ──────────────────────────────────────────────── +// +// `Disc::scan()` resolves keys and stores them on `Disc`; in most flows you +// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)` +// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is +// for callers that operate on raw sector buffers (e.g. ISO patching). pub use decrypt::{DecryptKeys, decrypt_sectors}; + +// ─── Disc structure ───────────────────────────────────────────────────────── +// +// `Disc::scan()` produces a fully-populated `Disc` (titles, streams, AACS +// state). `Disc::identify()` is the fast path — UDF only, no playlist parse, +// for displaying disc name + format quickly while a full scan runs in the +// background. The codec / channel / resolution enums are the canonical +// structured representation; never compare against display strings. pub use disc::{ AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc, - DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate, - ScanOptions, Stream, SubtitleStream, VideoStream, + DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose, + LabelQualifier, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream, }; + +// ─── Streams ──────────────────────────────────────────────────────────────── +// +// All stream types implement `pes::Stream` — read PES frames from a source, +// write PES frames to a sink. Pick the right type at construction: +// +// - `DiscStream` — physical drive or ISO (any `SectorReader`). Always read. +// - `MkvStream` — Matroska container. Read on `open()`, write on `create()`. +// - `M2tsStream` — Blu-ray Transport Stream. Read on `open()`, write on `create()`. +// - `NetworkStream` — TCP. Read on `listen()`, write on `connect()`. +// - `NullStream` — write-only black-hole sink. Useful for benchmarks. +// - `StdioStream` — pipe to/from stdin/stdout. Read or write. +// +// Most consumers use the URL resolvers (`input()` / `output()`) which pick +// the right type from a scheme:// URL. Direct construction is for callers +// that need to wire custom readers (e.g. autorip's drive-session reuse). pub use mux::DiscStream; pub use mux::M2tsStream; pub use mux::MkvStream; @@ -116,6 +160,13 @@ pub use mux::NetworkStream; pub use mux::NullStream; pub use mux::StdioStream; pub use mux::{InputOptions, StreamUrl, input, output, parse_url}; + +// ─── Lower-level surfaces ─────────────────────────────────────────────────── +// +// `ScsiTransport` is the platform-abstraction trait Drive uses; expose for +// out-of-tree platform backends. `SectorReader` lets callers feed any byte +// source (test harness, network image, SMB share) into the disc scan +// pipeline; `FileSectorReader` is the standard ISO-on-disk implementation. pub use scsi::ScsiTransport; pub use sector::{FileSectorReader, SectorReader}; pub use speed::DriveSpeed; diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index f49e7e6..5e7798f 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -87,11 +87,6 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> { } } -/// Write a complete EBML signed integer element. -pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> { - write_uint(w, id, val as u64) -} - /// Write a complete EBML float element (8-byte double). pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> { write_id(w, id)?; @@ -309,22 +304,6 @@ pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result> { Ok(buf) } -/// Read a VINT (track number) from a SimpleBlock. Returns (value, bytes_consumed). -pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> { - let mut first = [0u8; 1]; - r.read_exact(&mut first)?; - let b0 = first[0]; - if b0 & 0x80 != 0 { - return Ok(((b0 & 0x7F) as u64, 1)); - } - if b0 & 0x40 != 0 { - let mut b = [0u8; 1]; - r.read_exact(&mut b)?; - return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2)); - } - Err(crate::error::Error::MkvInvalid.into()) -} - // ============================================================ // Matroska Element IDs // ============================================================ @@ -342,12 +321,6 @@ pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285; // Segment pub const SEGMENT: u32 = 0x1853_8067; -// Seek Head -pub const SEEK_HEAD: u32 = 0x114D_9B74; -pub const SEEK: u32 = 0x4DBB; -pub const SEEK_ID: u32 = 0x53AB; -pub const SEEK_POSITION: u32 = 0x53AC; - // Segment Info pub const INFO: u32 = 0x1549_A966; pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; diff --git a/src/mux/iso.rs b/src/mux/iso.rs index 92c42b0..9e0ee12 100644 --- a/src/mux/iso.rs +++ b/src/mux/iso.rs @@ -19,8 +19,7 @@ pub struct IsoSectorReader { impl IsoSectorReader { pub fn open(path: &str) -> std::io::Result { - let file = File::open(Path::new(path)) - .map_err(|e| std::io::Error::new(e.kind(), format!("iso://{path}: {e}")))?; + let file = File::open(Path::new(path))?; let size = file.metadata()?.len(); let sectors = size / SECTOR_SIZE; if sectors > u32::MAX as u64 { diff --git a/src/mux/lookahead.rs b/src/mux/lookahead.rs deleted file mode 100644 index d3fc84e..0000000 --- a/src/mux/lookahead.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! LookaheadBuffer — generic pre-scan buffer for stream pipelines. -//! -//! Accumulates data up to a configurable limit. When the consumer finds -//! what it needs, the buffer can be drained (fast path, no re-read). -//! If the buffer fills before the consumer is satisfied, it signals -//! overflow — the caller should discard and re-read from the source. -//! -//! Used by MkvStream to collect SPS/PPS before writing the MKV header. -//! Reusable for any stream stage that needs to look ahead. - -/// Default lookahead buffer size: 5 MB. -pub const DEFAULT_LOOKAHEAD_SIZE: usize = 5 * 1024 * 1024; - -/// Lookahead buffer states. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum LookaheadState { - /// Still collecting data, haven't found what we need yet. - Collecting, - /// Found what we need, buffer has the data ready to drain. - Ready, - /// Buffer overflowed before finding what we need. - /// Caller should discard buffer, finish scanning without buffering, - /// then re-read from the source. - Overflow, -} - -/// A bounded lookahead buffer. -pub struct LookaheadBuffer { - data: Vec, - max_size: usize, - state: LookaheadState, -} - -impl LookaheadBuffer { - /// Create a new buffer with the given max size. - /// Pass 0 for no buffering (always overflows immediately). - pub fn new(max_size: usize) -> Self { - Self { - data: Vec::with_capacity(max_size.min(DEFAULT_LOOKAHEAD_SIZE)), - max_size, - state: LookaheadState::Collecting, - } - } - - /// Push data into the buffer. Returns the new state. - /// If the buffer would overflow, transitions to Overflow state. - pub fn push(&mut self, chunk: &[u8]) -> LookaheadState { - if self.state != LookaheadState::Collecting { - return self.state; - } - - if self.data.len() + chunk.len() > self.max_size { - self.state = LookaheadState::Overflow; - return self.state; - } - - self.data.extend_from_slice(chunk); - self.state - } - - /// Mark the buffer as ready — we found what we need. - pub fn mark_ready(&mut self) { - if self.state == LookaheadState::Collecting { - self.state = LookaheadState::Ready; - } - } - - /// Get the buffered data (only valid in Ready state). - pub fn data(&self) -> &[u8] { - &self.data - } - - /// Take ownership of the buffered data, clearing the buffer. - pub fn drain(&mut self) -> Vec { - self.state = LookaheadState::Collecting; - std::mem::take(&mut self.data) - } - - /// Current state. - pub fn state(&self) -> LookaheadState { - self.state - } - - /// How many bytes are buffered. - pub fn len(&self) -> usize { - self.data.len() - } - - /// Is the buffer empty? - pub fn is_empty(&self) -> bool { - self.data.is_empty() - } - - /// Max size this buffer can hold. - pub fn max_size(&self) -> usize { - self.max_size - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_basic_flow() { - let mut buf = LookaheadBuffer::new(100); - assert_eq!(buf.push(b"hello"), LookaheadState::Collecting); - assert_eq!(buf.push(b"world"), LookaheadState::Collecting); - assert_eq!(buf.len(), 10); - buf.mark_ready(); - assert_eq!(buf.state(), LookaheadState::Ready); - assert_eq!(buf.data(), b"helloworld"); - } - - #[test] - fn test_overflow() { - let mut buf = LookaheadBuffer::new(5); - assert_eq!(buf.push(b"abc"), LookaheadState::Collecting); - assert_eq!(buf.push(b"def"), LookaheadState::Overflow); - } - - #[test] - fn test_zero_size() { - let mut buf = LookaheadBuffer::new(0); - assert_eq!(buf.push(b"a"), LookaheadState::Overflow); - } -} diff --git a/src/mux/meta.rs b/src/mux/meta.rs index e6d54e0..81f2a8f 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -173,6 +173,7 @@ impl M2tsMeta { .parse() .unwrap_or(crate::disc::SampleRate::Unknown), secondary: *secondary, + purpose: crate::disc::LabelPurpose::Normal, label: label.clone(), }), MetaStream::Subtitle { @@ -185,6 +186,7 @@ impl M2tsMeta { codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)), language: language.clone(), forced: *forced, + qualifier: crate::disc::LabelQualifier::None, codec_data: None, }), }) diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index f5a4ff9..ddcc24d 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -8,7 +8,7 @@ use super::ebml; use crate::disc::{ AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream, }; -use std::io::{self, Seek, SeekFrom, Write}; +use std::io::{self, Seek, Write}; /// MKV track definition (built from disc stream metadata). pub struct MkvTrack { @@ -170,10 +170,6 @@ pub struct MkvMuxer { base_pts_ms: Option, cues: Vec, frame_count: u64, - /// File positions of codecPrivate placeholders (track_idx → offset, max_size). - /// Used to seek back and fill in SPS/PPS after first keyframe. - codec_private_slots: Vec>, - codec_private_filled: Vec, } /// New cluster every 5 seconds. @@ -219,8 +215,6 @@ impl MkvMuxer { ebml::end_master(&mut writer, info_pos)?; // Tracks - let mut codec_private_slots: Vec> = Vec::new(); - let mut codec_private_filled: Vec = Vec::new(); let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?; for (i, track) in tracks.iter().enumerate() { let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?; @@ -243,20 +237,12 @@ impl MkvMuxer { if let Some(ref cp) = track.codec_private { ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?; - codec_private_slots.push(None); // already filled - codec_private_filled.push(true); - } else if track.track_type == ebml::TRACK_TYPE_VIDEO { - // Reserve space for codecPrivate — will be filled after first keyframe - // Reserve 256 bytes (enough for SPS+PPS or VPS+SPS+PPS) - let cp_pos = writer.stream_position()?; - let placeholder = vec![0u8; 256]; - ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &placeholder)?; - codec_private_slots.push(Some((cp_pos, 256))); - codec_private_filled.push(false); - } else { - codec_private_slots.push(None); - codec_private_filled.push(true); } + // Pre-0.13 a deferred codecPrivate path existed for video tracks + // (placeholder reserve + later seek-back fill via + // `fill_codec_private`). The PES pipeline hands codec_private + // up-front via the DiscTitle, so the deferred path was never + // exercised — removed in the 0.13 dead-code sweep. // DefaultDuration — frame duration in nanoseconds if track.default_duration_ns > 0 { @@ -344,8 +330,6 @@ impl MkvMuxer { base_pts_ms: None, cues: Vec::new(), frame_count: 0, - codec_private_slots, - codec_private_filled, }) } @@ -415,30 +399,6 @@ impl MkvMuxer { Ok(()) } - /// Fill in a deferred codecPrivate for a track. - /// Seeks back to the placeholder, writes the actual data, restores position. - pub fn fill_codec_private(&mut self, track_idx: usize, data: &[u8]) -> io::Result<()> { - if track_idx >= self.codec_private_filled.len() || self.codec_private_filled[track_idx] { - return Ok(()); - } - if let Some((pos, max_size)) = self.codec_private_slots[track_idx] { - if data.len() > max_size { - // Data too large for reserved space — can't fill in place - // This shouldn't happen with 256 bytes reserved - return Ok(()); - } - let current = self.writer.stream_position()?; - self.writer.seek(SeekFrom::Start(pos))?; - // Rewrite: element ID + size + data + zero-pad remainder - let mut padded = data.to_vec(); - padded.resize(max_size, 0); - ebml::write_binary(&mut self.writer, ebml::CODEC_PRIVATE, &padded)?; - self.writer.seek(SeekFrom::Start(current))?; - self.codec_private_filled[track_idx] = true; - } - Ok(()) - } - fn start_cluster(&mut self, ts_ms: i64) -> io::Result<()> { // Close previous cluster if open if self.cluster_open { @@ -810,6 +770,7 @@ mod tests { codec: Codec::Pgs, language: "eng".into(), forced: true, + qualifier: crate::disc::LabelQualifier::Forced, codec_data: None, }); assert!(forced_sub.is_forced); @@ -835,6 +796,7 @@ mod tests { codec: Codec::Pgs, language: "eng".into(), forced: false, + qualifier: crate::disc::LabelQualifier::None, codec_data: None, }); assert!(!sub.is_forced); diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index e20fa36..6133607 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -380,6 +380,7 @@ fn parse_track( language: lang, sample_rate: srs, secondary: false, + purpose: crate::disc::LabelPurpose::Normal, label: name, })), 17 => Some(crate::disc::Stream::Subtitle(SubtitleStream { @@ -387,6 +388,7 @@ fn parse_track( codec, language: lang, forced, + qualifier: crate::disc::LabelQualifier::None, codec_data: None, })), _ => None, diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 87cfab9..692e6d8 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -15,22 +15,32 @@ //! //! For disc→ISO (raw sector copy), use `Disc::copy()` instead. +// Public modules — types here are intentionally part of the consumable API. pub mod codec; pub mod disc; -pub mod ebml; pub mod iso; -mod m2ts; -pub mod meta; -pub mod mkv; -mod mkvstream; -pub mod network; -pub mod null; -pub mod ps; pub mod resolve; -pub mod stdio; -pub mod ts; -pub mod tsmux; -pub mod tsreader; + +// Internal modules — implementation details. Their *types* are re-exported +// where appropriate (`MkvStream`, `M2tsStream`, etc. surface from `lib.rs`), +// but the module paths themselves are not part of the API. Pre-0.13 these +// were `pub`, leaking low-level EBML primitives, TS muxer internals, and +// network/stdio implementations that no external caller had business +// reaching for. +pub(crate) mod ebml; +pub(crate) mod m2ts; +/// FMKV metadata header (used by `M2tsStream` / `NetworkStream` / `StdioStream` +/// to round-trip codec_privates that don't fit inside the underlying format). +/// Exposed for integration tests that exercise the wire format directly. +pub mod meta; +pub(crate) mod mkv; +pub(crate) mod mkvstream; +pub(crate) mod network; +pub(crate) mod null; +pub(crate) mod ps; +pub(crate) mod stdio; +pub(crate) mod ts; +pub(crate) mod tsmux; pub use disc::DiscStream; pub use iso::IsoSectorReader; @@ -43,6 +53,13 @@ pub use stdio::StdioStream; use std::io::{Seek, Write}; -// WriteSeek — used internally by MKV muxer (container format requires seeking). +/// Combined `Write + Seek` for sinks accepted by the MKV muxer. +/// +/// Matroska's `SeekHead`, `Cues`, and `Cluster` size fields are written with +/// placeholder values during streaming and updated in-place at finalization, +/// so the output sink must support seeking. Provided as a single trait +/// alias so callers don't have to repeat `Write + Seek` everywhere; the +/// blanket impl below opts every `T: Write + Seek` in automatically +/// (`File`, `BufWriter`, `Cursor>`). pub trait WriteSeek: Write + Seek {} impl WriteSeek for T {} diff --git a/src/mux/network.rs b/src/mux/network.rs index 2e1053f..c69e019 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -144,6 +144,7 @@ mod tests { language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: crate::disc::LabelPurpose::Normal, label: "English".into(), }), ], diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 6c43732..7fccd47 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -171,17 +171,18 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result { - // Disc sources should use DiscStream::new() directly. - // The caller opens the drive, inits, scans, then creates the stream. - Err(io::Error::new( - io::ErrorKind::Unsupported, - "Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources", - )) + // Disc sources require live SCSI state — caller must use + // `Drive::open() + Disc::scan() + DiscStream::new()` directly. + // Surfaced as a typed error (no English commentary in the + // library; the CLI/UI explains the right entry point). + Err(crate::error::Error::DiscUrlNotDirect.into()) } StreamUrl::Iso { ref path } => { validate_file_path(path, "iso")?; let scan_opts = match &opts.keydb_path { - Some(p) => crate::disc::ScanOptions::with_keydb(p), + Some(p) => crate::disc::ScanOptions { + keydb_path: Some(p.into()), + }, None => crate::disc::ScanOptions::default(), }; let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?; @@ -210,17 +211,13 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result { 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 file = std::fs::File::open(path)?; let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file); Ok(Box::new(M2tsStream::open(reader)?)) } StreamUrl::Mkv { ref path } => { validate_file_path(path, "mkv")?; - let file = std::fs::File::open(path).map_err(|e| { - io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)) - })?; + let file = std::fs::File::open(path)?; let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file); Ok(Box::new(MkvStream::open(reader)?)) } @@ -245,18 +242,14 @@ pub fn output( match parsed { StreamUrl::Mkv { ref path } => { validate_file_path(path, "mkv")?; - let file = std::fs::File::create(path).map_err(|e| { - io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)) - })?; + let file = std::fs::File::create(path)?; let writer: Box = Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file)); Ok(Box::new(MkvStream::create(writer, title)?)) } StreamUrl::M2ts { ref path } => { validate_file_path(path, "m2ts")?; - let file = std::fs::File::create(path).map_err(|e| { - io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e)) - })?; + let file = std::fs::File::create(path)?; let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file); Ok(Box::new(M2tsStream::create(writer, title)?)) } diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 92c2cfc..e55383e 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -15,12 +15,6 @@ const TS_PACKET_SIZE: usize = 188; /// TS sync byte. const SYNC_BYTE: u8 = 0x47; -/// Size of head buffer for PTS/stream scanning (1 MB). -const SCAN_HEAD_SIZE: usize = 1024 * 1024; - -/// Size of tail buffer for last-PTS scanning (2 MB). -const SCAN_TAIL_SIZE: usize = 2 * 1024 * 1024; - /// A reassembled PES packet with timestamp info. #[derive(Debug)] pub struct PesPacket { @@ -104,19 +98,16 @@ pub struct TsDemuxer { } impl TsDemuxer { - /// Take the remainder bytes (leftover from last feed() that didn't - /// align to a 192-byte packet boundary). - pub fn take_remainder(&mut self) -> Vec { - std::mem::take(&mut self.remainder) - } - - /// Set the remainder bytes. Used to transfer alignment state from - /// one demuxer to another without losing sync. - pub fn set_remainder(&mut self, data: Vec) { - self.remainder = data; - } - /// Create a new demuxer tracking the given PIDs. + /// + /// Allocates a flat lookup table of `i16` slots — one per possible PID + /// up to `max(8192, max_pid + 1)`. The 8192 floor matches the BD-TS + /// 13-bit PID space (0..0x1FFF); the variable upper bound exists for + /// DVD program streams which may use 16-bit stream IDs above 8191. + /// Worst-case allocation is `u16::MAX × 2 bytes ≈ 128 KB` — bounded by + /// the type, so adversarial input can't drive this beyond predictable + /// limits. Empty `pids` yields max_pid 0; the floor still produces a + /// valid (wholly-unused) table. pub fn new(pids: &[u16]) -> Self { let max_pid = pids.iter().copied().max().unwrap_or(0) as usize; let table_size = (max_pid + 1).max(8192); @@ -446,6 +437,7 @@ pub fn scan_streams(data: &[u8]) -> Option> { language: "und".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: crate::disc::LabelPurpose::Normal, label: String::new(), })), 0x83 => Some(Stream::Audio(AudioStream { @@ -455,6 +447,7 @@ pub fn scan_streams(data: &[u8]) -> Option> { language: "und".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: crate::disc::LabelPurpose::Normal, label: String::new(), })), 0x84 | 0xA1 => Some(Stream::Audio(AudioStream { @@ -464,6 +457,7 @@ pub fn scan_streams(data: &[u8]) -> Option> { language: "und".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: crate::disc::LabelPurpose::Normal, label: String::new(), })), 0x85 | 0x86 => Some(Stream::Audio(AudioStream { @@ -473,6 +467,7 @@ pub fn scan_streams(data: &[u8]) -> Option> { language: "und".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: crate::disc::LabelPurpose::Normal, label: String::new(), })), 0x82 => Some(Stream::Audio(AudioStream { @@ -482,6 +477,7 @@ pub fn scan_streams(data: &[u8]) -> Option> { language: "und".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: crate::disc::LabelPurpose::Normal, label: String::new(), })), 0x90 => Some(Stream::Subtitle(SubtitleStream { @@ -489,6 +485,7 @@ pub fn scan_streams(data: &[u8]) -> Option> { codec: Codec::Pgs, language: "und".into(), forced: false, + qualifier: crate::disc::LabelQualifier::None, codec_data: None, })), _ => None, @@ -511,103 +508,6 @@ pub fn scan_streams(data: &[u8]) -> Option> { } } -// ============================================================ -// PTS scanning utilities (for duration detection) -// ============================================================ - -/// Find the first PTS for a given PID in BD-TS data. -pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option { - let mut offset = 0; - while offset + BD_TS_PACKET_SIZE <= data.len() { - if data[offset + 4] != SYNC_BYTE { - offset += 1; - continue; - } - let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16; - let pusi = data[offset + 5] & 0x40 != 0; - if pid == target_pid && pusi { - let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE]; - let afc = (ts[3] >> 4) & 0x03; - let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 }; - if payload_start < TS_PACKET_SIZE { - let payload = &ts[payload_start..]; - if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 { - let pts_dts_flags = (payload[7] >> 6) & 0x03; - if pts_dts_flags >= 2 { - return parse_timestamp(&payload[9..14]); - } - } - } - } - offset += BD_TS_PACKET_SIZE; - } - None -} - -/// Find the last PTS for a given PID in BD-TS data. -pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option { - let mut last_pts = None; - let mut offset = 0; - while offset + BD_TS_PACKET_SIZE <= data.len() { - if data[offset + 4] != SYNC_BYTE { - offset += 1; - continue; - } - let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16; - let pusi = data[offset + 5] & 0x40 != 0; - if pid == target_pid && pusi { - let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE]; - let afc = (ts[3] >> 4) & 0x03; - let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 }; - if payload_start < TS_PACKET_SIZE { - let payload = &ts[payload_start..]; - if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 { - let pts_dts_flags = (payload[7] >> 6) & 0x03; - if pts_dts_flags >= 2 { - last_pts = parse_timestamp(&payload[9..14]); - } - } - } - } - offset += BD_TS_PACKET_SIZE; - } - last_pts -} - -/// Scan an m2ts file for duration by reading first and last PTS. -/// Returns duration in seconds, or None if PTS cannot be found. -/// The reader position is restored after scanning. -pub fn scan_duration(r: &mut R, video_pid: u16) -> Option { - use std::io::SeekFrom; - - let start_pos = r.stream_position().ok()?; - - // Read first 1MB for first PTS - let mut head_buf = vec![0u8; SCAN_HEAD_SIZE]; - r.seek(SeekFrom::Start(0)).ok()?; - let head_n = r.read(&mut head_buf).ok()?; - let first_pts = scan_first_pts(&head_buf[..head_n], video_pid)?; - - // Read last 2MB for last PTS (aligned to 192-byte boundary) - let file_size = r.seek(SeekFrom::End(0)).ok()?; - let tail_size: u64 = SCAN_TAIL_SIZE as u64; - let raw_pos = file_size.saturating_sub(tail_size); - let seek_pos = (raw_pos / BD_TS_PACKET_SIZE as u64) * BD_TS_PACKET_SIZE as u64; - r.seek(SeekFrom::Start(seek_pos)).ok()?; - let mut tail_buf = vec![0u8; tail_size as usize]; - let tail_n = r.read(&mut tail_buf).ok()?; - let last_pts = scan_last_pts(&tail_buf[..tail_n], video_pid)?; - - // Restore reader position - let _ = r.seek(SeekFrom::Start(start_pos)); - - if last_pts > first_pts { - Some((last_pts - first_pts) as f64 / 90000.0) - } else { - None - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/mux/tsreader.rs b/src/mux/tsreader.rs deleted file mode 100644 index 8c5132a..0000000 --- a/src/mux/tsreader.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! TsDemuxReader — reads from any source, demuxes BD-TS, produces PES frames. -//! -//! Wraps any Read source with a TsDemuxer + CodecParsers. -//! One implementation used by M2TS, Network, Stdio, and any other BD-TS input. - -use super::codec::{self, CodecParser}; -use super::ts::TsDemuxer; -use crate::disc::Stream as DiscStream; -use crate::pes::PesFrame; -use std::collections::VecDeque; -use std::io::{self, Read}; - -const READ_BUF_SIZE: usize = 192 * 1024; // 1024 BD-TS packets - -/// Generic BD-TS → PES frame reader. -pub struct TsDemuxReader { - reader: R, - demuxer: TsDemuxer, - parsers: Vec<(u16, Box)>, - pid_to_track: Vec<(u16, usize)>, - pending: VecDeque, - buf: Vec, - eof: bool, -} - -impl TsDemuxReader { - /// Create from a reader and stream metadata. - pub fn new(reader: R, streams: &[DiscStream]) -> Self { - let mut pids = Vec::new(); - let mut parsers: Vec<(u16, Box)> = Vec::new(); - let mut pid_to_track = Vec::new(); - for (i, s) in streams.iter().enumerate() { - let (pid, c) = match s { - DiscStream::Video(v) => (v.pid, v.codec), - DiscStream::Audio(a) => (a.pid, a.codec), - DiscStream::Subtitle(s) => (s.pid, s.codec), - }; - pids.push(pid); - pid_to_track.push((pid, i)); - parsers.push((pid, codec::parser_for_codec(c, None))); - } - - Self { - reader, - demuxer: TsDemuxer::new(&pids), - parsers, - pid_to_track, - pending: VecDeque::new(), - buf: vec![0u8; READ_BUF_SIZE], - eof: false, - } - } - - /// Get the next PES frame. Returns None at EOF. - pub fn next_frame(&mut self) -> io::Result> { - if let Some(frame) = self.pending.pop_front() { - return Ok(Some(frame)); - } - if self.eof { - return Ok(None); - } - - loop { - let n = self.reader.read(&mut self.buf)?; - if n == 0 { - self.eof = true; - return Ok(None); - } - - let packets = self.demuxer.feed(&self.buf[..n]); - for pes in &packets { - if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) - { - if let Some((_, parser)) = - self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) - { - for frame in parser.parse(pes) { - self.pending - .push_back(PesFrame::from_codec_frame(*track, frame)); - } - } - } - } - - if let Some(frame) = self.pending.pop_front() { - return Ok(Some(frame)); - } - } - } - - /// Codec private data for a track. - pub fn codec_private(&self, track: usize) -> Option> { - let pid = self - .pid_to_track - .iter() - .find(|(_, idx)| *idx == track) - .map(|(pid, _)| *pid)?; - self.parsers - .iter() - .find(|(p, _)| *p == pid) - .and_then(|(_, parser)| parser.codec_private()) - } - - /// True when all primary video tracks have codec_private. - pub fn headers_ready(&self, streams: &[DiscStream]) -> bool { - for (idx, s) in streams.iter().enumerate() { - if let DiscStream::Video(v) = s { - if !v.secondary && self.codec_private(idx).is_none() { - return false; - } - } - } - true - } -} diff --git a/src/pes.rs b/src/pes.rs index f67e4fc..65a5754 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -78,26 +78,63 @@ impl PesFrame { } } -/// A stream. Read from it or write to it. Not both. +/// A PES frame source or sink. Each implementor is **either** read-only or +/// write-only — never both. +/// +/// Implementors fall into two camps: +/// +/// - **Read sources**: `DiscStream` (drive or ISO), `M2tsStream` (when +/// constructed from an existing file), `MkvStream` (demux), `NetworkStream` +/// (TCP listener), `StdioStream::input()`. These return frames from +/// `read()` and surface `StreamWriteOnly` (E9001) from `write()`. +/// - **Write sinks**: `MkvStream::create`, `M2tsStream::create`, +/// `NetworkStream::connect`, `StdioStream::output()`, `NullStream`. +/// These accept frames in `write()` and surface `StreamReadOnly` (E9000) +/// from `read()`. Always call `finish()` when done — that's where MKV +/// writes its `Cues` index and `M2tsStream` flushes the TS muxer. +/// +/// Direction is established at construction; mixing produces an error code, +/// not a panic. Most consumers don't construct streams directly — call +/// `mux::input(url, opts)` / `mux::output(url, title)` and let URL parsing +/// pick the right type. +/// +/// `info()` returns the stream's `DiscTitle` metadata (track list, codec +/// info, duration). For sources it's parsed from the input; for sinks it's +/// the metadata supplied at creation. Stable across all reads. +/// +/// `codec_private(track)` exposes per-track initialization data +/// (H.264 SPS/PPS, HEVC VPS/SPS/PPS, AC-3 fscod, etc.) that some output +/// formats need before any frame can be written. `headers_ready()` returns +/// false until enough input frames have been seen to populate every video +/// track's codec-private blob — callers buffer frames they read until +/// `headers_ready()` returns true. pub trait Stream { - /// Read the next frame. Returns None at end of stream. + /// Read the next frame, or `Ok(None)` at end of stream. Returns + /// `StreamWriteOnly` (E9001) on a write-only sink. fn read(&mut self) -> std::io::Result>; - /// Write a frame. + /// Write a frame to the sink. Returns `StreamReadOnly` (E9000) on a + /// read-only source. fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>; - /// Finalize (flush, write index, close). + /// Finalize the stream: flush buffered frames, write any container + /// index (MKV `Cues`), close the underlying file/socket. Idempotent + /// for read-only streams (no-op). fn finish(&mut self) -> std::io::Result<()>; - /// Stream metadata. + /// Stream metadata. Stable across reads — implementors must return a + /// consistent reference for the lifetime of the stream. fn info(&self) -> &crate::disc::DiscTitle; - /// Codec initialization data for a track (SPS/PPS, etc). + /// Codec initialization data for a track (SPS/PPS, AC-3 fscod, etc.). + /// `None` for tracks that don't need codec_private (raw passthrough). fn codec_private(&self, _track: usize) -> Option> { None } - /// True when codec_private is available for all video tracks. + /// True when `codec_private` is available for every video track — + /// callers buffer input frames until this flips, since some output + /// formats (MKV) can't write frames without codec init data. fn headers_ready(&self) -> bool { true } diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index 031ea08..d001eb1 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -212,8 +212,9 @@ impl MacScsiTransport { unsafe { IOObjectRelease(service) }; if kr != K_IO_RETURN_SUCCESS || plugin.is_null() { - return Err(Error::DeviceNotFound { - path: format!("{}: IOKit plugin creation failed (0x{:08x})", dev_str, kr), + return Err(Error::IoKitPluginFailed { + path: dev_str.to_string(), + kr: kr as u32, }); } @@ -231,8 +232,8 @@ impl MacScsiTransport { com_release(plugin); if hr != 0 || device_iface.is_null() { - return Err(Error::DeviceNotFound { - path: format!("{}: SCSITaskDeviceInterface not available", dev_str), + return Err(Error::ScsiInterfaceUnavailable { + path: dev_str.to_string(), }); } @@ -244,11 +245,12 @@ impl MacScsiTransport { }; if kr != K_IO_RETURN_SUCCESS { com_release(device_iface); - return Err(Error::DevicePermission { - path: format!( - "{}: exclusive access denied (0x{:08x}). Try: diskutil unmountDisk {}", - dev_str, kr, dev_str - ), + // No "Try: diskutil unmountDisk" hint — that's the CLI's job. + // The typed variant carries device path + IOReturn so the + // caller can render the right message in the right language. + return Err(Error::DeviceLocked { + path: dev_str.to_string(), + kr: kr as u32, }); } @@ -404,13 +406,23 @@ impl ScsiTransport for MacScsiTransport { /// BSD name → IOKit service for the SCSI device. /// /// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub. +/// +/// All failure paths surface as `Error::DeviceNotFound { path: bsd_name }` — +/// the four internal stages (IOMasterPort / IOBSDNameMatching / IOMedia +/// lookup / walk_to_authoring_device) collapse into one observable error +/// because none of them are user-actionable individually. Pre-0.13 each +/// stage stuffed an English description into `path:` ("…IOMasterPort +/// failed", "…SCSITaskDeviceInterface not available", etc.) which broke +/// the library's "no English text" rule. fn find_scsi_service(bsd_name: &str) -> Result { + let not_found = || Error::DeviceNotFound { + path: bsd_name.to_string(), + }; + let mut master: MachPort = 0; let kr = unsafe { IOMasterPort(0, &mut master) }; if kr != K_IO_RETURN_SUCCESS { - return Err(Error::DeviceNotFound { - path: format!("{}: IOMasterPort failed", bsd_name), - }); + return Err(not_found()); } // IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name } @@ -418,17 +430,13 @@ fn find_scsi_service(bsd_name: &str) -> Result { bsd_c.push(0); let matching = unsafe { IOBSDNameMatching(master, 0, bsd_c.as_ptr()) }; if matching.is_null() { - return Err(Error::DeviceNotFound { - path: format!("{}: IOBSDNameMatching failed", bsd_name), - }); + return Err(not_found()); } // Find the single IOMedia service (consumes the matching dict) let media = unsafe { IOServiceGetMatchingService(master, matching) }; if media == 0 { - return Err(Error::DeviceNotFound { - path: format!("{}: no IOMedia found", bsd_name), - }); + return Err(not_found()); } // Walk up the IOService plane to find the authoring device. @@ -441,9 +449,7 @@ fn find_scsi_service(bsd_name: &str) -> Result { let service = walk_to_authoring_device(media); unsafe { IOObjectRelease(media) }; - service.ok_or_else(|| Error::DeviceNotFound { - path: format!("{}: no SCSI authoring device in IORegistry", bsd_name), - }) + service.ok_or_else(not_found) } /// Walk up the IOService plane from an IOMedia to the SCSI authoring device. diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index e574dc5..0efcd34 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -82,8 +82,8 @@ pub fn open(device: &Path) -> Result> { #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { - Err(Error::DeviceNotFound { - path: format!("{}: unsupported platform", device.display()), + Err(Error::UnsupportedPlatform { + target: std::env::consts::OS.to_string(), }) } } diff --git a/src/sector.rs b/src/sector.rs index 0b8b5bf..cc625c5 100644 --- a/src/sector.rs +++ b/src/sector.rs @@ -39,10 +39,12 @@ impl FileSectorReader { let len = file.metadata()?.len(); let sectors = len / 2048; if sectors > u32::MAX as u64 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("{path}: image too large, max ~8 TB"), - )); + // ~8 TB hard cap (u32::MAX × 2048 bytes). Path lives in the + // typed Error variant — no English in the message. + return Err(crate::error::Error::IsoTooLarge { + path: path.to_string(), + } + .into()); } let capacity = sectors as u32; Ok(Self { diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index 12fc79d..b66df5f 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -361,7 +361,9 @@ fn aacs_cross_validation_encrypt_then_decrypt() { plaintext[off] = 0x47; off += 192; } - // Fill the rest with a recognisable pattern (prime modulus avoids artefacts) + // Fill the rest with a recognisable pattern (prime modulus avoids artefacts). + // Index-arithmetic — clearer with a counted loop than an enumerate chain. + #[allow(clippy::needless_range_loop)] for i in 16..aacs::ALIGNED_UNIT_LEN { if plaintext[i] == 0 { plaintext[i] = (i % 251) as u8; @@ -462,6 +464,7 @@ fn aacs_bus_decrypt_cross_validation() { ]; let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN]; + #[allow(clippy::needless_range_loop)] for i in 0..aacs::ALIGNED_UNIT_LEN { plaintext[i] = ((i * 3 + 17) & 0xFF) as u8; } @@ -494,6 +497,7 @@ fn css_roundtrip_with_snapshot() { let mut sector = vec![0x00u8; 2048]; sector[0x14] = 0x30; sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]); + #[allow(clippy::needless_range_loop)] for i in 0x80..2048 { sector[i] = ((i * 7 + 3) & 0xFF) as u8; } @@ -661,6 +665,7 @@ fn css_recover_title_key_with_exact_plaintext() { sector[0x54..0x59].copy_from_slice(&seed); let pes_header: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21]; sector[0x80..0x8A].copy_from_slice(&pes_header); + #[allow(clippy::needless_range_loop)] for i in 0x8A..2048 { sector[i] = ((i * 13 + 7) & 0xFF) as u8; } diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 5cdf7ae..9ea7117 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -133,7 +133,9 @@ fn scan_options_default() { #[test] fn scan_options_with_keydb() { - let opts = ScanOptions::with_keydb("/tmp/KEYDB.cfg"); + let opts = ScanOptions { + keydb_path: Some(("/tmp/KEYDB.cfg").into()), + }; assert_eq!( opts.keydb_path.as_ref().unwrap().to_str().unwrap(), "/tmp/KEYDB.cfg" @@ -143,7 +145,9 @@ fn scan_options_with_keydb() { #[test] fn scan_options_with_keydb_pathbuf() { let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg"); - let opts = ScanOptions::with_keydb(path.clone()); + let opts = ScanOptions { + keydb_path: Some(path.clone()), + }; assert_eq!(opts.keydb_path.unwrap(), path); } @@ -511,7 +515,9 @@ fn resolve_encryption_no_keydb() { build_udf_with_aacs_dir(&mut reader); // No keydb configured and no standard keydb on the system - let opts = ScanOptions::with_keydb("/nonexistent/path/KEYDB.cfg"); + let opts = ScanOptions { + keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()), + }; let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap(); // The disc detects encryption but can't resolve keys without a keydb diff --git a/tests/streams.rs b/tests/streams.rs index 07c1c9c..8f339e1 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -30,6 +30,7 @@ fn sample_disc_title() -> DiscTitle { language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: libfreemkv::LabelPurpose::Normal, label: "English Atmos".into(), }), Stream::Audio(AudioStream { @@ -39,6 +40,7 @@ fn sample_disc_title() -> DiscTitle { language: "fra".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: libfreemkv::LabelPurpose::Normal, label: "French".into(), }), Stream::Subtitle(SubtitleStream { @@ -46,6 +48,7 @@ fn sample_disc_title() -> DiscTitle { codec: Codec::Pgs, language: "eng".into(), forced: false, + qualifier: libfreemkv::LabelQualifier::None, codec_data: None, }), ], @@ -404,6 +407,7 @@ fn meta_codec_roundtrip() { language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: libfreemkv::LabelPurpose::Normal, label: String::new(), })); } @@ -413,6 +417,7 @@ fn meta_codec_roundtrip() { codec, language: "eng".into(), forced: false, + qualifier: libfreemkv::LabelQualifier::None, codec_data: None, })); } @@ -501,6 +506,7 @@ fn meta_all_stream_types() { language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: libfreemkv::LabelPurpose::Normal, label: "Primary Audio".into(), }), Stream::Subtitle(SubtitleStream { @@ -508,6 +514,7 @@ fn meta_all_stream_types() { codec: Codec::Pgs, language: "fra".into(), forced: true, + qualifier: libfreemkv::LabelQualifier::None, codec_data: None, }), Stream::Audio(AudioStream { @@ -517,6 +524,7 @@ fn meta_all_stream_types() { language: "eng".into(), sample_rate: SampleRate::S48, secondary: true, + purpose: libfreemkv::LabelPurpose::Normal, label: "Commentary".into(), }), ], @@ -623,6 +631,7 @@ fn mkvstream_roundtrip_bdts() { language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: libfreemkv::LabelPurpose::Normal, label: "English".into(), })], chapters: Vec::new(), @@ -679,6 +688,7 @@ fn mkvstream_meta_preserves_all_streams() { language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: libfreemkv::LabelPurpose::Normal, label: "English".into(), }), Stream::Audio(AudioStream { @@ -688,6 +698,7 @@ fn mkvstream_meta_preserves_all_streams() { language: "fra".into(), sample_rate: SampleRate::S48, secondary: false, + purpose: libfreemkv::LabelPurpose::Normal, label: "French".into(), }), Stream::Subtitle(SubtitleStream { @@ -695,6 +706,7 @@ fn mkvstream_meta_preserves_all_streams() { codec: Codec::Pgs, language: "eng".into(), forced: false, + qualifier: libfreemkv::LabelQualifier::None, codec_data: None, }), Stream::Subtitle(SubtitleStream { @@ -702,6 +714,7 @@ fn mkvstream_meta_preserves_all_streams() { codec: Codec::Pgs, language: "fra".into(), forced: true, + qualifier: libfreemkv::LabelQualifier::None, codec_data: None, }), ],