v0.13.0: zero English in library + API hygiene + dead-code sweep
Audit pass against the CLAUDE.md "no English text in library code" rule.
Found 9 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 Error variant.
New variants and codes: ScsiInterfaceUnavailable (E1004), DeviceLocked
(E1005), IoKitPluginFailed (E1006), UnsupportedPlatform (E2003),
PlatformNotImplemented (E2004), MapfileInvalid (E6011), DiscUrlNotDirect
(E9009).
labels::apply() previously pushed Commentary/Descriptive/Score/IME and
" (Secondary)" English literals into AudioStream.label, leaking into
MKV titles + autorip UI. AudioStream now exposes structured `purpose:
LabelPurpose`, SubtitleStream `qualifier: LabelQualifier`. Callers
translate to localized text. label keeps codec-formatting only.
API hygiene: 11 mux/* modules dropped from `pub` to `pub(crate)` —
their *types* are still re-exported from lib.rs, but the modules were
leaking low-level EBML/TS/network primitives. Stream trait gets a real
rustdoc explaining read-vs-write split. lib.rs grouped re-exports into
documented sections. ScanOptions::with_keydb() removed (one-method-per-
action rule); use struct literal.
Dead-code sweep: removed lookahead.rs (orphan, never declared as mod),
tsreader.rs (TsDemuxReader unused), ebml::{write_int,read_vint,SEEK_*},
ts::{scan_first/last_pts,scan_duration,SCAN_HEAD/TAIL_SIZE,take/set_
remainder}, MkvMuxer codec_private_slots/filled fields and
fill_codec_private method (deferred-codecPrivate path never used since
the v0.10 PES rewrite). cargo clippy --all-targets -D warnings clean.
Tests: new error::tests for variant codes + Display "no English" guard +
io::ErrorKind mapping. 233 lib tests, all green (was 230).
Breaking: ScanOptions::with_keydb removed; mux/* modules pub(crate);
AudioStream and SubtitleStream gained required fields; UnsupportedDrive
{ product_revision: "Renesas not yet implemented" } no longer produced
(use PlatformNotImplemented).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
|
||||
+13
-6
@@ -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<u64> {
|
||||
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)]
|
||||
|
||||
+13
-8
@@ -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<Vec<u8>>,
|
||||
}
|
||||
@@ -889,13 +901,6 @@ pub struct ScanOptions {
|
||||
}
|
||||
|
||||
impl ScanOptions {
|
||||
/// Create options with a specific KEYDB path.
|
||||
pub fn with_keydb(path: impl Into<std::path::PathBuf>) -> Self {
|
||||
ScanOptions {
|
||||
keydb_path: Some(path.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve KEYDB path: explicit path first, then standard locations.
|
||||
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = &self.keydb_path {
|
||||
|
||||
+2
-4
@@ -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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
+210
-1
@@ -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<Error> 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<Error> for std::io::Error {
|
||||
|
||||
/// Convenience alias for `Result<T, Error>`.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-18
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
-3
@@ -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<T, Error>`. `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;
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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;
|
||||
|
||||
+1
-2
@@ -19,8 +19,7 @@ pub struct IsoSectorReader {
|
||||
|
||||
impl IsoSectorReader {
|
||||
pub fn open(path: &str) -> std::io::Result<Self> {
|
||||
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 {
|
||||
|
||||
@@ -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<u8>,
|
||||
max_size: usize,
|
||||
state: LookaheadState,
|
||||
}
|
||||
|
||||
impl LookaheadBuffer {
|
||||
/// Create a new buffer with the given max size.
|
||||
/// Pass 0 for no buffering (always overflows immediately).
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
Self {
|
||||
data: Vec::with_capacity(max_size.min(DEFAULT_LOOKAHEAD_SIZE)),
|
||||
max_size,
|
||||
state: LookaheadState::Collecting,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push data into the buffer. Returns the new state.
|
||||
/// If the buffer would overflow, transitions to Overflow state.
|
||||
pub fn push(&mut self, chunk: &[u8]) -> LookaheadState {
|
||||
if self.state != LookaheadState::Collecting {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
if self.data.len() + chunk.len() > self.max_size {
|
||||
self.state = LookaheadState::Overflow;
|
||||
return self.state;
|
||||
}
|
||||
|
||||
self.data.extend_from_slice(chunk);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Mark the buffer as ready — we found what we need.
|
||||
pub fn mark_ready(&mut self) {
|
||||
if self.state == LookaheadState::Collecting {
|
||||
self.state = LookaheadState::Ready;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the buffered data (only valid in Ready state).
|
||||
pub fn data(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Take ownership of the buffered data, clearing the buffer.
|
||||
pub fn drain(&mut self) -> Vec<u8> {
|
||||
self.state = LookaheadState::Collecting;
|
||||
std::mem::take(&mut self.data)
|
||||
}
|
||||
|
||||
/// Current state.
|
||||
pub fn state(&self) -> LookaheadState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// How many bytes are buffered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
/// Is the buffer empty?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.data.is_empty()
|
||||
}
|
||||
|
||||
/// Max size this buffer can hold.
|
||||
pub fn max_size(&self) -> usize {
|
||||
self.max_size
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_flow() {
|
||||
let mut buf = LookaheadBuffer::new(100);
|
||||
assert_eq!(buf.push(b"hello"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.push(b"world"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.len(), 10);
|
||||
buf.mark_ready();
|
||||
assert_eq!(buf.state(), LookaheadState::Ready);
|
||||
assert_eq!(buf.data(), b"helloworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overflow() {
|
||||
let mut buf = LookaheadBuffer::new(5);
|
||||
assert_eq!(buf.push(b"abc"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.push(b"def"), LookaheadState::Overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_size() {
|
||||
let mut buf = LookaheadBuffer::new(0);
|
||||
assert_eq!(buf.push(b"a"), LookaheadState::Overflow);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
+8
-46
@@ -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<W: Write + Seek> {
|
||||
base_pts_ms: Option<i64>,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
|
||||
/// Used to seek back and fill in SPS/PPS after first keyframe.
|
||||
codec_private_slots: Vec<Option<(u64, usize)>>,
|
||||
codec_private_filled: Vec<bool>,
|
||||
}
|
||||
|
||||
/// New cluster every 5 seconds.
|
||||
@@ -219,8 +215,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, info_pos)?;
|
||||
|
||||
// Tracks
|
||||
let mut codec_private_slots: Vec<Option<(u64, usize)>> = Vec::new();
|
||||
let mut codec_private_filled: Vec<bool> = Vec::new();
|
||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
@@ -243,20 +237,12 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
|
||||
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<W: Write + Seek> MkvMuxer<W> {
|
||||
base_pts_ms: None,
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
codec_private_slots,
|
||||
codec_private_filled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,30 +399,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+30
-13
@@ -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<File>`, `Cursor<Vec<u8>>`).
|
||||
pub trait WriteSeek: Write + Seek {}
|
||||
impl<T: Write + Seek> WriteSeek for T {}
|
||||
|
||||
@@ -144,6 +144,7 @@ mod tests {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: "English".into(),
|
||||
}),
|
||||
],
|
||||
|
||||
+12
-19
@@ -171,17 +171,18 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
let parsed = parse_url(url);
|
||||
match parsed {
|
||||
StreamUrl::Disc { .. } => {
|
||||
// Disc sources should use DiscStream::new() directly.
|
||||
// The caller opens the drive, inits, scans, then creates the stream.
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources",
|
||||
))
|
||||
// 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<Box<dyn crate::pes::S
|
||||
}
|
||||
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 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<dyn super::WriteSeek> =
|
||||
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
|
||||
Ok(Box::new(MkvStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::create(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let file = std::fs::File::create(path)?;
|
||||
let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::create(writer, title)?))
|
||||
}
|
||||
|
||||
+15
-115
@@ -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<u8> {
|
||||
std::mem::take(&mut self.remainder)
|
||||
}
|
||||
|
||||
/// Set the remainder bytes. Used to transfer alignment state from
|
||||
/// one demuxer to another without losing sync.
|
||||
pub fn set_remainder(&mut self, data: Vec<u8>) {
|
||||
self.remainder = data;
|
||||
}
|
||||
|
||||
/// Create a new demuxer tracking the given PIDs.
|
||||
///
|
||||
/// 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<Vec<crate::disc::Stream>> {
|
||||
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<Vec<crate::disc::Stream>> {
|
||||
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<Vec<crate::disc::Stream>> {
|
||||
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<Vec<crate::disc::Stream>> {
|
||||
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<Vec<crate::disc::Stream>> {
|
||||
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<Vec<crate::disc::Stream>> {
|
||||
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<Vec<crate::disc::Stream>> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PTS scanning utilities (for duration detection)
|
||||
// ============================================================
|
||||
|
||||
/// Find the first PTS for a given PID in BD-TS data.
|
||||
pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
if pid == target_pid && pusi {
|
||||
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
|
||||
let afc = (ts[3] >> 4) & 0x03;
|
||||
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
|
||||
if payload_start < TS_PACKET_SIZE {
|
||||
let payload = &ts[payload_start..];
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
return parse_timestamp(&payload[9..14]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the last PTS for a given PID in BD-TS data.
|
||||
pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
let mut last_pts = None;
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
if pid == target_pid && pusi {
|
||||
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
|
||||
let afc = (ts[3] >> 4) & 0x03;
|
||||
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
|
||||
if payload_start < TS_PACKET_SIZE {
|
||||
let payload = &ts[payload_start..];
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
last_pts = parse_timestamp(&payload[9..14]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
last_pts
|
||||
}
|
||||
|
||||
/// Scan an m2ts file for duration by reading first and last PTS.
|
||||
/// Returns duration in seconds, or None if PTS cannot be found.
|
||||
/// The reader position is restored after scanning.
|
||||
pub fn scan_duration<R: std::io::Read + std::io::Seek>(r: &mut R, video_pid: u16) -> Option<f64> {
|
||||
use std::io::SeekFrom;
|
||||
|
||||
let start_pos = r.stream_position().ok()?;
|
||||
|
||||
// Read first 1MB for first PTS
|
||||
let mut head_buf = vec![0u8; SCAN_HEAD_SIZE];
|
||||
r.seek(SeekFrom::Start(0)).ok()?;
|
||||
let head_n = r.read(&mut head_buf).ok()?;
|
||||
let first_pts = scan_first_pts(&head_buf[..head_n], video_pid)?;
|
||||
|
||||
// Read last 2MB for last PTS (aligned to 192-byte boundary)
|
||||
let file_size = r.seek(SeekFrom::End(0)).ok()?;
|
||||
let tail_size: u64 = SCAN_TAIL_SIZE as u64;
|
||||
let raw_pos = 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::*;
|
||||
|
||||
@@ -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<R: Read> {
|
||||
reader: R,
|
||||
demuxer: TsDemuxer,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
pending: VecDeque<PesFrame>,
|
||||
buf: Vec<u8>,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl<R: Read> TsDemuxReader<R> {
|
||||
/// Create from a reader and stream metadata.
|
||||
pub fn new(reader: R, streams: &[DiscStream]) -> Self {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
let (pid, c) = match s {
|
||||
DiscStream::Video(v) => (v.pid, v.codec),
|
||||
DiscStream::Audio(a) => (a.pid, a.codec),
|
||||
DiscStream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, codec::parser_for_codec(c, 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<Option<PesFrame>> {
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
loop {
|
||||
let n = self.reader.read(&mut self.buf)?;
|
||||
if n == 0 {
|
||||
self.eof = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let packets = self.demuxer.feed(&self.buf[..n]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending
|
||||
.push_back(PesFrame::from_codec_frame(*track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec private data for a track.
|
||||
pub fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
/// True when all primary video tracks have codec_private.
|
||||
pub fn headers_ready(&self, streams: &[DiscStream]) -> bool {
|
||||
for (idx, s) in streams.iter().enumerate() {
|
||||
if let DiscStream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
+44
-7
@@ -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<Option<PesFrame>>;
|
||||
|
||||
/// 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<Vec<u8>> {
|
||||
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
|
||||
}
|
||||
|
||||
+27
-21
@@ -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<IOObject> {
|
||||
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<IOObject> {
|
||||
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<IOObject> {
|
||||
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.
|
||||
|
||||
+2
-2
@@ -82,8 +82,8 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
|
||||
|
||||
#[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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user