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:
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ───────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user