API: Drive object, typed StreamUrl, tray lock/unlock, Send traits

- Rename DriveSession → Drive across entire codebase
- find_drives() returns Vec<Drive>, find_drive() returns Option<Drive>
- 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
This commit is contained in:
MattJackson
2026-04-13 00:13:41 +00:00
parent fb1c35e653
commit f8b5a1eaf1
13 changed files with 254 additions and 201 deletions
+8 -8
View File
@@ -18,7 +18,7 @@
//! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility //! - 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) //! (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::error::{Error, Result};
use crate::scsi::DataDirection; use crate::scsi::DataDirection;
use num_bigint::BigUint; use num_bigint::BigUint;
@@ -26,14 +26,14 @@ use num_traits::{One, Zero};
use sha1::{Digest, Sha1}; use sha1::{Digest, Sha1};
/// Execute a SCSI command that reads data from the device. /// Execute a SCSI command that reads data from the device.
fn scsi_read(session: &mut DriveSession, cdb: &[u8], len: usize) -> Result<Vec<u8>> { fn scsi_read(session: &mut Drive, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
let mut buf = vec![0u8; len]; let mut buf = vec![0u8; len];
session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?; session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(buf) Ok(buf)
} }
/// Execute a SCSI command that writes data to the device. /// 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(); let mut buf = data.to_vec();
session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?; session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?;
Ok(()) Ok(())
@@ -765,7 +765,7 @@ pub struct AacsAuth {
/// Requires a host private key (20 bytes) and host certificate (92 bytes) /// Requires a host private key (20 bytes) and host certificate (92 bytes)
/// from the KEYDB.cfg HC entry. /// from the KEYDB.cfg HC entry.
pub fn aacs_authenticate( pub fn aacs_authenticate(
session: &mut DriveSession, session: &mut Drive,
host_priv_key: &[u8; 20], host_priv_key: &[u8; 20],
host_cert: &[u8], host_cert: &[u8],
) -> Result<AacsAuth> { ) -> Result<AacsAuth> {
@@ -890,7 +890,7 @@ pub fn aacs_authenticate(
/// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials /// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials
/// are not available. /// are not available.
pub fn aacs2_authenticate( pub fn aacs2_authenticate(
session: &mut DriveSession, session: &mut Drive,
host_priv_key_v1: &[u8; 20], host_priv_key_v1: &[u8; 20],
host_cert_v1: &[u8], host_cert_v1: &[u8],
host_priv_key_v2: Option<&[u8; 32]>, 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. /// Native AACS 2.0 handshake using P-256/SHA-256.
/// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs). /// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs).
fn aacs2_authenticate_p256( fn aacs2_authenticate_p256(
session: &mut DriveSession, session: &mut Drive,
host_priv_key: &[u8; 32], host_priv_key: &[u8; 32],
host_cert: &[u8], host_cert: &[u8],
) -> Result<AacsAuth> { ) -> Result<AacsAuth> {
@@ -1029,7 +1029,7 @@ fn aacs2_authenticate_p256(
} }
/// Read Volume ID after successful authentication. /// 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 // REPORT DISC STRUCTURE format 0x80
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36); let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsVidRead)?; 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). /// Read data keys after successful authentication (for AACS 2.0 bus encryption).
pub fn read_data_keys( pub fn read_data_keys(
session: &mut DriveSession, session: &mut Drive,
auth: &mut AacsAuth, auth: &mut AacsAuth,
) -> Result<([u8; 16], [u8; 16])> { ) -> Result<([u8; 16], [u8; 16])> {
// REPORT DISC STRUCTURE format 0x84 // REPORT DISC STRUCTURE format 0x84
+1 -1
View File
@@ -449,7 +449,7 @@ const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). /// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs. /// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive( pub fn read_mkb_from_drive(
session: &mut crate::drive::DriveSession, session: &mut crate::drive::Drive,
) -> crate::error::Result<Vec<u8>> { ) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
+1 -1
View File
@@ -17,7 +17,7 @@ impl Disc {
/// SCSI handshake result — volume ID and bus keys from ECDH authentication. /// SCSI handshake result — volume ID and bus keys from ECDH authentication.
/// Only available when scanning from a real drive (not ISO images). /// Only available when scanning from a real drive (not ISO images).
pub(super) fn do_handshake( pub(super) fn do_handshake(
session: &mut crate::drive::DriveSession, session: &mut crate::drive::Drive,
opts: &ScanOptions, opts: &ScanOptions,
) -> Option<HandshakeResult> { ) -> Option<HandshakeResult> {
use crate::aacs::{self, KeyDb}; use crate::aacs::{self, KeyDb};
+8 -8
View File
@@ -12,7 +12,7 @@ mod bluray;
mod dvd; mod dvd;
mod encrypt; mod encrypt;
use crate::drive::DriveSession; use crate::drive::Drive;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::sector::SectorReader; use crate::sector::SectorReader;
use crate::speed::DriveSpeed; use crate::speed::DriveSpeed;
@@ -449,7 +449,7 @@ impl ScanOptions {
/// Created by `Disc::open()`. Provides `rip()` to read title data. /// Created by `Disc::open()`. Provides `rip()` to read title data.
pub struct OpenDisc { pub struct OpenDisc {
pub disc: Disc, pub disc: Disc,
pub session: DriveSession, pub session: Drive,
} }
impl OpenDisc { impl OpenDisc {
@@ -459,7 +459,7 @@ impl OpenDisc {
pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> { pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> {
use std::path::Path; use std::path::Path;
let mut session = DriveSession::open(Path::new(device))?; let mut session = Drive::open(Path::new(device))?;
session.wait_ready()?; session.wait_ready()?;
// Init (unlock + firmware) -- non-fatal if fails // Init (unlock + firmware) -- non-fatal if fails
@@ -530,9 +530,9 @@ impl Disc {
/// 4. Parse playlists + streams /// 4. Parse playlists + streams
/// 5. Apply labels /// 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. /// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> { pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
// READ CAPACITY may fail in LibreDrive mode — proceed with 0 and estimate later // READ CAPACITY may fail in LibreDrive mode — proceed with 0 and estimate later
let capacity = Self::read_capacity(session).unwrap_or(0); let capacity = Self::read_capacity(session).unwrap_or(0);
let handshake = Self::do_handshake(session, opts); let handshake = Self::do_handshake(session, opts);
@@ -648,7 +648,7 @@ impl Disc {
DiscFormat::Unknown DiscFormat::Unknown
} }
fn read_capacity(session: &mut DriveSession) -> Result<u32> { fn read_capacity(session: &mut Drive) -> Result<u32> {
let cdb = [ let cdb = [
crate::scsi::SCSI_READ_CAPACITY, crate::scsi::SCSI_READ_CAPACITY,
0x00, 0x00,
@@ -684,7 +684,7 @@ impl Disc {
/// - On success streak: ramps batch back up, then restores disc speed /// - On success streak: ramps batch back up, then restores disc speed
/// - At minimum batch + still failing: retries once, then skips + zero-fills /// - At minimum batch + still failing: retries once, then skips + zero-fills
pub struct ContentReader<'a> { pub struct ContentReader<'a> {
session: &'a mut DriveSession, session: &'a mut Drive,
aacs: Option<&'a AacsState>, aacs: Option<&'a AacsState>,
css: Option<&'a crate::css::CssState>, css: Option<&'a crate::css::CssState>,
extents: Vec<Extent>, extents: Vec<Extent>,
@@ -715,7 +715,7 @@ impl Disc {
/// ///
pub fn open_title<'a>( pub fn open_title<'a>(
&'a self, &'a self,
session: &'a mut DriveSession, session: &'a mut Drive,
title_idx: usize, title_idx: usize,
) -> Result<ContentReader<'a>> { ) -> Result<ContentReader<'a>> {
let title = self.titles.get(title_idx).ok_or(Error::DiscTitleRange { let title = self.titles.get(title_idx).ok_or(Error::DiscTitleRange {
+3 -3
View File
@@ -1,6 +1,6 @@
//! Drive data capture — read hardware information via SCSI. //! Drive data capture — read hardware information via SCSI.
use crate::drive::DriveSession; use crate::drive::Drive;
use crate::error::Result; use crate::error::Result;
/// Raw data captured from a drive's SCSI responses. /// 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. /// Capture all available drive data via SCSI commands.
/// Returns raw responses — no formatting, no zipping, no presentation. /// Returns raw responses — no formatting, no zipping, no presentation.
pub fn capture_drive_data(session: &mut DriveSession) -> Result<DriveCapture> { pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
let id = &session.drive_id; let id = &session.drive_id;
// Already have INQUIRY from drive open // Already have INQUIRY from drive open
let inquiry = id.raw_inquiry.clone(); let inquiry = id.raw_inquiry.clone();
let gc_010c = id.raw_gc_010c.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(); let mut features = Vec::new();
for &(code, name) in FEATURES { for &(code, name) in FEATURES {
if let Some(data) = session.get_config_feature(code) { if let Some(data) = session.get_config_feature(code) {
+78 -36
View File
@@ -40,7 +40,7 @@ pub enum DriveStatus {
} }
/// Optical disc drive session -- open, identify, unlock, and read. /// Optical disc drive session -- open, identify, unlock, and read.
pub struct DriveSession { pub struct Drive {
scsi: Box<dyn ScsiTransport>, scsi: Box<dyn ScsiTransport>,
driver: Option<Box<dyn PlatformDriver>>, driver: Option<Box<dyn PlatformDriver>>,
pub profile: Option<DriveProfile>, pub profile: Option<DriveProfile>,
@@ -49,7 +49,7 @@ pub struct DriveSession {
device_path: String, device_path: String,
} }
impl DriveSession { impl Drive {
pub fn open(device: &Path) -> Result<Self> { pub fn open(device: &Path) -> Result<Self> {
let mut transport = crate::scsi::open(device)?; let mut transport = crate::scsi::open(device)?;
let profiles = profile::load_bundled()?; let profiles = profile::load_bundled()?;
@@ -65,7 +65,7 @@ impl DriveSession {
None => (None, None, None), None => (None, None, None),
}; };
Ok(DriveSession { Ok(Drive {
scsi: transport, scsi: transport,
driver, driver,
platform, platform,
@@ -159,47 +159,66 @@ impl DriveSession {
} }
/// Attempt to reset the drive to a clean state. /// Attempt to reset the drive to a clean state.
/// Tries multiple approaches in order: ///
/// 1. PREVENT ALLOW MEDIUM REMOVAL (allow) — clears command locks /// Escalates through increasingly aggressive recovery:
/// 2. START STOP UNIT (start)restarts the disc /// 1. Unlock tray + stop/start — handles normal stuck states
/// 3. If the drive has a profile, re-init (firmware re-upload + unlock) /// 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<()> { pub fn reset(&mut self) -> Result<()> {
let mut buf = [0u8; 0]; let mut buf = [0u8; 0];
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
// 1. Allow medium removal (clears any prevent lock) // 1. Unlock + stop/start
let allow = [0x1Eu8, 0x00, 0x00, 0x00, 0x00, 0x00]; self.unlock_tray();
let _ = self.scsi.as_mut().execute( let stop = [0x1Bu8, 0x00, 0x00, 0x00, 0x00, 0x00];
&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
let _ = self.scsi.as_mut().execute( let _ = self.scsi.as_mut().execute(
&stop, crate::scsi::DataDirection::None, &mut buf, 5_000, &stop, crate::scsi::DataDirection::None, &mut buf, 5_000,
); );
std::thread::sleep(std::time::Duration::from_millis(500)); 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( let _ = self.scsi.as_mut().execute(
&start, crate::scsi::DataDirection::None, &mut buf, 5_000, &start, crate::scsi::DataDirection::None, &mut buf, 5_000,
); );
std::thread::sleep(std::time::Duration::from_millis(2000)); 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( if self.scsi.as_mut().execute(
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000, &tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
).is_ok() { ).is_ok() {
return 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() { if self.driver.is_some() {
self.init()?; self.init()?;
std::thread::sleep(std::time::Duration::from_millis(1000)); 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, &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); let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
} }
pub fn eject(&mut self) -> Result<()> { /// Lock the tray so the disc cannot be ejected during a rip.
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0]; pub fn lock_tray(&mut self) {
let prevent = [0x1Eu8, 0x00, 0x00, 0x00, 0x01, 0x00];
let mut buf = [0u8; 0]; let mut buf = [0u8; 0];
let _ = self.scsi.as_mut().execute( let _ = self.scsi.as_mut().execute(
&allow_cdb, &prevent, crate::scsi::DataDirection::None, &mut buf, 5_000,
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 eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
let mut buf = [0u8; 0];
self.scsi.as_mut().execute( self.scsi.as_mut().execute(
&eject_cdb, &eject_cdb,
crate::scsi::DataDirection::None, 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<usize> { fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.read_disc(lba, count, buf) self.read_disc(lba, count, buf)
} }
} }
/// Find all optical drives connected to this system. /// 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<Drive> {
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<Drive> {
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")] #[cfg(target_os = "linux")]
{ {
linux::find_drives() 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<String> {
find_drives().into_iter().next().map(|(path, _)| path)
}
/// Resolve a device path to its raw SCSI device, with optional warning message. /// Resolve a device path to its raw SCSI device, with optional warning message.
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> { pub(crate) fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
linux::resolve_device(path) linux::resolve_device(path)
+8 -10
View File
@@ -6,19 +6,17 @@
//! # Quick Start //! # Quick Start
//! //!
//! ```no_run //! ```no_run
//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive}; //! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
//! use std::path::Path;
//! //!
//! let device = find_drive().expect("no optical drive found"); //! let mut drive = find_drive().expect("no optical drive found");
//! let mut session = DriveSession::open(Path::new(&device)).unwrap(); //! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
//! //!
//! for title in &disc.titles { //! for title in &disc.titles {
//! println!("{} -- {} streams", title.duration_display(), title.streams.len()); //! println!("{} -- {} streams", title.duration_display(), title.streams.len());
//! } //! }
//! //!
//! // Read content (decrypted automatically if AACS keys available) //! // 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() { //! while let Some(unit) = reader.read_unit().unwrap() {
//! // 6144 bytes of decrypted content per unit //! // 6144 bytes of decrypted content per unit
//! } //! }
@@ -27,7 +25,7 @@
//! # Architecture //! # Architecture
//! //!
//! ```text //! ```text
//! DriveSession -- open, identify, unlock, read sectors //! Drive -- open, identify, unlock, read sectors
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS) //! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
//! ├── DriveProfile -- per-drive unlock parameters (206 bundled) //! ├── DriveProfile -- per-drive unlock parameters (206 bundled)
//! ├── DriveId -- INQUIRY + GET_CONFIG identification //! ├── DriveId -- INQUIRY + GET_CONFIG identification
@@ -87,13 +85,13 @@ pub(crate) mod sector;
pub(crate) mod speed; pub(crate) mod speed;
pub(crate) mod udf; 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 drive::capture::{DriveCapture, CapturedFeature, capture_drive_data, mask_string, mask_bytes};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use event::{Event, EventKind}; pub use event::{Event, EventKind};
pub use identity::DriveId; pub use identity::DriveId;
pub use profile::DriveProfile; 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::{ pub use disc::{
AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc, AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc,
DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream, DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream,
@@ -108,7 +106,7 @@ pub use mux::MkvStream;
pub use mux::NetworkStream; pub use mux::NetworkStream;
pub use mux::NullStream; pub use mux::NullStream;
pub use mux::StdioStream; 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 scsi::ScsiTransport;
pub use sector::SectorReader; pub use sector::SectorReader;
pub use speed::DriveSpeed; pub use speed::DriveSpeed;
+8 -10
View File
@@ -1,6 +1,6 @@
//! DiscStream — read BD-TS data from an optical disc drive. //! 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. //! Handles drive init, AACS decryption, and sector reading.
//! //!
//! Reading state (extent index, offset, batch size, error recovery) is stored //! 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, detect_max_batch_sectors, ContentFormat, Disc, DiscTitle, Extent, MIN_BATCH_SECTORS,
RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER, RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER,
}; };
use crate::drive::DriveSession; use crate::drive::Drive;
use crate::error::Error; use crate::error::Error;
use crate::speed::DriveSpeed; use crate::speed::DriveSpeed;
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
@@ -29,9 +29,9 @@ struct AacsDecrypt {
#[derive(Default)] #[derive(Default)]
pub struct DiscOptions { pub struct DiscOptions {
/// Device path (e.g. "/dev/sg4"). None = auto-detect. /// Device path (e.g. "/dev/sg4"). None = auto-detect.
pub device: Option<String>, pub device: Option<std::path::PathBuf>,
/// KEYDB.cfg path. None = search standard locations. /// KEYDB.cfg path. None = search standard locations.
pub keydb_path: Option<String>, pub keydb_path: Option<std::path::PathBuf>,
/// Which title to read (0-based). None = longest title. /// Which title to read (0-based). None = longest title.
pub title_index: Option<usize>, pub title_index: Option<usize>,
} }
@@ -43,7 +43,7 @@ pub struct DiscOptions {
pub struct DiscStream { pub struct DiscStream {
disc_title: DiscTitle, disc_title: DiscTitle,
disc: Disc, disc: Disc,
session: DriveSession, session: Drive,
// Read buffer: holds one decoded batch // Read buffer: holds one decoded batch
batch_buf: Vec<u8>, batch_buf: Vec<u8>,
batch_pos: usize, batch_pos: usize,
@@ -74,20 +74,18 @@ pub struct DiscStream {
impl DiscStream { impl DiscStream {
/// Open the disc drive and scan disc metadata. /// Open the disc drive and scan disc metadata.
pub fn open(opts: DiscOptions) -> Result<Self, Error> { pub fn open(opts: DiscOptions) -> Result<Self, Error> {
let device = match opts.device { let mut session = match opts.device {
Some(ref d) => crate::drive::resolve_device(d)?.0, Some(ref d) => Drive::open(d)?,
None => crate::drive::find_drive().ok_or_else(|| Error::DeviceNotFound { None => crate::drive::find_drive().ok_or_else(|| Error::DeviceNotFound {
path: String::new(), path: String::new(),
})?, })?,
}; };
let mut session = DriveSession::open(Path::new(&device))?;
session.wait_ready()?; session.wait_ready()?;
let _ = session.init(); let _ = session.init();
let _ = session.probe_disc(); let _ = session.probe_disc();
let scan_opts = match opts.keydb_path { 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(), None => crate::disc::ScanOptions::default(),
}; };
let disc = Disc::scan(&mut session, &scan_opts)?; let disc = Disc::scan(&mut session, &scan_opts)?;
+119 -104
View File
@@ -22,7 +22,7 @@ use super::stdio::StdioStream;
use super::{IOStream, M2tsStream, MkvStream}; use super::{IOStream, M2tsStream, MkvStream};
use crate::disc::DiscTitle; use crate::disc::DiscTitle;
use std::io::{self, BufReader, BufWriter}; use std::io::{self, BufReader, BufWriter};
use std::path::Path; use std::path::{Path, PathBuf};
/// I/O buffer size for file streams. /// I/O buffer size for file streams.
const IO_BUF_SIZE: usize = 4 * 1024 * 1024; 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; const MKV_LOOKAHEAD_UHD: usize = 100 * 1024 * 1024;
/// Parsed stream URL. /// Parsed stream URL.
pub struct StreamUrl { pub enum StreamUrl {
pub scheme: String, /// Optical disc drive. Device path is optional (auto-detect if None).
pub path: String, Disc { device: Option<PathBuf> },
/// 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. /// All URLs must use the `scheme://path` format. Bare paths are not supported.
/// ///
/// ```text /// ```text
/// disc:// → scheme="disc", path="" /// disc:// → Disc { device: None }
/// disc:///dev/sg4 → scheme="disc", path="/dev/sg4" /// disc:///dev/sg4 → Disc { device: Some("/dev/sg4") }
/// m2ts:///tmp/Dune.m2ts → scheme="m2ts", path="/tmp/Dune.m2ts" /// m2ts:///tmp/Dune.m2ts → M2ts { path: "/tmp/Dune.m2ts" }
/// mkv://Dune.mkv → scheme="mkv", path="Dune.mkv" /// mkv://Dune.mkv → Mkv { path: "Dune.mkv" }
/// network://10.0.0.1:9000 → scheme="network", path="10.0.0.1:9000" /// network://10.0.0.1:9000 → Network { addr: "10.0.0.1:9000" }
/// null:// → scheme="null", path="" /// null:// → Null
/// ``` /// ```
pub fn parse_url(url: &str) -> StreamUrl { pub fn parse_url(url: &str) -> StreamUrl {
if let Some(rest) = url.strip_prefix("disc://") { if let Some(rest) = url.strip_prefix("disc://") {
return StreamUrl { return if rest.is_empty() {
scheme: "disc".into(), StreamUrl::Disc { device: None }
path: rest.to_string(), } else {
StreamUrl::Disc { device: Some(PathBuf::from(rest)) }
}; };
} }
if let Some(rest) = url.strip_prefix("m2ts://") { if let Some(rest) = url.strip_prefix("m2ts://") {
return StreamUrl { return StreamUrl::M2ts { path: PathBuf::from(rest) };
scheme: "m2ts".into(),
path: rest.to_string(),
};
} }
if let Some(rest) = url.strip_prefix("mkv://") { if let Some(rest) = url.strip_prefix("mkv://") {
return StreamUrl { return StreamUrl::Mkv { path: PathBuf::from(rest) };
scheme: "mkv".into(),
path: rest.to_string(),
};
} }
if let Some(rest) = url.strip_prefix("network://") { if let Some(rest) = url.strip_prefix("network://") {
return StreamUrl { return StreamUrl::Network { addr: rest.to_string() };
scheme: "network".into(),
path: rest.to_string(),
};
} }
if url == "null://" || url.starts_with("null://") { if url == "null://" || url.starts_with("null://") {
return StreamUrl { return StreamUrl::Null;
scheme: "null".into(),
path: String::new(),
};
} }
if url == "stdio://" || url.starts_with("stdio://") { if url == "stdio://" || url.starts_with("stdio://") {
return StreamUrl { return StreamUrl::Stdio;
scheme: "stdio".into(),
path: String::new(),
};
} }
if let Some(rest) = url.strip_prefix("iso://") { if let Some(rest) = url.strip_prefix("iso://") {
return StreamUrl { return StreamUrl::Iso { path: PathBuf::from(rest) };
scheme: "iso".into(),
path: rest.to_string(),
};
}
StreamUrl {
scheme: "unknown".into(),
path: url.to_string(),
} }
StreamUrl::Unknown { raw: url.to_string() }
} }
/// Validate that a file path is non-empty and has a filename component. /// Validate that a file path is non-empty and has a filename component.
fn validate_file_path(path: &str, scheme: &str) -> io::Result<()> { fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
if path.is_empty() { if path.as_os_str().is_empty() {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
format!( format!("{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"),
"{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"
),
)); ));
} }
let p = Path::new(path); if path.file_name().is_none() {
if p.file_name().is_none() {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
format!( format!("{scheme}://{} is not a valid file path — must include a filename", path.display()),
"{scheme}://{path} is not a valid file path — must include a filename"
),
)); ));
} }
Ok(()) Ok(())
@@ -133,9 +156,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
if !addr.contains(':') { if !addr.contains(':') {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
format!( format!("network://{addr} missing port — use network://{addr}:PORT"),
"network://{addr} missing port — use network://{addr}:PORT"
),
)); ));
} }
Ok(()) Ok(())
@@ -145,58 +166,56 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream>> { pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream>> {
let parsed = parse_url(url); let parsed = parse_url(url);
match parsed.scheme.as_str() { match parsed {
"disc" => { StreamUrl::Disc { device } => {
let disc_opts = DiscOptions { let disc_opts = DiscOptions {
device: if parsed.path.is_empty() { None } else { Some(parsed.path) }, device,
keydb_path: opts.keydb_path.clone(), keydb_path: opts.keydb_path.as_ref().map(|p| p.into()),
title_index: opts.title_index, title_index: opts.title_index,
}; };
let stream = DiscStream::open(disc_opts) let stream = DiscStream::open(disc_opts)
.map_err(|e| io::Error::other(e.to_string()))?; .map_err(|e| io::Error::other(e.to_string()))?;
Ok(Box::new(stream)) Ok(Box::new(stream))
} }
"m2ts" => { StreamUrl::M2ts { ref path } => {
validate_file_path(&parsed.path, "m2ts")?; validate_file_path(path, "m2ts")?;
let file = std::fs::File::open(&parsed.path) let file = std::fs::File::open(path)
.map_err(|e| io::Error::new(e.kind(), .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); let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::open(reader)?)) Ok(Box::new(M2tsStream::open(reader)?))
} }
"mkv" => { StreamUrl::Mkv { ref path } => {
validate_file_path(&parsed.path, "mkv")?; validate_file_path(path, "mkv")?;
let file = std::fs::File::open(&parsed.path) let file = std::fs::File::open(path)
.map_err(|e| io::Error::new(e.kind(), .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); let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::open(reader)?)) Ok(Box::new(MkvStream::open(reader)?))
} }
"network" => { StreamUrl::Network { ref addr } => {
validate_network_addr(&parsed.path)?; validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::listen(&parsed.path)?)) Ok(Box::new(NetworkStream::listen(addr)?))
} }
"stdio" => { StreamUrl::Stdio => {
Ok(Box::new(StdioStream::input())) Ok(Box::new(StdioStream::input()))
} }
"iso" => { StreamUrl::Iso { ref path } => {
validate_file_path(&parsed.path, "iso")?; validate_file_path(path, "iso")?;
let scan_opts = match &opts.keydb_path { let scan_opts = match &opts.keydb_path {
Some(p) => crate::disc::ScanOptions::with_keydb(p), Some(p) => crate::disc::ScanOptions::with_keydb(p),
None => crate::disc::ScanOptions::default(), 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, Err(io::Error::new(io::ErrorKind::InvalidInput,
"null:// is write-only — cannot use as input")) "null:// is write-only — cannot use as input"))
} }
"unknown" => { StreamUrl::Unknown { ref raw } => {
Err(io::Error::new(io::ErrorKind::InvalidInput, 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<Box<dyn IOStream
pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>> { pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>> {
let parsed = parse_url(url); let parsed = parse_url(url);
match parsed.scheme.as_str() { match parsed {
"disc" => { StreamUrl::Disc { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported, Err(io::Error::new(io::ErrorKind::Unsupported,
"disc:// is read-only — cannot use as output")) "disc:// is read-only — cannot use as output"))
} }
"iso" => { StreamUrl::Iso { ref path } => {
validate_file_path(&parsed.path, "iso")?; validate_file_path(path, "iso")?;
Ok(Box::new(IsoStream::create(&parsed.path)?.meta(meta))) Ok(Box::new(IsoStream::create(&path.to_string_lossy())?.meta(meta)))
} }
"null" => { StreamUrl::Null => {
Ok(Box::new(NullStream::new().meta(meta))) Ok(Box::new(NullStream::new().meta(meta)))
} }
"stdio" => { StreamUrl::Stdio => {
Ok(Box::new(StdioStream::output().meta(meta))) Ok(Box::new(StdioStream::output().meta(meta)))
} }
"m2ts" => { StreamUrl::M2ts { ref path } => {
validate_file_path(&parsed.path, "m2ts")?; validate_file_path(path, "m2ts")?;
let file = std::fs::File::create(&parsed.path) let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), .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); let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::new(writer).meta(meta))) Ok(Box::new(M2tsStream::new(writer).meta(meta)))
} }
"mkv" => { StreamUrl::Mkv { ref path } => {
validate_file_path(&parsed.path, "mkv")?; validate_file_path(path, "mkv")?;
let file = std::fs::File::create(&parsed.path) let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), .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); 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 { let lookahead = if meta.streams.len() > 15 {
MKV_LOOKAHEAD_UHD MKV_LOOKAHEAD_UHD
} else { } else {
@@ -242,16 +259,14 @@ pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>>
}; };
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead))) Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead)))
} }
"network" => { StreamUrl::Network { ref addr } => {
validate_network_addr(&parsed.path)?; validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::connect(&parsed.path)?.meta(meta))) Ok(Box::new(NetworkStream::connect(addr)?.meta(meta)))
} }
"unknown" => { StreamUrl::Unknown { ref raw } => {
Err(io::Error::new(io::ErrorKind::InvalidInput, 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))),
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod mt1959;
use crate::error::Result; use crate::error::Result;
use crate::scsi::ScsiTransport; use crate::scsi::ScsiTransport;
pub(crate) trait PlatformDriver { pub(crate) trait PlatformDriver: Send {
/// Unlock drive + upload firmware if needed. /// Unlock drive + upload firmware if needed.
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>; fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
+1 -1
View File
@@ -50,7 +50,7 @@ pub struct ScsiResult {
} }
/// Low-level SCSI transport — one implementation per platform. /// Low-level SCSI transport — one implementation per platform.
pub trait ScsiTransport { pub trait ScsiTransport: Send {
fn execute( fn execute(
&mut self, &mut self,
cdb: &[u8], cdb: &[u8],
+1 -1
View File
@@ -1,6 +1,6 @@
//! SectorReader — trait for reading 2048-byte disc sectors. //! 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 //! Used by UDF parser, disc scanner, label parsers — anything that
//! reads sectors doesn't need to know where they come from. //! reads sectors doesn't need to know where they come from.
+17 -17
View File
@@ -59,56 +59,56 @@ fn sample_disc_title() -> DiscTitle {
#[test] #[test]
fn parse_url_disc() { fn parse_url_disc() {
let u = parse_url("disc://"); let u = parse_url("disc://");
assert_eq!(u.scheme, "disc"); assert_eq!(u.scheme(), "disc");
assert_eq!(u.path, ""); assert_eq!(u.path_str(), "");
} }
#[test] #[test]
fn parse_url_disc_device() { fn parse_url_disc_device() {
let u = parse_url("disc:///dev/sg4"); let u = parse_url("disc:///dev/sg4");
assert_eq!(u.scheme, "disc"); assert_eq!(u.scheme(), "disc");
assert_eq!(u.path, "/dev/sg4"); assert_eq!(u.path_str(), "/dev/sg4");
} }
#[test] #[test]
fn parse_url_mkv() { fn parse_url_mkv() {
let u = parse_url("mkv://Dune.mkv"); let u = parse_url("mkv://Dune.mkv");
assert_eq!(u.scheme, "mkv"); assert_eq!(u.scheme(), "mkv");
assert_eq!(u.path, "Dune.mkv"); assert_eq!(u.path_str(), "Dune.mkv");
} }
#[test] #[test]
fn parse_url_network() { fn parse_url_network() {
let u = parse_url("network://10.0.0.1:9000"); let u = parse_url("network://10.0.0.1:9000");
assert_eq!(u.scheme, "network"); assert_eq!(u.scheme(), "network");
assert_eq!(u.path, "10.0.0.1:9000"); assert_eq!(u.path_str(), "10.0.0.1:9000");
} }
#[test] #[test]
fn parse_url_bare_path_rejected() { fn parse_url_bare_path_rejected() {
let u = parse_url("Dune.mkv"); let u = parse_url("Dune.mkv");
assert_eq!(u.scheme, "unknown"); assert_eq!(u.scheme(), "unknown");
} }
#[test] #[test]
fn parse_url_null() { fn parse_url_null() {
let u = parse_url("null://"); let u = parse_url("null://");
assert_eq!(u.scheme, "null"); assert_eq!(u.scheme(), "null");
assert_eq!(u.path, ""); assert_eq!(u.path_str(), "");
} }
#[test] #[test]
fn parse_url_m2ts_with_path() { fn parse_url_m2ts_with_path() {
let u = parse_url("m2ts:///tmp/Dune.m2ts"); let u = parse_url("m2ts:///tmp/Dune.m2ts");
assert_eq!(u.scheme, "m2ts"); assert_eq!(u.scheme(), "m2ts");
assert_eq!(u.path, "/tmp/Dune.m2ts"); assert_eq!(u.path_str(), "/tmp/Dune.m2ts");
} }
#[test] #[test]
fn parse_url_m2ts_relative() { fn parse_url_m2ts_relative() {
let u = parse_url("m2ts://Dune.m2ts"); let u = parse_url("m2ts://Dune.m2ts");
assert_eq!(u.scheme, "m2ts"); assert_eq!(u.scheme(), "m2ts");
assert_eq!(u.path, "Dune.m2ts"); assert_eq!(u.path_str(), "Dune.m2ts");
} }
#[test] #[test]
@@ -182,8 +182,8 @@ fn open_input_network_no_port_errors() {
#[test] #[test]
fn parse_url_stdio() { fn parse_url_stdio() {
let u = parse_url("stdio://"); let u = parse_url("stdio://");
assert_eq!(u.scheme, "stdio"); assert_eq!(u.scheme(), "stdio");
assert_eq!(u.path, ""); assert_eq!(u.path_str(), "");
} }
// ── M2TS metadata roundtrip ─────────────────────────────────── // ── M2TS metadata roundtrip ───────────────────────────────────