From f8b5a1eaf1484a83bec782bd4cd80f594186eaeb Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:13:41 +0000 Subject: [PATCH] API: Drive object, typed StreamUrl, tray lock/unlock, Send traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename DriveSession → Drive across entire codebase - find_drives() returns Vec, find_drive() returns Option - resolve_device() now pub(crate) — internal only - StreamUrl is now a typed enum (Disc, Mkv, M2ts, Iso, Network, Stdio, Null) with scheme() and path_str() accessors, replacing struct of Strings - Add lock_tray() / unlock_tray() for safe disc access during rips - Improve reset() with eject cycle that clears LibreDrive stuck state - Add Send bounds to ScsiTransport and PlatformDriver traits - DiscOptions uses PathBuf instead of String for device/keydb paths - Update doc example to use new Drive API --- src/aacs/handshake.rs | 16 +-- src/aacs/keys.rs | 2 +- src/disc/encrypt.rs | 2 +- src/disc/mod.rs | 16 +-- src/drive/capture.rs | 6 +- src/drive/mod.rs | 114 ++++++++++++++------- src/lib.rs | 18 ++-- src/mux/disc.rs | 18 ++-- src/mux/resolve.rs | 223 ++++++++++++++++++++++-------------------- src/platform/mod.rs | 2 +- src/scsi/mod.rs | 2 +- src/sector.rs | 2 +- tests/streams.rs | 34 +++---- 13 files changed, 254 insertions(+), 201 deletions(-) diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index f39832e..1f9e676 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -18,7 +18,7 @@ //! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility //! (full P-256/SHA-256 AACS 2.0 handshake prepared but rarely needed) -use crate::drive::DriveSession; +use crate::drive::Drive; use crate::error::{Error, Result}; use crate::scsi::DataDirection; use num_bigint::BigUint; @@ -26,14 +26,14 @@ use num_traits::{One, Zero}; use sha1::{Digest, Sha1}; /// Execute a SCSI command that reads data from the device. -fn scsi_read(session: &mut DriveSession, cdb: &[u8], len: usize) -> Result> { +fn scsi_read(session: &mut Drive, cdb: &[u8], len: usize) -> Result> { let mut buf = vec![0u8; len]; session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?; Ok(buf) } /// Execute a SCSI command that writes data to the device. -fn scsi_write(session: &mut DriveSession, cdb: &[u8], data: &[u8]) -> Result<()> { +fn scsi_write(session: &mut Drive, cdb: &[u8], data: &[u8]) -> Result<()> { let mut buf = data.to_vec(); session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?; Ok(()) @@ -765,7 +765,7 @@ pub struct AacsAuth { /// Requires a host private key (20 bytes) and host certificate (92 bytes) /// from the KEYDB.cfg HC entry. pub fn aacs_authenticate( - session: &mut DriveSession, + session: &mut Drive, host_priv_key: &[u8; 20], host_cert: &[u8], ) -> Result { @@ -890,7 +890,7 @@ pub fn aacs_authenticate( /// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials /// are not available. pub fn aacs2_authenticate( - session: &mut DriveSession, + session: &mut Drive, host_priv_key_v1: &[u8; 20], host_cert_v1: &[u8], host_priv_key_v2: Option<&[u8; 32]>, @@ -914,7 +914,7 @@ pub fn aacs2_authenticate( /// Native AACS 2.0 handshake using P-256/SHA-256. /// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs). fn aacs2_authenticate_p256( - session: &mut DriveSession, + session: &mut Drive, host_priv_key: &[u8; 32], host_cert: &[u8], ) -> Result { @@ -1029,7 +1029,7 @@ fn aacs2_authenticate_p256( } /// Read Volume ID after successful authentication. -pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> { +pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 16]> { // REPORT DISC STRUCTURE format 0x80 let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36); let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsVidRead)?; @@ -1051,7 +1051,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result /// Read data keys after successful authentication (for AACS 2.0 bus encryption). pub fn read_data_keys( - session: &mut DriveSession, + session: &mut Drive, auth: &mut AacsAuth, ) -> Result<([u8; 16], [u8; 16])> { // REPORT DISC STRUCTURE format 0x84 diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index 2c6e699..8c6dc96 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -449,7 +449,7 @@ const MKB_PACK_SIZE: usize = 32772; /// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). /// Returns the concatenated MKB data from all packs. pub fn read_mkb_from_drive( - session: &mut crate::drive::DriveSession, + session: &mut crate::drive::Drive, ) -> crate::error::Result> { use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index b46e217..c5adb31 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -17,7 +17,7 @@ impl Disc { /// SCSI handshake result — volume ID and bus keys from ECDH authentication. /// Only available when scanning from a real drive (not ISO images). pub(super) fn do_handshake( - session: &mut crate::drive::DriveSession, + session: &mut crate::drive::Drive, opts: &ScanOptions, ) -> Option { use crate::aacs::{self, KeyDb}; diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 040f2b7..ba58b91 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -12,7 +12,7 @@ mod bluray; mod dvd; mod encrypt; -use crate::drive::DriveSession; +use crate::drive::Drive; use crate::error::{Error, Result}; use crate::sector::SectorReader; use crate::speed::DriveSpeed; @@ -449,7 +449,7 @@ impl ScanOptions { /// Created by `Disc::open()`. Provides `rip()` to read title data. pub struct OpenDisc { pub disc: Disc, - pub session: DriveSession, + pub session: Drive, } impl OpenDisc { @@ -459,7 +459,7 @@ impl OpenDisc { pub fn open(device: &str, keydb_path: Option<&str>) -> Result { use std::path::Path; - let mut session = DriveSession::open(Path::new(device))?; + let mut session = Drive::open(Path::new(device))?; session.wait_ready()?; // Init (unlock + firmware) -- non-fatal if fails @@ -530,9 +530,9 @@ impl Disc { /// 4. Parse playlists + streams /// 5. Apply labels /// - /// The session must be open and unlocked (DriveSession::open handles this). + /// The session must be open and unlocked (Drive::open handles this). /// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands. - pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result { + pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result { // READ CAPACITY may fail in LibreDrive mode — proceed with 0 and estimate later let capacity = Self::read_capacity(session).unwrap_or(0); let handshake = Self::do_handshake(session, opts); @@ -648,7 +648,7 @@ impl Disc { DiscFormat::Unknown } - fn read_capacity(session: &mut DriveSession) -> Result { + fn read_capacity(session: &mut Drive) -> Result { let cdb = [ crate::scsi::SCSI_READ_CAPACITY, 0x00, @@ -684,7 +684,7 @@ impl Disc { /// - On success streak: ramps batch back up, then restores disc speed /// - At minimum batch + still failing: retries once, then skips + zero-fills pub struct ContentReader<'a> { - session: &'a mut DriveSession, + session: &'a mut Drive, aacs: Option<&'a AacsState>, css: Option<&'a crate::css::CssState>, extents: Vec, @@ -715,7 +715,7 @@ impl Disc { /// pub fn open_title<'a>( &'a self, - session: &'a mut DriveSession, + session: &'a mut Drive, title_idx: usize, ) -> Result> { let title = self.titles.get(title_idx).ok_or(Error::DiscTitleRange { diff --git a/src/drive/capture.rs b/src/drive/capture.rs index 6785c74..766880e 100644 --- a/src/drive/capture.rs +++ b/src/drive/capture.rs @@ -1,6 +1,6 @@ //! Drive data capture — read hardware information via SCSI. -use crate::drive::DriveSession; +use crate::drive::Drive; use crate::error::Result; /// Raw data captured from a drive's SCSI responses. @@ -51,14 +51,14 @@ const FEATURES: &[(u16, &str)] = &[ /// Capture all available drive data via SCSI commands. /// Returns raw responses — no formatting, no zipping, no presentation. -pub fn capture_drive_data(session: &mut DriveSession) -> Result { +pub fn capture_drive_data(session: &mut Drive) -> Result { let id = &session.drive_id; // Already have INQUIRY from drive open let inquiry = id.raw_inquiry.clone(); let gc_010c = id.raw_gc_010c.clone(); - // Capture GET_CONFIG features using DriveSession's query methods + // Capture GET_CONFIG features using Drive's query methods let mut features = Vec::new(); for &(code, name) in FEATURES { if let Some(data) = session.get_config_feature(code) { diff --git a/src/drive/mod.rs b/src/drive/mod.rs index f922005..fd59496 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -40,7 +40,7 @@ pub enum DriveStatus { } /// Optical disc drive session -- open, identify, unlock, and read. -pub struct DriveSession { +pub struct Drive { scsi: Box, driver: Option>, pub profile: Option, @@ -49,7 +49,7 @@ pub struct DriveSession { device_path: String, } -impl DriveSession { +impl Drive { pub fn open(device: &Path) -> Result { let mut transport = crate::scsi::open(device)?; let profiles = profile::load_bundled()?; @@ -65,7 +65,7 @@ impl DriveSession { None => (None, None, None), }; - Ok(DriveSession { + Ok(Drive { scsi: transport, driver, platform, @@ -159,47 +159,66 @@ impl DriveSession { } /// Attempt to reset the drive to a clean state. - /// Tries multiple approaches in order: - /// 1. PREVENT ALLOW MEDIUM REMOVAL (allow) — clears command locks - /// 2. START STOP UNIT (start) — restarts the disc - /// 3. If the drive has a profile, re-init (firmware re-upload + unlock) + /// + /// Escalates through increasingly aggressive recovery: + /// 1. Unlock tray + stop/start — handles normal stuck states + /// 2. Eject cycle — clears LibreDrive firmware stuck state (proven on BU40N) + /// 3. Re-init — firmware re-upload if profile available + /// + /// Note: step 2 physically ejects the tray. On slimline drives the user + /// must push it back in manually. Returns Ok(()) if TUR succeeds after + /// any step, even if the drive reports "tray open" (that's a valid state). pub fn reset(&mut self) -> Result<()> { let mut buf = [0u8; 0]; + let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00]; - // 1. Allow medium removal (clears any prevent lock) - let allow = [0x1Eu8, 0x00, 0x00, 0x00, 0x00, 0x00]; - let _ = self.scsi.as_mut().execute( - &allow, crate::scsi::DataDirection::None, &mut buf, 5_000, - ); - - // 2. START STOP UNIT: stop then start (forces disc re-spin) - let stop = [0x1Bu8, 0x00, 0x00, 0x00, 0x00, 0x00]; // stop + // 1. Unlock + stop/start + self.unlock_tray(); + let stop = [0x1Bu8, 0x00, 0x00, 0x00, 0x00, 0x00]; let _ = self.scsi.as_mut().execute( &stop, crate::scsi::DataDirection::None, &mut buf, 5_000, ); std::thread::sleep(std::time::Duration::from_millis(500)); - let start = [0x1Bu8, 0x00, 0x00, 0x00, 0x01, 0x00]; // start + let start = [0x1Bu8, 0x00, 0x00, 0x00, 0x01, 0x00]; let _ = self.scsi.as_mut().execute( &start, crate::scsi::DataDirection::None, &mut buf, 5_000, ); std::thread::sleep(std::time::Duration::from_millis(2000)); - // 3. Check if TUR works now - let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00]; if self.scsi.as_mut().execute( &tur, crate::scsi::DataDirection::None, &mut buf, 5_000, ).is_ok() { return Ok(()); } - // 4. If still stuck and we have a profile, try re-init + // 2. Eject cycle — clears MT1959 LibreDrive stuck state. + // After eject, TUR returning "Not Ready — tray open" (sense key 2) + // counts as success: the drive is functional, just needs disc reinserted. + self.unlock_tray(); + let eject = [0x1Bu8, 0x00, 0x00, 0x00, 0x02, 0x00]; + let _ = self.scsi.as_mut().execute( + &eject, crate::scsi::DataDirection::None, &mut buf, 30_000, + ); + std::thread::sleep(std::time::Duration::from_millis(2000)); + + match self.scsi.as_mut().execute( + &tur, crate::scsi::DataDirection::None, &mut buf, 5_000, + ) { + Ok(_) => return Ok(()), + Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), // tray open = valid + _ => {} + } + + // 3. If still stuck and we have a profile, try re-init if self.driver.is_some() { self.init()?; std::thread::sleep(std::time::Duration::from_millis(1000)); - if self.scsi.as_mut().execute( + match self.scsi.as_mut().execute( &tur, crate::scsi::DataDirection::None, &mut buf, 5_000, - ).is_ok() { - return Ok(()); + ) { + Ok(_) => return Ok(()), + Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), + _ => {} } } @@ -346,16 +365,29 @@ impl DriveSession { let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000); } - pub fn eject(&mut self) -> Result<()> { - let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0]; + /// Lock the tray so the disc cannot be ejected during a rip. + pub fn lock_tray(&mut self) { + let prevent = [0x1Eu8, 0x00, 0x00, 0x00, 0x01, 0x00]; let mut buf = [0u8; 0]; let _ = self.scsi.as_mut().execute( - &allow_cdb, - crate::scsi::DataDirection::None, - &mut buf, - 5_000, + &prevent, crate::scsi::DataDirection::None, &mut buf, 5_000, ); + } + + /// Unlock the tray so the user can manually eject the disc. + pub fn unlock_tray(&mut self) { + let allow = [0x1Eu8, 0x00, 0x00, 0x00, 0x00, 0x00]; + let mut buf = [0u8; 0]; + let _ = self.scsi.as_mut().execute( + &allow, crate::scsi::DataDirection::None, &mut buf, 5_000, + ); + } + + /// Eject the disc tray. Unlocks first, then ejects. + pub fn eject(&mut self) -> Result<()> { + self.unlock_tray(); let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0]; + let mut buf = [0u8; 0]; self.scsi.as_mut().execute( &eject_cdb, crate::scsi::DataDirection::None, @@ -376,14 +408,29 @@ impl DriveSession { } } -impl SectorReader for DriveSession { +impl SectorReader for Drive { fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result { self.read_disc(lba, count, buf) } } /// Find all optical drives connected to this system. -pub fn find_drives() -> Vec<(String, DriveId)> { +/// Returns opened Drive objects ready for use. +pub fn find_drives() -> Vec { + discover_drives() + .into_iter() + .filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok()) + .collect() +} + +/// Find the first optical drive. +/// Returns an opened Drive ready for use. +pub fn find_drive() -> Option { + find_drives().into_iter().next() +} + +/// Internal: discover drive paths + IDs without opening full Drive objects. +fn discover_drives() -> Vec<(String, DriveId)> { #[cfg(target_os = "linux")] { linux::find_drives() @@ -398,13 +445,8 @@ pub fn find_drives() -> Vec<(String, DriveId)> { } } -/// Find the first optical drive, returning its device path. -pub fn find_drive() -> Option { - find_drives().into_iter().next().map(|(path, _)| path) -} - /// Resolve a device path to its raw SCSI device, with optional warning message. -pub fn resolve_device(path: &str) -> Result<(String, Option)> { +pub(crate) fn resolve_device(path: &str) -> Result<(String, Option)> { #[cfg(target_os = "linux")] { linux::resolve_device(path) diff --git a/src/lib.rs b/src/lib.rs index 2d33099..b3d3c96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,19 +6,17 @@ //! # Quick Start //! //! ```no_run -//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive}; -//! use std::path::Path; +//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive}; //! -//! let device = find_drive().expect("no optical drive found"); -//! let mut session = DriveSession::open(Path::new(&device)).unwrap(); -//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap(); +//! let mut drive = find_drive().expect("no optical drive found"); +//! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap(); //! //! for title in &disc.titles { //! println!("{} -- {} streams", title.duration_display(), title.streams.len()); //! } //! //! // Read content (decrypted automatically if AACS keys available) -//! let mut reader = disc.open_title(&mut session, 0).unwrap(); +//! let mut reader = disc.open_title(&mut drive, 0).unwrap(); //! while let Some(unit) = reader.read_unit().unwrap() { //! // 6144 bytes of decrypted content per unit //! } @@ -27,7 +25,7 @@ //! # Architecture //! //! ```text -//! DriveSession -- open, identify, unlock, read sectors +//! Drive -- open, identify, unlock, read sectors //! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS) //! ├── DriveProfile -- per-drive unlock parameters (206 bundled) //! ├── DriveId -- INQUIRY + GET_CONFIG identification @@ -87,13 +85,13 @@ pub(crate) mod sector; pub(crate) mod speed; pub(crate) mod udf; -pub use drive::{find_drive, find_drives, resolve_device, DriveSession, DriveStatus}; +pub use drive::{find_drive, find_drives, Drive, DriveStatus}; pub use drive::capture::{DriveCapture, CapturedFeature, capture_drive_data, mask_string, mask_bytes}; pub use error::{Error, Result}; pub use event::{Event, EventKind}; pub use identity::DriveId; pub use profile::DriveProfile; -// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly +// Platform trait is pub(crate) -- callers use Drive, not Platform directly pub use disc::{ AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc, DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream, @@ -108,7 +106,7 @@ pub use mux::MkvStream; pub use mux::NetworkStream; pub use mux::NullStream; pub use mux::StdioStream; -pub use mux::{open_input, open_output, parse_url, InputOptions}; +pub use mux::{open_input, open_output, parse_url, InputOptions, StreamUrl}; pub use scsi::ScsiTransport; pub use sector::SectorReader; pub use speed::DriveSpeed; diff --git a/src/mux/disc.rs b/src/mux/disc.rs index de02d97..9f27d94 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -1,6 +1,6 @@ //! DiscStream — read BD-TS data from an optical disc drive. //! -//! Read-only stream. Wraps DriveSession + Disc. +//! Read-only stream. Wraps Drive + Disc. //! Handles drive init, AACS decryption, and sector reading. //! //! Reading state (extent index, offset, batch size, error recovery) is stored @@ -12,7 +12,7 @@ use crate::disc::{ detect_max_batch_sectors, ContentFormat, Disc, DiscTitle, Extent, MIN_BATCH_SECTORS, RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER, }; -use crate::drive::DriveSession; +use crate::drive::Drive; use crate::error::Error; use crate::speed::DriveSpeed; use std::io::{self, Read, Write}; @@ -29,9 +29,9 @@ struct AacsDecrypt { #[derive(Default)] pub struct DiscOptions { /// Device path (e.g. "/dev/sg4"). None = auto-detect. - pub device: Option, + pub device: Option, /// KEYDB.cfg path. None = search standard locations. - pub keydb_path: Option, + pub keydb_path: Option, /// Which title to read (0-based). None = longest title. pub title_index: Option, } @@ -43,7 +43,7 @@ pub struct DiscOptions { pub struct DiscStream { disc_title: DiscTitle, disc: Disc, - session: DriveSession, + session: Drive, // Read buffer: holds one decoded batch batch_buf: Vec, batch_pos: usize, @@ -74,20 +74,18 @@ pub struct DiscStream { impl DiscStream { /// Open the disc drive and scan disc metadata. pub fn open(opts: DiscOptions) -> Result { - let device = match opts.device { - Some(ref d) => crate::drive::resolve_device(d)?.0, + let mut session = match opts.device { + Some(ref d) => Drive::open(d)?, None => crate::drive::find_drive().ok_or_else(|| Error::DeviceNotFound { path: String::new(), })?, }; - - let mut session = DriveSession::open(Path::new(&device))?; session.wait_ready()?; let _ = session.init(); let _ = session.probe_disc(); let scan_opts = match opts.keydb_path { - Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp), + Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp.clone()), None => crate::disc::ScanOptions::default(), }; let disc = Disc::scan(&mut session, &scan_opts)?; diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index d50c352..68d9627 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -22,7 +22,7 @@ use super::stdio::StdioStream; use super::{IOStream, M2tsStream, MkvStream}; use crate::disc::DiscTitle; use std::io::{self, BufReader, BufWriter}; -use std::path::Path; +use std::path::{Path, PathBuf}; /// I/O buffer size for file streams. const IO_BUF_SIZE: usize = 4 * 1024 * 1024; @@ -33,90 +33,113 @@ const MKV_LOOKAHEAD_DEFAULT: usize = 10 * 1024 * 1024; const MKV_LOOKAHEAD_UHD: usize = 100 * 1024 * 1024; /// Parsed stream URL. -pub struct StreamUrl { - pub scheme: String, - pub path: String, +pub enum StreamUrl { + /// Optical disc drive. Device path is optional (auto-detect if None). + Disc { device: Option }, + /// MPEG-2 transport stream file. + M2ts { path: PathBuf }, + /// Matroska container file. + Mkv { path: PathBuf }, + /// Network stream (host:port). + Network { addr: String }, + /// Standard I/O (stdin/stdout). + Stdio, + /// ISO disc image file. + Iso { path: PathBuf }, + /// Null sink (write-only, discards data). + Null, + /// Unrecognized URL. + Unknown { raw: String }, } -/// Parse a URL string into scheme + path. +impl StreamUrl { + /// The scheme name (e.g. "disc", "mkv", "null"). + pub fn scheme(&self) -> &str { + match self { + StreamUrl::Disc { .. } => "disc", + StreamUrl::M2ts { .. } => "m2ts", + StreamUrl::Mkv { .. } => "mkv", + StreamUrl::Network { .. } => "network", + StreamUrl::Stdio => "stdio", + StreamUrl::Iso { .. } => "iso", + StreamUrl::Null => "null", + StreamUrl::Unknown { .. } => "unknown", + } + } + + /// The path/address component, or empty string for scheme-only URLs. + pub fn path_str(&self) -> &str { + match self { + StreamUrl::Disc { device: Some(p) } => p.to_str().unwrap_or(""), + StreamUrl::Disc { device: None } => "", + StreamUrl::M2ts { path } | StreamUrl::Mkv { path } | StreamUrl::Iso { path } => { + path.to_str().unwrap_or("") + } + StreamUrl::Network { addr } => addr, + StreamUrl::Stdio | StreamUrl::Null => "", + StreamUrl::Unknown { raw } => raw, + } + } + + /// Whether this URL represents a disc source (disc:// or iso://). + pub fn is_disc_source(&self) -> bool { + matches!(self, StreamUrl::Disc { .. } | StreamUrl::Iso { .. }) + } +} + +/// Parse a URL string into a typed StreamUrl. /// /// All URLs must use the `scheme://path` format. Bare paths are not supported. /// /// ```text -/// disc:// → scheme="disc", path="" -/// disc:///dev/sg4 → scheme="disc", path="/dev/sg4" -/// m2ts:///tmp/Dune.m2ts → scheme="m2ts", path="/tmp/Dune.m2ts" -/// mkv://Dune.mkv → scheme="mkv", path="Dune.mkv" -/// network://10.0.0.1:9000 → scheme="network", path="10.0.0.1:9000" -/// null:// → scheme="null", path="" +/// disc:// → Disc { device: None } +/// disc:///dev/sg4 → Disc { device: Some("/dev/sg4") } +/// m2ts:///tmp/Dune.m2ts → M2ts { path: "/tmp/Dune.m2ts" } +/// mkv://Dune.mkv → Mkv { path: "Dune.mkv" } +/// network://10.0.0.1:9000 → Network { addr: "10.0.0.1:9000" } +/// null:// → Null /// ``` pub fn parse_url(url: &str) -> StreamUrl { if let Some(rest) = url.strip_prefix("disc://") { - return StreamUrl { - scheme: "disc".into(), - path: rest.to_string(), + return if rest.is_empty() { + StreamUrl::Disc { device: None } + } else { + StreamUrl::Disc { device: Some(PathBuf::from(rest)) } }; } if let Some(rest) = url.strip_prefix("m2ts://") { - return StreamUrl { - scheme: "m2ts".into(), - path: rest.to_string(), - }; + return StreamUrl::M2ts { path: PathBuf::from(rest) }; } if let Some(rest) = url.strip_prefix("mkv://") { - return StreamUrl { - scheme: "mkv".into(), - path: rest.to_string(), - }; + return StreamUrl::Mkv { path: PathBuf::from(rest) }; } if let Some(rest) = url.strip_prefix("network://") { - return StreamUrl { - scheme: "network".into(), - path: rest.to_string(), - }; + return StreamUrl::Network { addr: rest.to_string() }; } if url == "null://" || url.starts_with("null://") { - return StreamUrl { - scheme: "null".into(), - path: String::new(), - }; + return StreamUrl::Null; } if url == "stdio://" || url.starts_with("stdio://") { - return StreamUrl { - scheme: "stdio".into(), - path: String::new(), - }; + return StreamUrl::Stdio; } if let Some(rest) = url.strip_prefix("iso://") { - return StreamUrl { - scheme: "iso".into(), - path: rest.to_string(), - }; - } - - StreamUrl { - scheme: "unknown".into(), - path: url.to_string(), + return StreamUrl::Iso { path: PathBuf::from(rest) }; } + StreamUrl::Unknown { raw: url.to_string() } } /// Validate that a file path is non-empty and has a filename component. -fn validate_file_path(path: &str, scheme: &str) -> io::Result<()> { - if path.is_empty() { +fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> { + if path.as_os_str().is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!( - "{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})" - ), + format!("{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"), )); } - let p = Path::new(path); - if p.file_name().is_none() { + if path.file_name().is_none() { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!( - "{scheme}://{path} is not a valid file path — must include a filename" - ), + format!("{scheme}://{} is not a valid file path — must include a filename", path.display()), )); } Ok(()) @@ -133,9 +156,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> { if !addr.contains(':') { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!( - "network://{addr} missing port — use network://{addr}:PORT" - ), + format!("network://{addr} missing port — use network://{addr}:PORT"), )); } Ok(()) @@ -145,58 +166,56 @@ fn validate_network_addr(addr: &str) -> io::Result<()> { pub fn open_input(url: &str, opts: &InputOptions) -> io::Result> { let parsed = parse_url(url); - match parsed.scheme.as_str() { - "disc" => { + match parsed { + StreamUrl::Disc { device } => { let disc_opts = DiscOptions { - device: if parsed.path.is_empty() { None } else { Some(parsed.path) }, - keydb_path: opts.keydb_path.clone(), + device, + keydb_path: opts.keydb_path.as_ref().map(|p| p.into()), title_index: opts.title_index, }; let stream = DiscStream::open(disc_opts) .map_err(|e| io::Error::other(e.to_string()))?; Ok(Box::new(stream)) } - "m2ts" => { - validate_file_path(&parsed.path, "m2ts")?; - let file = std::fs::File::open(&parsed.path) + 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://{}: {}", parsed.path, e)))?; + format!("m2ts://{}: {}", path.display(), e)))?; let reader = BufReader::with_capacity(IO_BUF_SIZE, file); Ok(Box::new(M2tsStream::open(reader)?)) } - "mkv" => { - validate_file_path(&parsed.path, "mkv")?; - let file = std::fs::File::open(&parsed.path) + 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://{}: {}", parsed.path, e)))?; + format!("mkv://{}: {}", path.display(), e)))?; let reader = BufReader::with_capacity(IO_BUF_SIZE, file); Ok(Box::new(MkvStream::open(reader)?)) } - "network" => { - validate_network_addr(&parsed.path)?; - Ok(Box::new(NetworkStream::listen(&parsed.path)?)) + StreamUrl::Network { ref addr } => { + validate_network_addr(addr)?; + Ok(Box::new(NetworkStream::listen(addr)?)) } - "stdio" => { + StreamUrl::Stdio => { Ok(Box::new(StdioStream::input())) } - "iso" => { - validate_file_path(&parsed.path, "iso")?; + StreamUrl::Iso { ref path } => { + validate_file_path(path, "iso")?; let scan_opts = match &opts.keydb_path { Some(p) => crate::disc::ScanOptions::with_keydb(p), None => crate::disc::ScanOptions::default(), }; - Ok(Box::new(IsoStream::open(&parsed.path, opts.title_index, &scan_opts)?)) + Ok(Box::new(IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?)) } - "null" => { + StreamUrl::Null => { Err(io::Error::new(io::ErrorKind::InvalidInput, "null:// is write-only — cannot use as input")) } - "unknown" => { + StreamUrl::Unknown { ref raw } => { Err(io::Error::new(io::ErrorKind::InvalidInput, - format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", parsed.path))) + format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw))) } - _ => Err(io::Error::new(io::ErrorKind::InvalidInput, - format!("unknown scheme: {}://", parsed.scheme))), } } @@ -204,37 +223,35 @@ pub fn open_input(url: &str, opts: &InputOptions) -> io::Result io::Result> { let parsed = parse_url(url); - match parsed.scheme.as_str() { - "disc" => { + match parsed { + StreamUrl::Disc { .. } => { Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only — cannot use as output")) } - "iso" => { - validate_file_path(&parsed.path, "iso")?; - Ok(Box::new(IsoStream::create(&parsed.path)?.meta(meta))) + StreamUrl::Iso { ref path } => { + validate_file_path(path, "iso")?; + Ok(Box::new(IsoStream::create(&path.to_string_lossy())?.meta(meta))) } - "null" => { + StreamUrl::Null => { Ok(Box::new(NullStream::new().meta(meta))) } - "stdio" => { + StreamUrl::Stdio => { Ok(Box::new(StdioStream::output().meta(meta))) } - "m2ts" => { - validate_file_path(&parsed.path, "m2ts")?; - let file = std::fs::File::create(&parsed.path) + 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://{}: {}", parsed.path, e)))?; + format!("m2ts://{}: {}", path.display(), e)))?; let writer = BufWriter::with_capacity(IO_BUF_SIZE, file); Ok(Box::new(M2tsStream::new(writer).meta(meta))) } - "mkv" => { - validate_file_path(&parsed.path, "mkv")?; - let file = std::fs::File::create(&parsed.path) + 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://{}: {}", parsed.path, e)))?; + format!("mkv://{}: {}", path.display(), e)))?; let writer = BufWriter::with_capacity(IO_BUF_SIZE, file); - // Size lookahead based on content: UHD (many streams) needs larger buffer - // because HEVC SPS/PPS may not appear until well past 10 MB let lookahead = if meta.streams.len() > 15 { MKV_LOOKAHEAD_UHD } else { @@ -242,16 +259,14 @@ pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result> }; Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead))) } - "network" => { - validate_network_addr(&parsed.path)?; - Ok(Box::new(NetworkStream::connect(&parsed.path)?.meta(meta))) + StreamUrl::Network { ref addr } => { + validate_network_addr(addr)?; + Ok(Box::new(NetworkStream::connect(addr)?.meta(meta))) } - "unknown" => { + StreamUrl::Unknown { ref raw } => { Err(io::Error::new(io::ErrorKind::InvalidInput, - format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", parsed.path))) + format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", raw))) } - _ => Err(io::Error::new(io::ErrorKind::InvalidInput, - format!("unknown scheme: {}://", parsed.scheme))), } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index e403742..b8582f6 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -5,7 +5,7 @@ pub mod mt1959; use crate::error::Result; use crate::scsi::ScsiTransport; -pub(crate) trait PlatformDriver { +pub(crate) trait PlatformDriver: Send { /// Unlock drive + upload firmware if needed. fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>; diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index c58ab68..b3580d4 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -50,7 +50,7 @@ pub struct ScsiResult { } /// Low-level SCSI transport — one implementation per platform. -pub trait ScsiTransport { +pub trait ScsiTransport: Send { fn execute( &mut self, cdb: &[u8], diff --git a/src/sector.rs b/src/sector.rs index 3d14f1e..8c7ad42 100644 --- a/src/sector.rs +++ b/src/sector.rs @@ -1,6 +1,6 @@ //! SectorReader — trait for reading 2048-byte disc sectors. //! -//! Implemented by DriveSession (SCSI) and IsoFile (file-backed). +//! Implemented by Drive (SCSI) and IsoFile (file-backed). //! Used by UDF parser, disc scanner, label parsers — anything that //! reads sectors doesn't need to know where they come from. diff --git a/tests/streams.rs b/tests/streams.rs index 2819f84..5a615fe 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -59,56 +59,56 @@ fn sample_disc_title() -> DiscTitle { #[test] fn parse_url_disc() { let u = parse_url("disc://"); - assert_eq!(u.scheme, "disc"); - assert_eq!(u.path, ""); + assert_eq!(u.scheme(), "disc"); + assert_eq!(u.path_str(), ""); } #[test] fn parse_url_disc_device() { let u = parse_url("disc:///dev/sg4"); - assert_eq!(u.scheme, "disc"); - assert_eq!(u.path, "/dev/sg4"); + assert_eq!(u.scheme(), "disc"); + assert_eq!(u.path_str(), "/dev/sg4"); } #[test] fn parse_url_mkv() { let u = parse_url("mkv://Dune.mkv"); - assert_eq!(u.scheme, "mkv"); - assert_eq!(u.path, "Dune.mkv"); + assert_eq!(u.scheme(), "mkv"); + assert_eq!(u.path_str(), "Dune.mkv"); } #[test] fn parse_url_network() { let u = parse_url("network://10.0.0.1:9000"); - assert_eq!(u.scheme, "network"); - assert_eq!(u.path, "10.0.0.1:9000"); + assert_eq!(u.scheme(), "network"); + assert_eq!(u.path_str(), "10.0.0.1:9000"); } #[test] fn parse_url_bare_path_rejected() { let u = parse_url("Dune.mkv"); - assert_eq!(u.scheme, "unknown"); + assert_eq!(u.scheme(), "unknown"); } #[test] fn parse_url_null() { let u = parse_url("null://"); - assert_eq!(u.scheme, "null"); - assert_eq!(u.path, ""); + assert_eq!(u.scheme(), "null"); + assert_eq!(u.path_str(), ""); } #[test] fn parse_url_m2ts_with_path() { let u = parse_url("m2ts:///tmp/Dune.m2ts"); - assert_eq!(u.scheme, "m2ts"); - assert_eq!(u.path, "/tmp/Dune.m2ts"); + assert_eq!(u.scheme(), "m2ts"); + assert_eq!(u.path_str(), "/tmp/Dune.m2ts"); } #[test] fn parse_url_m2ts_relative() { let u = parse_url("m2ts://Dune.m2ts"); - assert_eq!(u.scheme, "m2ts"); - assert_eq!(u.path, "Dune.m2ts"); + assert_eq!(u.scheme(), "m2ts"); + assert_eq!(u.path_str(), "Dune.m2ts"); } #[test] @@ -182,8 +182,8 @@ fn open_input_network_no_port_errors() { #[test] fn parse_url_stdio() { let u = parse_url("stdio://"); - assert_eq!(u.scheme, "stdio"); - assert_eq!(u.path, ""); + assert_eq!(u.scheme(), "stdio"); + assert_eq!(u.path_str(), ""); } // ── M2TS metadata roundtrip ───────────────────────────────────