mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
077e4d018f | ||
|
|
f8b5a1eaf1 | ||
|
|
fb1c35e653 | ||
|
|
9268ea2fdb | ||
|
|
9078aa8718 | ||
|
|
c55e6991b8 | ||
|
|
ca931b6522 | ||
|
|
d4d98ce593 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility
|
||||
//! (full P-256/SHA-256 AACS 2.0 handshake prepared but rarely needed)
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::scsi::DataDirection;
|
||||
use num_bigint::BigUint;
|
||||
@@ -26,14 +26,14 @@ use num_traits::{One, Zero};
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
/// Execute a SCSI command that reads data from the device.
|
||||
fn scsi_read(session: &mut DriveSession, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
|
||||
fn scsi_read(session: &mut Drive, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len];
|
||||
session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Execute a SCSI command that writes data to the device.
|
||||
fn scsi_write(session: &mut DriveSession, cdb: &[u8], data: &[u8]) -> Result<()> {
|
||||
fn scsi_write(session: &mut Drive, cdb: &[u8], data: &[u8]) -> Result<()> {
|
||||
let mut buf = data.to_vec();
|
||||
session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?;
|
||||
Ok(())
|
||||
@@ -765,7 +765,7 @@ pub struct AacsAuth {
|
||||
/// Requires a host private key (20 bytes) and host certificate (92 bytes)
|
||||
/// from the KEYDB.cfg HC entry.
|
||||
pub fn aacs_authenticate(
|
||||
session: &mut DriveSession,
|
||||
session: &mut Drive,
|
||||
host_priv_key: &[u8; 20],
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
@@ -890,7 +890,7 @@ pub fn aacs_authenticate(
|
||||
/// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials
|
||||
/// are not available.
|
||||
pub fn aacs2_authenticate(
|
||||
session: &mut DriveSession,
|
||||
session: &mut Drive,
|
||||
host_priv_key_v1: &[u8; 20],
|
||||
host_cert_v1: &[u8],
|
||||
host_priv_key_v2: Option<&[u8; 32]>,
|
||||
@@ -914,7 +914,7 @@ pub fn aacs2_authenticate(
|
||||
/// Native AACS 2.0 handshake using P-256/SHA-256.
|
||||
/// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs).
|
||||
fn aacs2_authenticate_p256(
|
||||
session: &mut DriveSession,
|
||||
session: &mut Drive,
|
||||
host_priv_key: &[u8; 32],
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
@@ -1029,7 +1029,7 @@ fn aacs2_authenticate_p256(
|
||||
}
|
||||
|
||||
/// Read Volume ID after successful authentication.
|
||||
pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> {
|
||||
pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 16]> {
|
||||
// REPORT DISC STRUCTURE format 0x80
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
|
||||
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsVidRead)?;
|
||||
@@ -1051,7 +1051,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
|
||||
|
||||
/// Read data keys after successful authentication (for AACS 2.0 bus encryption).
|
||||
pub fn read_data_keys(
|
||||
session: &mut DriveSession,
|
||||
session: &mut Drive,
|
||||
auth: &mut AacsAuth,
|
||||
) -> Result<([u8; 16], [u8; 16])> {
|
||||
// REPORT DISC STRUCTURE format 0x84
|
||||
|
||||
+2
-2
@@ -167,7 +167,7 @@ impl KeyDb {
|
||||
.to_string();
|
||||
// Try with 0x prefix and without
|
||||
self.disc_entries
|
||||
.get(&format!("0x{}", hash))
|
||||
.get(&format!("0x{hash}"))
|
||||
.or_else(|| self.disc_entries.get(&hash))
|
||||
.and_then(|e| e.vuk)
|
||||
}
|
||||
@@ -180,7 +180,7 @@ impl KeyDb {
|
||||
.trim_start_matches("0x")
|
||||
.to_string();
|
||||
self.disc_entries
|
||||
.get(&format!("0x{}", hash))
|
||||
.get(&format!("0x{hash}"))
|
||||
.or_else(|| self.disc_entries.get(&hash))
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -55,7 +55,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
let mut s = String::with_capacity(42);
|
||||
s.push_str("0x");
|
||||
for b in hash {
|
||||
s.push_str(&format!("{:02X}", b));
|
||||
s.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -341,7 +341,7 @@ fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
|
||||
/// Compute v_mask from a UV value.
|
||||
fn calc_v_mask(uv: u32) -> u32 {
|
||||
let mut v_mask: u32 = 0xFFFFFFFF;
|
||||
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||
v_mask <<= 1;
|
||||
}
|
||||
@@ -411,7 +411,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
|
||||
continue;
|
||||
}
|
||||
|
||||
let u_mask: u32 = 0xFFFFFFFF << u_mask_shift;
|
||||
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
|
||||
let v_mask = calc_v_mask(uv);
|
||||
|
||||
if ((device_number & u_mask) == (uv & u_mask))
|
||||
@@ -419,7 +419,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
|
||||
{
|
||||
// Found matching subset-difference — find the right device key
|
||||
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||
let dev_key_u_mask: u32 = 0xFFFFFFFF << dk.u_mask_shift;
|
||||
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
|
||||
|
||||
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||
// Derive processing key via tree traversal
|
||||
@@ -449,7 +449,7 @@ const MKB_PACK_SIZE: usize = 32772;
|
||||
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||
/// Returns the concatenated MKB data from all packs.
|
||||
pub fn read_mkb_from_drive(
|
||||
session: &mut crate::drive::DriveSession,
|
||||
session: &mut crate::drive::Drive,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ impl ClipInfo {
|
||||
|
||||
/// Reconstruct full SPN from coarse + fine entry.
|
||||
pub fn full_spn(coarse: &EpCoarse, fine: &EpFine) -> u32 {
|
||||
(coarse.spn_coarse & 0xFFFE0000) + fine.spn_fine
|
||||
(coarse.spn_coarse & 0xFFFE_0000) + fine.spn_fine
|
||||
}
|
||||
|
||||
/// Get all EP entries as (PTS, SPN) pairs, fully resolved.
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ impl Disc {
|
||||
2 => "stereo".to_string(),
|
||||
6 => "5.1".to_string(),
|
||||
8 => "7.1".to_string(),
|
||||
n => format!("{}ch", n),
|
||||
n => format!("{n}ch"),
|
||||
};
|
||||
let sample_rate = match a.sample_rate {
|
||||
48000 => "48kHz".to_string(),
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ impl Disc {
|
||||
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
|
||||
/// Only available when scanning from a real drive (not ISO images).
|
||||
pub(super) fn do_handshake(
|
||||
session: &mut crate::drive::DriveSession,
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> Option<HandshakeResult> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
+14
-13
@@ -12,7 +12,7 @@ mod bluray;
|
||||
mod dvd;
|
||||
mod encrypt;
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::speed::DriveSpeed;
|
||||
@@ -333,7 +333,7 @@ impl DiscTitle {
|
||||
pub fn duration_display(&self) -> String {
|
||||
let hrs = (self.duration_secs / 3600.0) as u32;
|
||||
let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
|
||||
format!("{}h {:02}m", hrs, mins)
|
||||
format!("{hrs}h {mins:02}m")
|
||||
}
|
||||
|
||||
/// Size in GB
|
||||
@@ -449,7 +449,7 @@ impl ScanOptions {
|
||||
/// Created by `Disc::open()`. Provides `rip()` to read title data.
|
||||
pub struct OpenDisc {
|
||||
pub disc: Disc,
|
||||
pub session: DriveSession,
|
||||
pub session: Drive,
|
||||
}
|
||||
|
||||
impl OpenDisc {
|
||||
@@ -459,7 +459,7 @@ impl OpenDisc {
|
||||
pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> {
|
||||
use std::path::Path;
|
||||
|
||||
let mut session = DriveSession::open(Path::new(device))?;
|
||||
let mut session = Drive::open(Path::new(device))?;
|
||||
session.wait_ready()?;
|
||||
|
||||
// Init (unlock + firmware) -- non-fatal if fails
|
||||
@@ -530,10 +530,11 @@ impl Disc {
|
||||
/// 4. Parse playlists + streams
|
||||
/// 5. Apply labels
|
||||
///
|
||||
/// The session must be open and unlocked (DriveSession::open handles this).
|
||||
/// The session must be open and unlocked (Drive::open handles this).
|
||||
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
||||
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
|
||||
let capacity = Self::read_capacity(session)?;
|
||||
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
|
||||
// READ CAPACITY may fail in LibreDrive mode — proceed with 0 and estimate later
|
||||
let capacity = Self::read_capacity(session).unwrap_or(0);
|
||||
let handshake = Self::do_handshake(session, opts);
|
||||
Self::scan_with(session, capacity, handshake, opts)
|
||||
}
|
||||
@@ -647,7 +648,7 @@ impl Disc {
|
||||
DiscFormat::Unknown
|
||||
}
|
||||
|
||||
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
||||
fn read_capacity(session: &mut Drive) -> Result<u32> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_CAPACITY,
|
||||
0x00,
|
||||
@@ -683,7 +684,7 @@ impl Disc {
|
||||
/// - On success streak: ramps batch back up, then restores disc speed
|
||||
/// - At minimum batch + still failing: retries once, then skips + zero-fills
|
||||
pub struct ContentReader<'a> {
|
||||
session: &'a mut DriveSession,
|
||||
session: &'a mut Drive,
|
||||
aacs: Option<&'a AacsState>,
|
||||
css: Option<&'a crate::css::CssState>,
|
||||
extents: Vec<Extent>,
|
||||
@@ -714,7 +715,7 @@ impl Disc {
|
||||
///
|
||||
pub fn open_title<'a>(
|
||||
&'a self,
|
||||
session: &'a mut DriveSession,
|
||||
session: &'a mut Drive,
|
||||
title_idx: usize,
|
||||
) -> Result<ContentReader<'a>> {
|
||||
let title = self.titles.get(title_idx).ok_or(Error::DiscTitleRange {
|
||||
@@ -761,7 +762,7 @@ pub(crate) fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
|
||||
// For sg devices, find the corresponding block device name
|
||||
let block_name = if dev_name.starts_with("sg") {
|
||||
let block_dir = format!("/sys/class/scsi_generic/{}/device/block", dev_name);
|
||||
let block_dir = format!("/sys/class/scsi_generic/{dev_name}/device/block");
|
||||
std::fs::read_dir(&block_dir)
|
||||
.ok()
|
||||
.and_then(|mut entries| entries.next())
|
||||
@@ -772,7 +773,7 @@ pub(crate) fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
};
|
||||
|
||||
if let Some(bname) = block_name {
|
||||
let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", bname);
|
||||
let sysfs_path = format!("/sys/block/{bname}/queue/max_hw_sectors_kb");
|
||||
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
|
||||
if let Ok(kb) = content.trim().parse::<u32>() {
|
||||
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
|
||||
@@ -1045,7 +1046,7 @@ fn format_channels(audio_format: u8) -> String {
|
||||
3 => "stereo".into(),
|
||||
6 => "5.1".into(),
|
||||
12 => "7.1".into(),
|
||||
_ if audio_format > 0 => format!("{}ch", audio_format),
|
||||
_ if audio_format > 0 => format!("{audio_format}ch"),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Drive data capture — read hardware information via SCSI.
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Raw data captured from a drive's SCSI responses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveCapture {
|
||||
/// Raw INQUIRY response (96 bytes)
|
||||
pub inquiry: Vec<u8>,
|
||||
/// Raw GET_CONFIG 010C response
|
||||
pub gc_010c: Vec<u8>,
|
||||
/// GET_CONFIG feature responses: (feature_code, feature_name, data)
|
||||
pub features: Vec<CapturedFeature>,
|
||||
/// REPORT_KEY RPC state
|
||||
pub rpc_state: Option<Vec<u8>>,
|
||||
/// MODE SENSE page 2A (capabilities)
|
||||
pub mode_2a: Option<Vec<u8>>,
|
||||
/// READ_BUFFER 0xF1 (Pioneer vendor data)
|
||||
pub rb_f1: Option<Vec<u8>>,
|
||||
/// READ_BUFFER mode 6 (MTK vendor data)
|
||||
pub rb_mode6: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A single GET CONFIGURATION feature response from the drive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapturedFeature {
|
||||
pub code: u16,
|
||||
pub name: &'static str,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Feature codes to capture.
|
||||
const FEATURES: &[(u16, &str)] = &[
|
||||
(0x0000, "Profile List"),
|
||||
(0x0001, "Core"),
|
||||
(0x0003, "Removable Medium"),
|
||||
(0x0010, "Random Readable"),
|
||||
(0x001D, "Multi-Read"),
|
||||
(0x001E, "CD Read"),
|
||||
(0x001F, "DVD Read"),
|
||||
(0x0040, "BD Read"),
|
||||
(0x0041, "BD Write"),
|
||||
(0x0100, "Power Management"),
|
||||
(0x0102, "Embedded Changer"),
|
||||
(0x0107, "Real Time Streaming"),
|
||||
(0x0108, "Serial Number"),
|
||||
(0x010C, "Firmware Information"),
|
||||
(0x010D, "AACS"),
|
||||
];
|
||||
|
||||
/// Capture all available drive data via SCSI commands.
|
||||
/// Returns raw responses — no formatting, no zipping, no presentation.
|
||||
pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
|
||||
let id = &session.drive_id;
|
||||
|
||||
// Already have INQUIRY from drive open
|
||||
let inquiry = id.raw_inquiry.clone();
|
||||
let gc_010c = id.raw_gc_010c.clone();
|
||||
|
||||
// Capture GET_CONFIG features using Drive's query methods
|
||||
let mut features = Vec::new();
|
||||
for &(code, name) in FEATURES {
|
||||
if let Some(data) = session.get_config_feature(code) {
|
||||
features.push(CapturedFeature { code, name, data });
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor-specific READ_BUFFER queries
|
||||
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
|
||||
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
|
||||
|
||||
// Standard queries
|
||||
let rpc_state = session.report_key_rpc_state();
|
||||
let mode_2a = session.mode_sense_page(0x2A);
|
||||
|
||||
Ok(DriveCapture {
|
||||
inquiry,
|
||||
gc_010c,
|
||||
features,
|
||||
rpc_state,
|
||||
mode_2a,
|
||||
rb_f1,
|
||||
rb_mode6,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mask a string for privacy (letters->A, digits->0).
|
||||
pub fn mask_string(s: &str) -> String {
|
||||
s.chars().map(|c| {
|
||||
if c.is_ascii_alphabetic() { 'A' }
|
||||
else if c.is_ascii_digit() { '0' }
|
||||
else { c }
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Mask bytes for privacy.
|
||||
pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
|
||||
data.iter().map(|&b| {
|
||||
if b.is_ascii_alphabetic() { b'A' }
|
||||
else if b.is_ascii_digit() { b'0' }
|
||||
else { b }
|
||||
}).collect()
|
||||
}
|
||||
+3
-5
@@ -6,7 +6,7 @@ use crate::identity::DriveId;
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/sg{}", i);
|
||||
let path = format!("/dev/sg{i}");
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
@@ -40,8 +40,7 @@ pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
&& sg_id.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning = format!(
|
||||
"{} is a block device (sr) — using {} (sg) for raw access",
|
||||
path, sg_path
|
||||
"{path} is a block device (sr) — using {sg_path} (sg) for raw access"
|
||||
);
|
||||
return Ok((sg_path, Some(warning)));
|
||||
}
|
||||
@@ -49,8 +48,7 @@ pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
return Ok((
|
||||
path.to_string(),
|
||||
Some(format!(
|
||||
"{} is a block device (sr) — no matching sg device found",
|
||||
path
|
||||
"{path} is a block device (sr) — no matching sg device found"
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
+240
-23
@@ -6,6 +6,8 @@
|
||||
//! 3. `init()` — activate custom firmware. Removes riplock.
|
||||
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
|
||||
|
||||
pub mod capture;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -22,7 +24,23 @@ use crate::scsi::ScsiTransport;
|
||||
use crate::sector::SectorReader;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct DriveSession {
|
||||
/// Physical state of the drive tray and disc.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DriveStatus {
|
||||
/// Tray is open
|
||||
TrayOpen,
|
||||
/// Tray closed, no disc
|
||||
NoDisc,
|
||||
/// Tray closed, disc present and ready
|
||||
DiscPresent,
|
||||
/// Drive is loading or spinning up
|
||||
NotReady,
|
||||
/// Could not determine status
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||
pub struct Drive {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
driver: Option<Box<dyn PlatformDriver>>,
|
||||
pub profile: Option<DriveProfile>,
|
||||
@@ -31,7 +49,7 @@ pub struct DriveSession {
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl DriveSession {
|
||||
impl Drive {
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let profiles = profile::load_bundled()?;
|
||||
@@ -47,7 +65,7 @@ impl DriveSession {
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
Ok(DriveSession {
|
||||
Ok(Drive {
|
||||
scsi: transport,
|
||||
driver,
|
||||
platform,
|
||||
@@ -64,15 +82,31 @@ impl DriveSession {
|
||||
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut tried_reset = false;
|
||||
|
||||
for _ in 0..60 {
|
||||
let mut buf = [0u8; 0];
|
||||
if self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5000)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 5, .. }) if !tried_reset => {
|
||||
// Illegal Request on TUR — drive may be stuck from a previous session.
|
||||
// Try reset() which attempts multiple recovery approaches.
|
||||
tried_reset = true;
|
||||
if self.reset().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// If reset failed but disc is present, proceed anyway —
|
||||
// the scan path will handle errors individually.
|
||||
if self.drive_status() == DriveStatus::DiscPresent {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
@@ -81,6 +115,118 @@ impl DriveSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// Query the physical state of the drive — disc present, tray open, etc.
|
||||
/// Uses GET EVENT STATUS NOTIFICATION which works regardless of firmware state.
|
||||
pub fn drive_status(&mut self) -> DriveStatus {
|
||||
// GET EVENT STATUS NOTIFICATION: polled, media event class (0x10)
|
||||
let cdb = [0x4Au8, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x08, 0x00];
|
||||
let mut buf = [0u8; 8];
|
||||
match self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(r) if r.bytes_transferred >= 6 => {
|
||||
let media_status = buf[5];
|
||||
// Bits 1-0: door/tray state
|
||||
// Bit 1: media present, Bit 0: tray open
|
||||
match media_status & 0x03 {
|
||||
0x00 => DriveStatus::NoDisc, // tray closed, no disc
|
||||
0x01 => DriveStatus::TrayOpen, // tray open
|
||||
0x02 => DriveStatus::DiscPresent, // tray closed, disc present
|
||||
0x03 => DriveStatus::DiscPresent, // tray closed, disc present
|
||||
_ => DriveStatus::Unknown,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Fallback: try TUR
|
||||
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut empty = [0u8; 0];
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut empty,
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => DriveStatus::DiscPresent,
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => DriveStatus::NotReady,
|
||||
Err(Error::ScsiError { sense_key: 6, .. }) => DriveStatus::NotReady, // UNIT ATTENTION
|
||||
_ => DriveStatus::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to reset the drive to a clean state.
|
||||
///
|
||||
/// Escalates through increasingly aggressive recovery:
|
||||
/// 1. Unlock tray + stop/start — handles normal stuck states
|
||||
/// 2. Eject cycle — clears LibreDrive firmware stuck state (proven on BU40N)
|
||||
/// 3. Re-init — firmware re-upload if profile available
|
||||
///
|
||||
/// Note: step 2 physically ejects the tray. On slimline drives the user
|
||||
/// must push it back in manually. Returns Ok(()) if TUR succeeds after
|
||||
/// any step, even if the drive reports "tray open" (that's a valid state).
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
let mut buf = [0u8; 0];
|
||||
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
// 1. Unlock + stop/start
|
||||
self.unlock_tray();
|
||||
let stop = [0x1Bu8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&stop, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let start = [0x1Bu8, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&start, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
if self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Eject cycle — clears MT1959 LibreDrive stuck state.
|
||||
// After eject, TUR returning "Not Ready — tray open" (sense key 2)
|
||||
// counts as success: the drive is functional, just needs disc reinserted.
|
||||
self.unlock_tray();
|
||||
let eject = [0x1Bu8, 0x00, 0x00, 0x00, 0x02, 0x00];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&eject, crate::scsi::DataDirection::None, &mut buf, 30_000,
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), // tray open = valid
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 3. If still stuck and we have a profile, try re-init
|
||||
if self.driver.is_some() {
|
||||
self.init()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
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(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::DeviceNotFound {
|
||||
path: format!("{}: drive reset failed", self.device_path),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn platform_name(&self) -> &str {
|
||||
match self.platform {
|
||||
Some(ref p) => p.name(),
|
||||
@@ -119,6 +265,51 @@ impl DriveSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Query a specific GET CONFIGURATION feature by code.
|
||||
/// Returns the feature data (without the 8-byte header), or None if not available.
|
||||
pub fn get_config_feature(&mut self, feature_code: u16) -> Option<Vec<u8>> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_GET_CONFIGURATION, 0x02,
|
||||
(feature_code >> 8) as u8, feature_code as u8,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 256];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 8 {
|
||||
Some(buf[8..r.bytes_transferred].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Read REPORT KEY RPC state (region playback control).
|
||||
pub fn report_key_rpc_state(&mut self) -> Option<Vec<u8>> {
|
||||
let cdb = [0xA4u8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00];
|
||||
let mut buf = vec![0u8; 8];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
}
|
||||
|
||||
/// Read MODE SENSE page data.
|
||||
pub fn mode_sense_page(&mut self, page: u8) -> Option<Vec<u8>> {
|
||||
let cdb = [0x5Au8, 0x00, page, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00];
|
||||
let mut buf = vec![0u8; 252];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
}
|
||||
|
||||
/// Read vendor-specific READ BUFFER data.
|
||||
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
|
||||
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
let r = self.scsi.as_mut()
|
||||
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
|
||||
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
match self.driver {
|
||||
Some(ref d) => d.is_ready(),
|
||||
@@ -174,16 +365,29 @@ impl DriveSession {
|
||||
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
|
||||
}
|
||||
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
|
||||
/// Lock the tray so the disc cannot be ejected during a rip.
|
||||
pub fn lock_tray(&mut self) {
|
||||
let prevent = [0x1Eu8, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&allow_cdb,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
5_000,
|
||||
&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
}
|
||||
|
||||
/// Unlock the tray so the user can manually eject the disc.
|
||||
pub fn unlock_tray(&mut self) {
|
||||
let allow = [0x1Eu8, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(
|
||||
&allow, crate::scsi::DataDirection::None, &mut buf, 5_000,
|
||||
);
|
||||
}
|
||||
|
||||
/// Eject the disc tray. Unlocks first, then ejects.
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
self.unlock_tray();
|
||||
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
self.scsi.as_mut().execute(
|
||||
&eject_cdb,
|
||||
crate::scsi::DataDirection::None,
|
||||
@@ -204,13 +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> {
|
||||
self.read_disc(lba, count, buf)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
/// Find all optical drives connected to this system.
|
||||
/// 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")]
|
||||
{
|
||||
linux::find_drives()
|
||||
@@ -225,11 +445,8 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_drive() -> Option<String> {
|
||||
find_drives().into_iter().next().map(|(path, _)| path)
|
||||
}
|
||||
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
/// Resolve a device path to its raw SCSI device, with optional warning message.
|
||||
pub(crate) fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::resolve_device(path)
|
||||
|
||||
+43
-9
@@ -71,102 +71,135 @@ pub const E_MUX_WRITE: u16 = 9001;
|
||||
/// Structured error with numeric code and context data. No English text.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
// Device
|
||||
/// Device not found at the given path.
|
||||
DeviceNotFound {
|
||||
path: String,
|
||||
},
|
||||
/// Insufficient permissions to open the device.
|
||||
DevicePermission {
|
||||
path: String,
|
||||
},
|
||||
|
||||
// Profile
|
||||
/// Drive model is not in the profile database.
|
||||
UnsupportedDrive {
|
||||
vendor_id: String,
|
||||
product_id: String,
|
||||
product_revision: String,
|
||||
},
|
||||
/// No matching firmware profile found for this drive revision.
|
||||
ProfileNotFound {
|
||||
vendor_id: String,
|
||||
product_revision: String,
|
||||
vendor_specific: String,
|
||||
},
|
||||
/// Failed to parse the bundled profile database.
|
||||
ProfileParse,
|
||||
|
||||
// Unlock
|
||||
/// Drive unlock (firmware upload) failed.
|
||||
UnlockFailed,
|
||||
/// Firmware signature verification failed.
|
||||
SignatureMismatch {
|
||||
expected: [u8; 4],
|
||||
got: [u8; 4],
|
||||
},
|
||||
/// Operation requires an unlocked drive.
|
||||
NotUnlocked,
|
||||
/// Operation requires a calibrated drive.
|
||||
NotCalibrated,
|
||||
|
||||
// SCSI
|
||||
/// SCSI command returned an error status.
|
||||
ScsiError {
|
||||
opcode: u8,
|
||||
status: u8,
|
||||
sense_key: u8,
|
||||
},
|
||||
/// SCSI command timed out.
|
||||
ScsiTimeout {
|
||||
opcode: u8,
|
||||
},
|
||||
|
||||
// I/O
|
||||
/// Underlying I/O error.
|
||||
IoError {
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// Write operation failed.
|
||||
WriteError,
|
||||
|
||||
// Disc format
|
||||
/// Failed to read disc sector.
|
||||
DiscRead {
|
||||
sector: u64,
|
||||
},
|
||||
/// MPLS playlist parsing failed.
|
||||
MplsParse,
|
||||
/// CLPI clip info parsing failed.
|
||||
ClpiParse,
|
||||
/// File not found on the UDF filesystem.
|
||||
UdfNotFound {
|
||||
path: String,
|
||||
},
|
||||
/// Disc contains no playable titles.
|
||||
DiscNoTitles,
|
||||
/// Title index out of range.
|
||||
DiscTitleRange {
|
||||
index: usize,
|
||||
count: usize,
|
||||
},
|
||||
/// Title has no sector extents to read.
|
||||
DiscNoExtents,
|
||||
/// DVD IFO file parsing failed.
|
||||
IfoParse,
|
||||
|
||||
// AACS
|
||||
/// No AACS decryption keys available for this disc.
|
||||
AacsNoKeys,
|
||||
/// Host certificate too short.
|
||||
AacsCertShort,
|
||||
/// Failed to allocate AGID for AACS handshake.
|
||||
AacsAgidAlloc,
|
||||
/// Drive rejected the host certificate.
|
||||
AacsCertRejected,
|
||||
/// Failed to read drive certificate.
|
||||
AacsCertRead,
|
||||
/// Drive certificate verification failed.
|
||||
AacsCertVerify,
|
||||
/// Failed to read host key from drive.
|
||||
AacsKeyRead,
|
||||
/// Drive rejected the host key.
|
||||
AacsKeyRejected,
|
||||
/// Host key verification failed.
|
||||
AacsKeyVerify,
|
||||
/// Failed to read Volume ID.
|
||||
AacsVidRead,
|
||||
/// Volume ID MAC verification failed.
|
||||
AacsVidMac,
|
||||
/// Failed to derive the data key.
|
||||
AacsDataKey,
|
||||
/// Failed to derive the Volume Unique Key.
|
||||
AacsVukDerive,
|
||||
|
||||
// Keydb
|
||||
/// Failed to connect to the KEYDB server.
|
||||
KeydbConnect {
|
||||
host: String,
|
||||
},
|
||||
/// KEYDB server returned an HTTP error.
|
||||
KeydbHttp {
|
||||
status: u16,
|
||||
},
|
||||
/// Downloaded KEYDB file is invalid (no entries found).
|
||||
KeydbInvalid,
|
||||
/// Failed to write KEYDB to disk.
|
||||
KeydbWrite {
|
||||
path: String,
|
||||
},
|
||||
/// Failed to parse KEYDB file.
|
||||
KeydbParse,
|
||||
/// Failed to load KEYDB from disk.
|
||||
KeydbLoad {
|
||||
path: String,
|
||||
},
|
||||
|
||||
// Mux
|
||||
/// Lookahead buffer exhausted before codec headers found.
|
||||
MuxLookahead,
|
||||
/// Muxer write failed.
|
||||
MuxWrite,
|
||||
}
|
||||
|
||||
@@ -306,4 +339,5 @@ impl From<std::io::Error> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience alias for `Result<T, Error>`.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ fn parse_vts(
|
||||
vts_number: u8,
|
||||
titles_info: &[(u16, u8)],
|
||||
) -> Result<DvdTitleSet> {
|
||||
let path = format!("/VIDEO_TS/VTS_{:02}_0.IFO", vts_number);
|
||||
let path = format!("/VIDEO_TS/VTS_{vts_number:02}_0.IFO");
|
||||
let vts_data = udf.read_file(reader, &path)?;
|
||||
|
||||
// Validate VTS magic
|
||||
|
||||
+3
-3
@@ -71,6 +71,7 @@ pub fn save(data: &[u8]) -> Result<UpdateResult> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Result of a KEYDB update -- path written, entry count, and byte size.
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateResult {
|
||||
pub path: PathBuf,
|
||||
@@ -82,7 +83,7 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
|
||||
let (mut host, mut port, mut path) = parse_url(url)?;
|
||||
|
||||
for _ in 0..5 {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let addr = format!("{host}:{port}");
|
||||
let mut stream =
|
||||
TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
||||
stream
|
||||
@@ -90,8 +91,7 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
|
||||
.ok();
|
||||
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n",
|
||||
path, host
|
||||
"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n"
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
|
||||
@@ -174,8 +174,8 @@ fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
|
||||
}
|
||||
|
||||
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{}>", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = xml[start..].find(&close)? + start;
|
||||
Some(xml[start..end].trim().to_string())
|
||||
|
||||
@@ -140,7 +140,7 @@ fn find_feature_playlist(xml: &str) -> Option<String> {
|
||||
|
||||
/// Extract an XML attribute value from an element string.
|
||||
fn extract_attr(element: &str, name: &str) -> Option<String> {
|
||||
let needle = format!("{}=\"", name);
|
||||
let needle = format!("{name}=\"");
|
||||
let start = element.find(&needle)? + needle.len();
|
||||
let end = element[start..].find('"')? + start;
|
||||
Some(element[start..end].to_string())
|
||||
|
||||
+10
-11
@@ -6,19 +6,17 @@
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive};
|
||||
//! use std::path::Path;
|
||||
//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
|
||||
//!
|
||||
//! let device = find_drive().expect("no optical drive found");
|
||||
//! let mut session = DriveSession::open(Path::new(&device)).unwrap();
|
||||
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
//! let mut drive = find_drive().expect("no optical drive found");
|
||||
//! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
|
||||
//!
|
||||
//! for title in &disc.titles {
|
||||
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
|
||||
//! }
|
||||
//!
|
||||
//! // Read content (decrypted automatically if AACS keys available)
|
||||
//! let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
//! let mut reader = disc.open_title(&mut drive, 0).unwrap();
|
||||
//! while let Some(unit) = reader.read_unit().unwrap() {
|
||||
//! // 6144 bytes of decrypted content per unit
|
||||
//! }
|
||||
@@ -27,7 +25,7 @@
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! DriveSession -- open, identify, unlock, read sectors
|
||||
//! Drive -- open, identify, unlock, read sectors
|
||||
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
|
||||
//! ├── DriveProfile -- per-drive unlock parameters (206 bundled)
|
||||
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
|
||||
@@ -81,18 +79,19 @@ pub(crate) mod labels;
|
||||
pub(crate) mod mpls;
|
||||
pub mod mux;
|
||||
pub(crate) mod platform;
|
||||
pub(crate) mod profile;
|
||||
pub mod profile;
|
||||
pub mod scsi;
|
||||
pub(crate) mod sector;
|
||||
pub(crate) mod speed;
|
||||
pub(crate) mod udf;
|
||||
|
||||
pub use drive::{find_drive, find_drives, resolve_device, DriveSession};
|
||||
pub use drive::{find_drive, find_drives, Drive, DriveStatus};
|
||||
pub use drive::capture::{DriveCapture, CapturedFeature, capture_drive_data, mask_string, mask_bytes};
|
||||
pub use error::{Error, Result};
|
||||
pub use event::{Event, EventKind};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
|
||||
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
|
||||
pub use disc::{
|
||||
AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc,
|
||||
DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream,
|
||||
@@ -107,7 +106,7 @@ pub use mux::MkvStream;
|
||||
pub use mux::NetworkStream;
|
||||
pub use mux::NullStream;
|
||||
pub use mux::StdioStream;
|
||||
pub use mux::{open_input, open_output, parse_url, InputOptions};
|
||||
pub use mux::{open_input, open_output, parse_url, InputOptions, StreamUrl};
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use sector::SectorReader;
|
||||
pub use speed::DriveSpeed;
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn format_palette(palette: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut parts: Vec<String> = Vec::with_capacity(palette.len());
|
||||
for color in palette {
|
||||
let [r, g, b] = ycbcr_to_rgb(color);
|
||||
parts.push(format!("{:02x}{:02x}{:02x}", r, g, b));
|
||||
parts.push(format!("{r:02x}{g:02x}{b:02x}"));
|
||||
}
|
||||
let line = format!("palette: {}\n", parts.join(", "));
|
||||
line.into_bytes()
|
||||
|
||||
+8
-11
@@ -1,6 +1,6 @@
|
||||
//! DiscStream — read BD-TS data from an optical disc drive.
|
||||
//!
|
||||
//! Read-only stream. Wraps DriveSession + Disc.
|
||||
//! Read-only stream. Wraps Drive + Disc.
|
||||
//! Handles drive init, AACS decryption, and sector reading.
|
||||
//!
|
||||
//! Reading state (extent index, offset, batch size, error recovery) is stored
|
||||
@@ -12,11 +12,10 @@ use crate::disc::{
|
||||
detect_max_batch_sectors, ContentFormat, Disc, DiscTitle, Extent, MIN_BATCH_SECTORS,
|
||||
RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER,
|
||||
};
|
||||
use crate::drive::DriveSession;
|
||||
use crate::drive::Drive;
|
||||
use crate::error::Error;
|
||||
use crate::speed::DriveSpeed;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// AACS decryption parameters needed at read time.
|
||||
/// Extracted from `AacsState` so we don't need `Clone` on the full struct.
|
||||
@@ -29,9 +28,9 @@ struct AacsDecrypt {
|
||||
#[derive(Default)]
|
||||
pub struct DiscOptions {
|
||||
/// 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.
|
||||
pub keydb_path: Option<String>,
|
||||
pub keydb_path: Option<std::path::PathBuf>,
|
||||
/// Which title to read (0-based). None = longest title.
|
||||
pub title_index: Option<usize>,
|
||||
}
|
||||
@@ -43,7 +42,7 @@ pub struct DiscOptions {
|
||||
pub struct DiscStream {
|
||||
disc_title: DiscTitle,
|
||||
disc: Disc,
|
||||
session: DriveSession,
|
||||
session: Drive,
|
||||
// Read buffer: holds one decoded batch
|
||||
batch_buf: Vec<u8>,
|
||||
batch_pos: usize,
|
||||
@@ -74,20 +73,18 @@ pub struct DiscStream {
|
||||
impl DiscStream {
|
||||
/// Open the disc drive and scan disc metadata.
|
||||
pub fn open(opts: DiscOptions) -> Result<Self, Error> {
|
||||
let device = match opts.device {
|
||||
Some(ref d) => crate::drive::resolve_device(d)?.0,
|
||||
let mut session = match opts.device {
|
||||
Some(ref d) => Drive::open(d)?,
|
||||
None => crate::drive::find_drive().ok_or_else(|| Error::DeviceNotFound {
|
||||
path: String::new(),
|
||||
})?,
|
||||
};
|
||||
|
||||
let mut session = DriveSession::open(Path::new(&device))?;
|
||||
session.wait_ready()?;
|
||||
let _ = session.init();
|
||||
let _ = session.probe_disc();
|
||||
|
||||
let scan_opts = match opts.keydb_path {
|
||||
Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp),
|
||||
Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp.clone()),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
let disc = Disc::scan(&mut session, &scan_opts)?;
|
||||
|
||||
+14
-14
@@ -203,7 +203,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
let mut b = [0u8; 2];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (((b0 & 0x1F) as u64) << 16) | (b[0] as u64) << 8 | b[1] as u64;
|
||||
if val == 0x1FFFFF {
|
||||
if val == 0x1F_FFFF {
|
||||
return Ok((u64::MAX, 3));
|
||||
}
|
||||
Ok((val, 3))
|
||||
@@ -212,7 +212,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
r.read_exact(&mut b)?;
|
||||
let val =
|
||||
(((b0 & 0x0F) as u64) << 24) | (b[0] as u64) << 16 | (b[1] as u64) << 8 | b[2] as u64;
|
||||
if val == 0x0FFFFFFF {
|
||||
if val == 0x0FFF_FFFF {
|
||||
return Ok((u64::MAX, 4));
|
||||
}
|
||||
Ok((val, 4))
|
||||
@@ -256,7 +256,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
| (b[4] as u64) << 16
|
||||
| (b[5] as u64) << 8
|
||||
| b[6] as u64;
|
||||
if val == 0x00FFFFFFFFFFFFFF {
|
||||
if val == 0x00FF_FFFF_FFFF_FFFF {
|
||||
return Ok((u64::MAX, 8));
|
||||
}
|
||||
Ok((val, 8))
|
||||
@@ -336,7 +336,7 @@ pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
// ============================================================
|
||||
|
||||
// EBML Header
|
||||
pub const EBML: u32 = 0x1A45DFA3;
|
||||
pub const EBML: u32 = 0x1A45_DFA3;
|
||||
pub const EBML_VERSION: u32 = 0x4286;
|
||||
pub const EBML_READ_VERSION: u32 = 0x42F7;
|
||||
pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2;
|
||||
@@ -346,24 +346,24 @@ pub const EBML_DOC_TYPE_VERSION: u32 = 0x4287;
|
||||
pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
||||
|
||||
// Segment
|
||||
pub const SEGMENT: u32 = 0x18538067;
|
||||
pub const SEGMENT: u32 = 0x1853_8067;
|
||||
|
||||
// Seek Head
|
||||
pub const SEEK_HEAD: u32 = 0x114D9B74;
|
||||
pub const SEEK_HEAD: u32 = 0x114D_9B74;
|
||||
pub const SEEK: u32 = 0x4DBB;
|
||||
pub const SEEK_ID: u32 = 0x53AB;
|
||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||
|
||||
// Segment Info
|
||||
pub const INFO: u32 = 0x1549A966;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2AD7B1;
|
||||
pub const INFO: u32 = 0x1549_A966;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
|
||||
pub const DURATION: u32 = 0x4489;
|
||||
pub const MUXING_APP: u32 = 0x4D80;
|
||||
pub const WRITING_APP: u32 = 0x5741;
|
||||
pub const TITLE: u32 = 0x7BA9;
|
||||
|
||||
// Tracks
|
||||
pub const TRACKS: u32 = 0x1654AE6B;
|
||||
pub const TRACKS: u32 = 0x1654_AE6B;
|
||||
pub const TRACK_ENTRY: u32 = 0xAE;
|
||||
pub const TRACK_NUMBER: u32 = 0xD7;
|
||||
pub const TRACK_UID: u32 = 0x73C5;
|
||||
@@ -371,11 +371,11 @@ pub const TRACK_TYPE: u32 = 0x83;
|
||||
pub const FLAG_LACING: u32 = 0x9C;
|
||||
pub const FLAG_DEFAULT: u32 = 0x88;
|
||||
pub const FLAG_FORCED: u32 = 0x55AA;
|
||||
pub const LANGUAGE: u32 = 0x22B59C;
|
||||
pub const LANGUAGE: u32 = 0x22_B59C;
|
||||
pub const CODEC_ID: u32 = 0x86;
|
||||
pub const CODEC_PRIVATE: u32 = 0x63A2;
|
||||
pub const TRACK_NAME: u32 = 0x536E;
|
||||
pub const DEFAULT_DURATION: u32 = 0x23E383;
|
||||
pub const DEFAULT_DURATION: u32 = 0x23_E383;
|
||||
|
||||
// Video
|
||||
pub const VIDEO: u32 = 0xE0;
|
||||
@@ -396,12 +396,12 @@ pub const CHANNELS: u32 = 0x9F;
|
||||
pub const BIT_DEPTH: u32 = 0x6264;
|
||||
|
||||
// Cluster
|
||||
pub const CLUSTER: u32 = 0x1F43B675;
|
||||
pub const CLUSTER: u32 = 0x1F43_B675;
|
||||
pub const CLUSTER_TIMESTAMP: u32 = 0xE7;
|
||||
pub const SIMPLE_BLOCK: u32 = 0xA3;
|
||||
|
||||
// Cues
|
||||
pub const CUES: u32 = 0x1C53BB6B;
|
||||
pub const CUES: u32 = 0x1C53_BB6B;
|
||||
pub const CUE_POINT: u32 = 0xBB;
|
||||
pub const CUE_TIME: u32 = 0xB3;
|
||||
pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
|
||||
@@ -409,7 +409,7 @@ pub const CUE_TRACK: u32 = 0xF7;
|
||||
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
|
||||
|
||||
// Chapters
|
||||
pub const CHAPTERS: u32 = 0x1043A770;
|
||||
pub const CHAPTERS: u32 = 0x1043_A770;
|
||||
pub const EDITION_ENTRY: u32 = 0x45B9;
|
||||
pub const CHAPTER_ATOM: u32 = 0xB6;
|
||||
pub const CHAPTER_UID: u32 = 0x73C4;
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ pub struct IsoSectorReader {
|
||||
impl IsoSectorReader {
|
||||
pub fn open(path: &str) -> io::Result<Self> {
|
||||
let file = File::open(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
|
||||
let size = file.metadata()?.len();
|
||||
let capacity = (size / SECTOR_SIZE) as u32;
|
||||
Ok(Self { file, capacity })
|
||||
@@ -123,7 +123,7 @@ impl IsoStream {
|
||||
/// Create an ISO file for writing.
|
||||
pub fn create(path: &str) -> io::Result<Self> {
|
||||
let file = File::create(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
|
||||
let buf_writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
let iso_writer = IsoWriter::new(buf_writer, "FREEMKV", "00001.m2ts");
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ impl<W: Write + Seek> IsoWriter<W> {
|
||||
// Partition starting location at offset 188
|
||||
pd[188..192].copy_from_slice(&PARTITION_START.to_le_bytes());
|
||||
// Partition length (large enough for everything)
|
||||
let part_len: u32 = 0xFFFFFFFF;
|
||||
let part_len: u32 = 0xFFFF_FFFF;
|
||||
pd[192..196].copy_from_slice(&part_len.to_le_bytes());
|
||||
self.writer.write_all(&pd)?;
|
||||
|
||||
|
||||
+2
-2
@@ -183,7 +183,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x1000000)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x100_0000)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
|
||||
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
|
||||
@@ -432,7 +432,7 @@ fn parse_resolution(s: &str) -> (u32, u32) {
|
||||
|
||||
fn parse_sample_rate(s: &str) -> f64 {
|
||||
if s.contains("192") {
|
||||
192000.0
|
||||
192_000.0
|
||||
} else if s.contains("96") {
|
||||
96000.0
|
||||
} else {
|
||||
|
||||
@@ -84,7 +84,11 @@ impl MkvStream {
|
||||
for s in &dt.streams {
|
||||
let (pid, track, parser) = match s {
|
||||
crate::disc::Stream::Video(v) => {
|
||||
ws.video_pending += 1;
|
||||
// Only count primary video as pending — secondary streams
|
||||
// (Dolby Vision EL, PiP) may never produce codec headers
|
||||
if !v.secondary {
|
||||
ws.video_pending += 1;
|
||||
}
|
||||
(v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec))
|
||||
}
|
||||
crate::disc::Stream::Audio(a) => {
|
||||
@@ -512,7 +516,7 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
|
||||
"S_VOBSUB" => Codec::DvdSub,
|
||||
_ => Codec::Unknown(0),
|
||||
};
|
||||
let res = format!("{}p", ph);
|
||||
let res = format!("{ph}p");
|
||||
let chs: String = match ch {
|
||||
8 => "7.1",
|
||||
6 => "5.1",
|
||||
|
||||
+129
-108
@@ -22,101 +22,124 @@ use super::stdio::StdioStream;
|
||||
use super::{IOStream, M2tsStream, MkvStream};
|
||||
use crate::disc::DiscTitle;
|
||||
use std::io::{self, BufReader, BufWriter};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// I/O buffer size for file streams.
|
||||
const IO_BUF_SIZE: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// MKV lookahead buffer size.
|
||||
const MKV_LOOKAHEAD: usize = 10 * 1024 * 1024;
|
||||
/// Default MKV lookahead buffer size.
|
||||
/// Dynamically increased for UHD content (many streams delay video codec headers).
|
||||
const MKV_LOOKAHEAD_DEFAULT: usize = 10 * 1024 * 1024;
|
||||
const MKV_LOOKAHEAD_UHD: usize = 100 * 1024 * 1024;
|
||||
|
||||
/// Parsed stream URL.
|
||||
pub struct StreamUrl {
|
||||
pub scheme: String,
|
||||
pub path: String,
|
||||
pub enum StreamUrl {
|
||||
/// Optical disc drive. Device path is optional (auto-detect if None).
|
||||
Disc { device: Option<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.
|
||||
///
|
||||
/// ```text
|
||||
/// disc:// → scheme="disc", path=""
|
||||
/// disc:///dev/sg4 → scheme="disc", path="/dev/sg4"
|
||||
/// m2ts:///tmp/Dune.m2ts → scheme="m2ts", path="/tmp/Dune.m2ts"
|
||||
/// mkv://Dune.mkv → scheme="mkv", path="Dune.mkv"
|
||||
/// network://10.0.0.1:9000 → scheme="network", path="10.0.0.1:9000"
|
||||
/// null:// → scheme="null", path=""
|
||||
/// disc:// → Disc { device: None }
|
||||
/// disc:///dev/sg4 → Disc { device: Some("/dev/sg4") }
|
||||
/// m2ts:///tmp/Dune.m2ts → M2ts { path: "/tmp/Dune.m2ts" }
|
||||
/// mkv://Dune.mkv → Mkv { path: "Dune.mkv" }
|
||||
/// network://10.0.0.1:9000 → Network { addr: "10.0.0.1:9000" }
|
||||
/// null:// → Null
|
||||
/// ```
|
||||
pub fn parse_url(url: &str) -> StreamUrl {
|
||||
if let Some(rest) = url.strip_prefix("disc://") {
|
||||
return StreamUrl {
|
||||
scheme: "disc".into(),
|
||||
path: rest.to_string(),
|
||||
return if rest.is_empty() {
|
||||
StreamUrl::Disc { device: None }
|
||||
} else {
|
||||
StreamUrl::Disc { device: Some(PathBuf::from(rest)) }
|
||||
};
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("m2ts://") {
|
||||
return StreamUrl {
|
||||
scheme: "m2ts".into(),
|
||||
path: rest.to_string(),
|
||||
};
|
||||
return StreamUrl::M2ts { path: PathBuf::from(rest) };
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("mkv://") {
|
||||
return StreamUrl {
|
||||
scheme: "mkv".into(),
|
||||
path: rest.to_string(),
|
||||
};
|
||||
return StreamUrl::Mkv { path: PathBuf::from(rest) };
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("network://") {
|
||||
return StreamUrl {
|
||||
scheme: "network".into(),
|
||||
path: rest.to_string(),
|
||||
};
|
||||
return StreamUrl::Network { addr: rest.to_string() };
|
||||
}
|
||||
if url == "null://" || url.starts_with("null://") {
|
||||
return StreamUrl {
|
||||
scheme: "null".into(),
|
||||
path: String::new(),
|
||||
};
|
||||
return StreamUrl::Null;
|
||||
}
|
||||
if url == "stdio://" || url.starts_with("stdio://") {
|
||||
return StreamUrl {
|
||||
scheme: "stdio".into(),
|
||||
path: String::new(),
|
||||
};
|
||||
return StreamUrl::Stdio;
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("iso://") {
|
||||
return StreamUrl {
|
||||
scheme: "iso".into(),
|
||||
path: rest.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
StreamUrl {
|
||||
scheme: "unknown".into(),
|
||||
path: url.to_string(),
|
||||
return StreamUrl::Iso { path: PathBuf::from(rest) };
|
||||
}
|
||||
StreamUrl::Unknown { raw: url.to_string() }
|
||||
}
|
||||
|
||||
/// Validate that a file path is non-empty and has a filename component.
|
||||
fn validate_file_path(path: &str, scheme: &str) -> io::Result<()> {
|
||||
if path.is_empty() {
|
||||
fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"{}:// requires a file path (e.g. {}://movie.{})",
|
||||
scheme, scheme, scheme
|
||||
),
|
||||
format!("{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"),
|
||||
));
|
||||
}
|
||||
let p = Path::new(path);
|
||||
if p.file_name().is_none() {
|
||||
if path.file_name().is_none() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"{}://{} is not a valid file path — must include a filename",
|
||||
scheme, path
|
||||
),
|
||||
format!("{scheme}://{} is not a valid file path — must include a filename", path.display()),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -133,10 +156,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
if !addr.contains(':') {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"network://{} missing port — use network://{}:PORT",
|
||||
addr, addr
|
||||
),
|
||||
format!("network://{addr} missing port — use network://{addr}:PORT"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -146,58 +166,56 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream>> {
|
||||
let parsed = parse_url(url);
|
||||
|
||||
match parsed.scheme.as_str() {
|
||||
"disc" => {
|
||||
match parsed {
|
||||
StreamUrl::Disc { device } => {
|
||||
let disc_opts = DiscOptions {
|
||||
device: if parsed.path.is_empty() { None } else { Some(parsed.path) },
|
||||
keydb_path: opts.keydb_path.clone(),
|
||||
device,
|
||||
keydb_path: opts.keydb_path.as_ref().map(|p| p.into()),
|
||||
title_index: opts.title_index,
|
||||
};
|
||||
let stream = DiscStream::open(disc_opts)
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
"m2ts" => {
|
||||
validate_file_path(&parsed.path, "m2ts")?;
|
||||
let file = std::fs::File::open(&parsed.path)
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("m2ts://{}: {}", parsed.path, e)))?;
|
||||
format!("m2ts://{}: {}", path.display(), e)))?;
|
||||
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::open(reader)?))
|
||||
}
|
||||
"mkv" => {
|
||||
validate_file_path(&parsed.path, "mkv")?;
|
||||
let file = std::fs::File::open(&parsed.path)
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("mkv://{}: {}", parsed.path, e)))?;
|
||||
format!("mkv://{}: {}", path.display(), e)))?;
|
||||
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::open(reader)?))
|
||||
}
|
||||
"network" => {
|
||||
validate_network_addr(&parsed.path)?;
|
||||
Ok(Box::new(NetworkStream::listen(&parsed.path)?))
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::listen(addr)?))
|
||||
}
|
||||
"stdio" => {
|
||||
StreamUrl::Stdio => {
|
||||
Ok(Box::new(StdioStream::input()))
|
||||
}
|
||||
"iso" => {
|
||||
validate_file_path(&parsed.path, "iso")?;
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
let scan_opts = match &opts.keydb_path {
|
||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
Ok(Box::new(IsoStream::open(&parsed.path, opts.title_index, &scan_opts)?))
|
||||
Ok(Box::new(IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?))
|
||||
}
|
||||
"null" => {
|
||||
StreamUrl::Null => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
"null:// is write-only — cannot use as input"))
|
||||
}
|
||||
"unknown" => {
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", parsed.path)))
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw)))
|
||||
}
|
||||
_ => Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("unknown scheme: {}://", parsed.scheme))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,47 +223,50 @@ 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>> {
|
||||
let parsed = parse_url(url);
|
||||
|
||||
match parsed.scheme.as_str() {
|
||||
"disc" => {
|
||||
match parsed {
|
||||
StreamUrl::Disc { .. } => {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"disc:// is read-only — cannot use as output"))
|
||||
}
|
||||
"iso" => {
|
||||
validate_file_path(&parsed.path, "iso")?;
|
||||
Ok(Box::new(IsoStream::create(&parsed.path)?.meta(meta)))
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
Ok(Box::new(IsoStream::create(&path.to_string_lossy())?.meta(meta)))
|
||||
}
|
||||
"null" => {
|
||||
StreamUrl::Null => {
|
||||
Ok(Box::new(NullStream::new().meta(meta)))
|
||||
}
|
||||
"stdio" => {
|
||||
StreamUrl::Stdio => {
|
||||
Ok(Box::new(StdioStream::output().meta(meta)))
|
||||
}
|
||||
"m2ts" => {
|
||||
validate_file_path(&parsed.path, "m2ts")?;
|
||||
let file = std::fs::File::create(&parsed.path)
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::create(path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("m2ts://{}: {}", parsed.path, e)))?;
|
||||
format!("m2ts://{}: {}", path.display(), e)))?;
|
||||
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::new(writer).meta(meta)))
|
||||
}
|
||||
"mkv" => {
|
||||
validate_file_path(&parsed.path, "mkv")?;
|
||||
let file = std::fs::File::create(&parsed.path)
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::create(path)
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("mkv://{}: {}", parsed.path, e)))?;
|
||||
format!("mkv://{}: {}", path.display(), e)))?;
|
||||
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(MKV_LOOKAHEAD)))
|
||||
let lookahead = if meta.streams.len() > 15 {
|
||||
MKV_LOOKAHEAD_UHD
|
||||
} else {
|
||||
MKV_LOOKAHEAD_DEFAULT
|
||||
};
|
||||
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead)))
|
||||
}
|
||||
"network" => {
|
||||
validate_network_addr(&parsed.path)?;
|
||||
Ok(Box::new(NetworkStream::connect(&parsed.path)?.meta(meta)))
|
||||
StreamUrl::Network { ref addr } => {
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::connect(addr)?.meta(meta)))
|
||||
}
|
||||
"unknown" => {
|
||||
StreamUrl::Unknown { ref raw } => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", parsed.path)))
|
||||
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", raw)))
|
||||
}
|
||||
_ => Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("unknown scheme: {}://", parsed.scheme))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ pub mod mt1959;
|
||||
use crate::error::Result;
|
||||
use crate::scsi::ScsiTransport;
|
||||
|
||||
pub(crate) trait PlatformDriver {
|
||||
pub(crate) trait PlatformDriver: Send {
|
||||
/// Unlock drive + upload firmware if needed.
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ pub struct ScsiResult {
|
||||
}
|
||||
|
||||
/// Low-level SCSI transport — one implementation per platform.
|
||||
pub trait ScsiTransport {
|
||||
pub trait ScsiTransport: Send {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//! SectorReader — trait for reading 2048-byte disc sectors.
|
||||
//!
|
||||
//! Implemented by DriveSession (SCSI) and IsoFile (file-backed).
|
||||
//! Implemented by Drive (SCSI) and IsoFile (file-backed).
|
||||
//! Used by UDF parser, disc scanner, label parsers — anything that
|
||||
//! reads sectors doesn't need to know where they come from.
|
||||
|
||||
|
||||
+4
-4
@@ -288,7 +288,7 @@ impl UdfFs {
|
||||
|
||||
let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]);
|
||||
let extent_type = raw_len >> 30;
|
||||
let data_len = raw_len & 0x3FFFFFFF;
|
||||
let data_len = raw_len & 0x3FFF_FFFF;
|
||||
let data_lba =
|
||||
u32::from_le_bytes([icb[off + 4], icb[off + 5], icb[off + 6], icb[off + 7]]);
|
||||
|
||||
@@ -455,7 +455,7 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
meta_icb[ad_off + 1],
|
||||
meta_icb[ad_off + 2],
|
||||
meta_icb[ad_off + 3],
|
||||
]) & 0x3FFFFFFF;
|
||||
]) & 0x3FFF_FFFF;
|
||||
metadata_size_bytes = ad_len;
|
||||
let ad_pos = u32::from_le_bytes([
|
||||
meta_icb[ad_off + 4],
|
||||
@@ -543,7 +543,7 @@ fn read_directory(
|
||||
icb[ad_off + 1],
|
||||
icb[ad_off + 2],
|
||||
icb[ad_off + 3],
|
||||
]) & 0x3FFFFFFF;
|
||||
]) & 0x3FFF_FFFF;
|
||||
let pos = u32::from_le_bytes([
|
||||
icb[ad_off + 4],
|
||||
icb[ad_off + 5],
|
||||
@@ -565,7 +565,7 @@ fn read_directory(
|
||||
icb[ad_off + 1],
|
||||
icb[ad_off + 2],
|
||||
icb[ad_off + 3],
|
||||
]) & 0x3FFFFFFF;
|
||||
]) & 0x3FFF_FFFF;
|
||||
let pos = u32::from_le_bytes([
|
||||
icb[ad_off + 4],
|
||||
icb[ad_off + 5],
|
||||
|
||||
+17
-17
@@ -59,56 +59,56 @@ fn sample_disc_title() -> DiscTitle {
|
||||
#[test]
|
||||
fn parse_url_disc() {
|
||||
let u = parse_url("disc://");
|
||||
assert_eq!(u.scheme, "disc");
|
||||
assert_eq!(u.path, "");
|
||||
assert_eq!(u.scheme(), "disc");
|
||||
assert_eq!(u.path_str(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_disc_device() {
|
||||
let u = parse_url("disc:///dev/sg4");
|
||||
assert_eq!(u.scheme, "disc");
|
||||
assert_eq!(u.path, "/dev/sg4");
|
||||
assert_eq!(u.scheme(), "disc");
|
||||
assert_eq!(u.path_str(), "/dev/sg4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_mkv() {
|
||||
let u = parse_url("mkv://Dune.mkv");
|
||||
assert_eq!(u.scheme, "mkv");
|
||||
assert_eq!(u.path, "Dune.mkv");
|
||||
assert_eq!(u.scheme(), "mkv");
|
||||
assert_eq!(u.path_str(), "Dune.mkv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_network() {
|
||||
let u = parse_url("network://10.0.0.1:9000");
|
||||
assert_eq!(u.scheme, "network");
|
||||
assert_eq!(u.path, "10.0.0.1:9000");
|
||||
assert_eq!(u.scheme(), "network");
|
||||
assert_eq!(u.path_str(), "10.0.0.1:9000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_bare_path_rejected() {
|
||||
let u = parse_url("Dune.mkv");
|
||||
assert_eq!(u.scheme, "unknown");
|
||||
assert_eq!(u.scheme(), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_null() {
|
||||
let u = parse_url("null://");
|
||||
assert_eq!(u.scheme, "null");
|
||||
assert_eq!(u.path, "");
|
||||
assert_eq!(u.scheme(), "null");
|
||||
assert_eq!(u.path_str(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_m2ts_with_path() {
|
||||
let u = parse_url("m2ts:///tmp/Dune.m2ts");
|
||||
assert_eq!(u.scheme, "m2ts");
|
||||
assert_eq!(u.path, "/tmp/Dune.m2ts");
|
||||
assert_eq!(u.scheme(), "m2ts");
|
||||
assert_eq!(u.path_str(), "/tmp/Dune.m2ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_m2ts_relative() {
|
||||
let u = parse_url("m2ts://Dune.m2ts");
|
||||
assert_eq!(u.scheme, "m2ts");
|
||||
assert_eq!(u.path, "Dune.m2ts");
|
||||
assert_eq!(u.scheme(), "m2ts");
|
||||
assert_eq!(u.path_str(), "Dune.m2ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -182,8 +182,8 @@ fn open_input_network_no_port_errors() {
|
||||
#[test]
|
||||
fn parse_url_stdio() {
|
||||
let u = parse_url("stdio://");
|
||||
assert_eq!(u.scheme, "stdio");
|
||||
assert_eq!(u.path, "");
|
||||
assert_eq!(u.scheme(), "stdio");
|
||||
assert_eq!(u.path_str(), "");
|
||||
}
|
||||
|
||||
// ── M2TS metadata roundtrip ───────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user